mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 00:03:11 +00:00
refactor: simplify ai chat db draft deploys
This commit is contained in:
@@ -166,6 +166,141 @@ const toolCallbacks: ToolCallbacks = {
|
||||
removeToolStatus: vi.fn()
|
||||
}
|
||||
|
||||
const scriptDrafts = new Map<string, any>()
|
||||
const flowDrafts = new Map<string, any>()
|
||||
const appDrafts = new Map<string, any>()
|
||||
|
||||
function serviceMock(fn: unknown): any {
|
||||
return vi.mocked(fn as any) as any
|
||||
}
|
||||
|
||||
function resetDbDraftMocks(): void {
|
||||
scriptDrafts.clear()
|
||||
flowDrafts.clear()
|
||||
appDrafts.clear()
|
||||
|
||||
serviceMock(DraftService.createDraft).mockImplementation(async ({ requestBody }: any) => {
|
||||
const { path, typ, value } = requestBody
|
||||
if (typ === 'script') scriptDrafts.set(path, structuredClone(value))
|
||||
if (typ === 'flow') flowDrafts.set(path, structuredClone(value))
|
||||
if (typ === 'app') appDrafts.set(path, structuredClone(value))
|
||||
return 'draft created'
|
||||
})
|
||||
serviceMock(DraftService.deleteDraft).mockImplementation(async ({ kind, path }: any) => {
|
||||
if (kind === 'script') scriptDrafts.delete(path)
|
||||
if (kind === 'flow') flowDrafts.delete(path)
|
||||
if (kind === 'app') appDrafts.delete(path)
|
||||
return 'draft deleted'
|
||||
})
|
||||
|
||||
serviceMock(ScriptService.existsScriptByPath).mockImplementation(async ({ path }: any) =>
|
||||
scriptDrafts.has(path)
|
||||
)
|
||||
serviceMock(ScriptService.getScriptByPathWithDraft).mockImplementation(async ({ path }: any) => {
|
||||
const draft = scriptDrafts.get(path)
|
||||
if (!draft) throw new Error('getScriptByPathWithDraft mock not configured')
|
||||
return {
|
||||
...draft,
|
||||
hash: 'draft-anchor-hash',
|
||||
draft_only: true,
|
||||
draft: structuredClone(draft)
|
||||
} as any
|
||||
})
|
||||
serviceMock(ScriptService.getScriptByPath).mockImplementation(async ({ path }: any) => {
|
||||
const draft = scriptDrafts.get(path)
|
||||
if (!draft) throw new Error('getScriptByPath mock not configured')
|
||||
return { ...structuredClone(draft), hash: 'draft-anchor-hash', draft_only: true } as any
|
||||
})
|
||||
serviceMock(ScriptService.listScripts).mockImplementation(async ({ pathStart, perPage }: any) =>
|
||||
Array.from(scriptDrafts.values())
|
||||
.filter((draft) => !pathStart || draft.path.startsWith(pathStart))
|
||||
.slice(0, perPage)
|
||||
.map((draft) => ({
|
||||
path: draft.path,
|
||||
summary: draft.summary,
|
||||
language: draft.language,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
}))
|
||||
)
|
||||
serviceMock(ScriptService.createScript).mockImplementation(async ({ requestBody }: any) => {
|
||||
if (!requestBody.draft_only) scriptDrafts.delete(requestBody.path)
|
||||
return 'created'
|
||||
})
|
||||
|
||||
serviceMock(FlowService.existsFlowByPath).mockImplementation(async ({ path }: any) =>
|
||||
flowDrafts.has(path)
|
||||
)
|
||||
serviceMock(FlowService.getFlowByPathWithDraft).mockImplementation(async ({ path }: any) => {
|
||||
const draft = flowDrafts.get(path)
|
||||
if (!draft) throw new Error('getFlowByPathWithDraft mock not configured')
|
||||
return {
|
||||
...draft,
|
||||
draft_only: true,
|
||||
draft: structuredClone(draft)
|
||||
} as any
|
||||
})
|
||||
serviceMock(FlowService.getFlowByPath).mockImplementation(async ({ path }: any) => {
|
||||
const draft = flowDrafts.get(path)
|
||||
if (!draft) throw new Error('getFlowByPath mock not configured')
|
||||
return { ...structuredClone(draft), draft_only: true } as any
|
||||
})
|
||||
serviceMock(FlowService.listFlows).mockImplementation(async ({ pathStart, perPage }: any) =>
|
||||
Array.from(flowDrafts.values())
|
||||
.filter((draft) => !pathStart || draft.path.startsWith(pathStart))
|
||||
.slice(0, perPage)
|
||||
.map((draft) => ({
|
||||
path: draft.path,
|
||||
summary: draft.summary,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
}))
|
||||
)
|
||||
serviceMock(FlowService.createFlow).mockImplementation(async ({ requestBody }: any) => {
|
||||
if (!requestBody.draft_only) flowDrafts.delete(requestBody.path)
|
||||
return 'created'
|
||||
})
|
||||
serviceMock(FlowService.updateFlow).mockImplementation(async ({ path }: any) => {
|
||||
flowDrafts.delete(path)
|
||||
return 'updated'
|
||||
})
|
||||
|
||||
serviceMock(AppService.existsApp).mockImplementation(async ({ path }: any) => appDrafts.has(path))
|
||||
serviceMock(AppService.getAppByPathWithDraft).mockImplementation(async ({ path }: any) => {
|
||||
const draft = appDrafts.get(path)
|
||||
if (!draft) throw new Error('getAppByPathWithDraft mock not configured')
|
||||
const value = draft.value ?? {}
|
||||
return {
|
||||
path,
|
||||
summary: draft.summary ?? '',
|
||||
value,
|
||||
policy: draft.policy,
|
||||
custom_path: draft.custom_path,
|
||||
draft_only: true,
|
||||
draft: structuredClone(draft)
|
||||
} as any
|
||||
})
|
||||
serviceMock(AppService.listApps).mockImplementation(async ({ pathStart, perPage }: any) =>
|
||||
Array.from(appDrafts.values())
|
||||
.filter((draft) => !pathStart || draft.path.startsWith(pathStart))
|
||||
.slice(0, perPage)
|
||||
.map((draft) => ({
|
||||
path: draft.path,
|
||||
summary: draft.summary,
|
||||
has_draft: true,
|
||||
draft_only: true
|
||||
}))
|
||||
)
|
||||
serviceMock(AppService.createAppRaw).mockImplementation(async ({ formData }: any) => {
|
||||
if (!formData.app.draft_only) appDrafts.delete(formData.app.path)
|
||||
return 'created'
|
||||
})
|
||||
serviceMock(AppService.updateAppRaw).mockImplementation(async ({ path }: any) => {
|
||||
appDrafts.delete(path)
|
||||
return 'updated'
|
||||
})
|
||||
}
|
||||
|
||||
function getGlobalTool(name: string): Tool<{}> {
|
||||
const tool = globalTools.find((candidate) => candidate.def.function.name === name)
|
||||
if (!tool) {
|
||||
@@ -215,6 +350,7 @@ describe('global AI tools', () => {
|
||||
localStorage.clear()
|
||||
clearGlobalDrafts(WORKSPACE)
|
||||
vi.clearAllMocks()
|
||||
resetDbDraftMocks()
|
||||
})
|
||||
|
||||
it('defaults the datatable instruction subject to the TypeScript SQL SDK', async () => {
|
||||
@@ -437,24 +573,23 @@ describe('global AI tools', () => {
|
||||
expect(VariableService.updateVariable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes script drafts into UserDraft', async () => {
|
||||
it('writes script drafts into DB drafts without a non-live UserDraft mirror', async () => {
|
||||
const content = 'export async function main() {\n\treturn "hello"\n}'
|
||||
|
||||
await callGlobalTool('write_script', {
|
||||
const raw = await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/hello',
|
||||
summary: 'Hello script',
|
||||
language: 'bun',
|
||||
content
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
|
||||
{
|
||||
path: 'f/scripts/hello',
|
||||
summary: 'Hello script',
|
||||
language: 'bun',
|
||||
content
|
||||
}
|
||||
)
|
||||
expect(UserDraft.get('script', 'f/scripts/hello', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(JSON.parse(raw).item).toMatchObject({
|
||||
path: 'f/scripts/hello',
|
||||
summary: 'Hello script',
|
||||
language: 'bun',
|
||||
value: content
|
||||
})
|
||||
expect(ScriptService.createScript).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: expect.objectContaining({
|
||||
@@ -1107,7 +1242,7 @@ describe('global AI tools', () => {
|
||||
expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discards a local draft without deleting the workspace item', async () => {
|
||||
it('discards a DB draft and its draft-only anchor without a local mirror', async () => {
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/discard-me',
|
||||
summary: 'Temporary draft',
|
||||
@@ -1115,7 +1250,9 @@ describe('global AI tools', () => {
|
||||
content: 'export async function main() { return 1 }'
|
||||
})
|
||||
|
||||
expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined()
|
||||
expect(
|
||||
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
|
||||
).toBeUndefined()
|
||||
|
||||
const raw = await callGlobalTool('discard_local_draft', {
|
||||
type: 'script',
|
||||
@@ -1128,6 +1265,16 @@ describe('global AI tools', () => {
|
||||
path: 'f/scripts/discard-me'
|
||||
})
|
||||
expect(raw).toContain('The deployed workspace item was not changed')
|
||||
expect(DraftService.deleteDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'script',
|
||||
path: 'f/scripts/discard-me'
|
||||
})
|
||||
expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
path: 'f/scripts/discard-me',
|
||||
keepCaptures: true
|
||||
})
|
||||
expect(
|
||||
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
|
||||
).toBeUndefined()
|
||||
@@ -1170,19 +1317,21 @@ describe('global AI tools', () => {
|
||||
content: 'new content'
|
||||
})
|
||||
|
||||
expect(
|
||||
UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
|
||||
).toMatchObject({
|
||||
path: 'f/scripts/existing',
|
||||
parent_hash: 'deployed-hash',
|
||||
summary: 'new summary',
|
||||
description: 'db draft description',
|
||||
content: 'new content',
|
||||
language: 'bun'
|
||||
})
|
||||
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 'deployed-hash',
|
||||
remoteDraftRev: '2026-05-22T10:00:00Z'
|
||||
expect(UserDraft.get('script', 'f/scripts/existing', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(DraftService.createDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/scripts/existing',
|
||||
typ: 'script',
|
||||
value: expect.objectContaining({
|
||||
path: 'f/scripts/existing',
|
||||
parent_hash: 'deployed-hash',
|
||||
summary: 'new summary',
|
||||
description: 'db draft description',
|
||||
content: 'new content',
|
||||
language: 'bun'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1219,15 +1368,19 @@ describe('global AI tools', () => {
|
||||
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
|
||||
})
|
||||
|
||||
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
|
||||
path: 'f/flows/existing',
|
||||
summary: 'new summary',
|
||||
description: 'db draft description',
|
||||
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
|
||||
})
|
||||
expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 42,
|
||||
remoteDraftRev: '2026-05-22T10:00:00Z'
|
||||
expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(DraftService.createDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/flows/existing',
|
||||
typ: 'flow',
|
||||
value: expect.objectContaining({
|
||||
path: 'f/flows/existing',
|
||||
summary: 'new summary',
|
||||
description: 'db draft description',
|
||||
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1374,26 +1527,34 @@ describe('global AI tools', () => {
|
||||
content: 'export default function New() { return null }'
|
||||
})
|
||||
|
||||
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
|
||||
expect(draft).toMatchObject({
|
||||
summary: 'saved app draft',
|
||||
files: {
|
||||
'/src/App.tsx': 'draft content',
|
||||
'/src/New.tsx': 'export default function New() { return null }'
|
||||
},
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: { language: 'bun', content: 'export async function main() {}' }
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
|
||||
policy: { execution_mode: 'anonymous' },
|
||||
custom_path: 'report'
|
||||
})
|
||||
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 4,
|
||||
remoteDraftRev: '2026-05-22T10:30:00Z'
|
||||
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
|
||||
expect(DraftService.createDraft).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
requestBody: {
|
||||
path: 'f/apps/report',
|
||||
typ: 'app',
|
||||
value: expect.objectContaining({
|
||||
summary: 'saved app draft',
|
||||
value: expect.objectContaining({
|
||||
files: {
|
||||
'/src/App.tsx': 'draft content',
|
||||
'/src/New.tsx': 'export default function New() { return null }'
|
||||
},
|
||||
runnables: {
|
||||
main: {
|
||||
type: 'inline',
|
||||
inlineScript: {
|
||||
language: 'bun',
|
||||
content: 'export async function main() {}'
|
||||
}
|
||||
}
|
||||
},
|
||||
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
|
||||
}),
|
||||
policy: { execution_mode: 'anonymous' },
|
||||
custom_path: 'report'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2098,7 +2259,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_step prefers local script drafts for script steps', async () => {
|
||||
it('test_run_step previews DB-backed script drafts for script steps', async () => {
|
||||
const content = 'export async function main(name: string) {\n\treturn `draft ${name}`\n}'
|
||||
await callGlobalTool('write_script', {
|
||||
path: 'f/scripts/step-script',
|
||||
@@ -2141,7 +2302,7 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('test_run_step previews local draft subflows for flow steps', async () => {
|
||||
it('test_run_step previews DB-backed draft subflows for flow steps', async () => {
|
||||
const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }]
|
||||
await callGlobalTool('write_flow', {
|
||||
path: 'f/flows/nested-draft',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
AppService,
|
||||
AzureTriggerService,
|
||||
DraftService,
|
||||
FlowService,
|
||||
GcpTriggerService,
|
||||
HttpTriggerService,
|
||||
@@ -19,29 +18,24 @@ import {
|
||||
} from '$lib/gen'
|
||||
import { $ScriptLang } from '$lib/gen/schemas.gen'
|
||||
import type {
|
||||
AppWithLastVersion,
|
||||
CreateResource,
|
||||
CreateVariable,
|
||||
Flow,
|
||||
FlowValue,
|
||||
ListableApp,
|
||||
ListableResource,
|
||||
ListableVariable,
|
||||
NewSchedule,
|
||||
NewScript,
|
||||
Resource,
|
||||
Schedule,
|
||||
Script,
|
||||
ScriptLang
|
||||
} from '$lib/gen/types.gen'
|
||||
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
|
||||
import {
|
||||
FRAMEWORK_TEMPLATES,
|
||||
STARTER_RUNNABLE,
|
||||
STARTER_RUNNABLE_KEY,
|
||||
type FrameworkKey
|
||||
} from '$lib/components/raw_apps/templates'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
applyEditableFlowJsonToFlow,
|
||||
buildEditableFlowJson,
|
||||
@@ -74,9 +68,8 @@ import {
|
||||
} from '../shared'
|
||||
import type { ContextElement } from '../context'
|
||||
import { getDatatableTools } from '../datatableTools'
|
||||
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import {
|
||||
resourceRequestSchema,
|
||||
scheduleRequestSchema,
|
||||
@@ -96,14 +89,27 @@ import {
|
||||
type WorkspaceItemType
|
||||
} from './workspaceItems'
|
||||
import {
|
||||
buildFlowDeployRequestBody,
|
||||
buildScriptDeployRequestBody,
|
||||
type FlowDeployMetadata,
|
||||
type ScriptDeployMetadata
|
||||
} from './deployRequests'
|
||||
appToItem,
|
||||
deleteDbDraftAndDraftOnlyAnchor,
|
||||
deployAppDraft,
|
||||
deployFlowDraft,
|
||||
deployScriptDraft,
|
||||
flowToItem,
|
||||
isDbDraftWorkspaceItemType,
|
||||
loadAppDraftValue,
|
||||
loadAppValueForRead,
|
||||
loadFlowWithDbDraft,
|
||||
loadScriptWithDbDraft,
|
||||
loadWorkspaceDraft,
|
||||
loadWorkspaceDraftForDeploy,
|
||||
recomputeAppPolicy,
|
||||
saveAppDraft,
|
||||
saveFlowDraftToDb,
|
||||
saveScriptDraftToDb,
|
||||
scriptToItem
|
||||
} from './workspaceDrafts'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
import {
|
||||
clearEphemeralSecretVariableDraftValue,
|
||||
deleteGlobalDraft,
|
||||
@@ -111,8 +117,8 @@ import {
|
||||
getGlobalDraft,
|
||||
getGlobalDraftStoragePath,
|
||||
listGlobalDrafts,
|
||||
saveGlobalAppDraft,
|
||||
setEphemeralSecretVariableDraftValue,
|
||||
syncLiveGlobalDraft,
|
||||
triggerKindToUserDraftKind
|
||||
} from './userDraftAdapter'
|
||||
|
||||
@@ -650,57 +656,6 @@ function itemMatches(
|
||||
)
|
||||
}
|
||||
|
||||
type DraftFlagged = {
|
||||
has_draft?: boolean
|
||||
draft_only?: boolean
|
||||
}
|
||||
|
||||
type ReadableScript = Pick<Script, 'path' | 'summary' | 'language' | 'content'> & DraftFlagged
|
||||
|
||||
function scriptToItem(
|
||||
script: ReadableScript,
|
||||
includeValue: boolean,
|
||||
isDraft = !!script.has_draft || !!script.draft_only
|
||||
): WorkspaceItem {
|
||||
return {
|
||||
type: 'script',
|
||||
path: script.path,
|
||||
summary: script.summary,
|
||||
language: script.language,
|
||||
value: includeValue ? script.content : undefined,
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
function flowToItem(
|
||||
flow: Pick<Flow, 'path' | 'summary' | 'value' | 'schema'> & DraftFlagged,
|
||||
includeValue: boolean,
|
||||
isDraft = !!flow.has_draft || !!flow.draft_only
|
||||
): WorkspaceItem {
|
||||
return {
|
||||
type: 'flow',
|
||||
path: flow.path,
|
||||
summary: flow.summary,
|
||||
value: includeValue
|
||||
? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null }
|
||||
: undefined,
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScriptWithDbDraft(path: string, workspace: string): Promise<ReadableScript> {
|
||||
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
return script.draft ? { ...script, ...script.draft, path: script.path } : script
|
||||
}
|
||||
|
||||
async function loadFlowWithDbDraft(
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<Pick<Flow, 'path' | 'summary' | 'value' | 'schema'>> {
|
||||
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
return flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a flow workspace item into the compact response we send to the model:
|
||||
* rawscript content is replaced with `inline_script.<moduleId>` placeholders.
|
||||
@@ -896,12 +851,6 @@ type AppMetadata = {
|
||||
data?: any
|
||||
}
|
||||
|
||||
type LoadedAppDraftValue = {
|
||||
value: AppDraftValue
|
||||
meta?: UserDraftMeta
|
||||
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
|
||||
}
|
||||
|
||||
function summarizeAppValue(value: AppDraftValue): AppMetadata {
|
||||
const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(
|
||||
([path, content]) => ({
|
||||
@@ -935,19 +884,6 @@ function summarizeAppValue(value: AppDraftValue): AppMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
function appToItem(
|
||||
app: (ListableApp | AppWithLastVersion) & DraftFlagged,
|
||||
includeValue: boolean
|
||||
): WorkspaceItem {
|
||||
return {
|
||||
type: 'app',
|
||||
path: app.path,
|
||||
summary: app.summary,
|
||||
value: includeValue ? ((app as AppWithLastVersion).value as AppDraftValue) : undefined,
|
||||
isDraft: !!app.has_draft || !!app.draft_only
|
||||
}
|
||||
}
|
||||
|
||||
const GENERATED_APP_FILE_PATHS = new Set(['/wmill.d.ts'])
|
||||
|
||||
function assertNotGeneratedAppFile(filePath: string): void {
|
||||
@@ -1011,77 +947,6 @@ function getInlineRunnableContent(
|
||||
return { content: runnable.inlineScript?.content ?? '', runnable }
|
||||
}
|
||||
|
||||
function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
|
||||
if (value.data?.creation) {
|
||||
return {
|
||||
tables: value.data.tables ?? [],
|
||||
datatable: value.data.creation.datatable,
|
||||
schema: value.data.creation.schema
|
||||
}
|
||||
}
|
||||
if (value.data) {
|
||||
return value.data
|
||||
}
|
||||
if (value.datatables) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
|
||||
}
|
||||
if (value.dataTableRefs) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
|
||||
}
|
||||
return { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
|
||||
function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
|
||||
const value = (app.value ?? {}) as Record<string, any>
|
||||
return {
|
||||
summary: app.summary ?? '',
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: normalizeRawAppData(value),
|
||||
policy: app.policy ?? fallback?.policy,
|
||||
custom_path: app.custom_path ?? fallback?.custom_path
|
||||
}
|
||||
}
|
||||
|
||||
function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
|
||||
return {
|
||||
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined,
|
||||
remoteDraftRev: app.draft_created_at
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
|
||||
const draft = getGlobalDraft(workspace, 'app', path)
|
||||
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
|
||||
return draft.value as AppDraftValue
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
return appSourceToDraftValue(app.draft ?? app, app)
|
||||
}
|
||||
|
||||
async function loadAppDraftValue(path: string, workspace: string): Promise<LoadedAppDraftValue> {
|
||||
const draft = getGlobalDraft(workspace, 'app', path)
|
||||
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
|
||||
return { value: draft.value as AppDraftValue }
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
const value = appSourceToDraftValue(app.draft ?? app, app)
|
||||
return { value, meta: appDraftMeta(app), existing: app }
|
||||
}
|
||||
|
||||
async function saveAppDraft(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
meta?: UserDraftMeta,
|
||||
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
|
||||
): Promise<WorkspaceItem> {
|
||||
await saveAppDraftToDb(workspace, path, value, existing)
|
||||
return saveGlobalAppDraft(workspace, path, value, meta)
|
||||
}
|
||||
|
||||
type TriggerLike = { path: string; summary?: string | null }
|
||||
|
||||
function triggerToItem(
|
||||
@@ -1218,12 +1083,11 @@ async function readWorkspaceItem(
|
||||
)
|
||||
case 'app': {
|
||||
// Returns lightweight metadata only — file/runnable contents come via read_app_file.
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
const value = appSourceToDraftValue(app.draft ?? app)
|
||||
const value = await loadAppValueForRead(path, workspace)
|
||||
const metadata = summarizeAppValue(value)
|
||||
return {
|
||||
type: 'app',
|
||||
path: app.path,
|
||||
path,
|
||||
summary: value.summary,
|
||||
value: metadata as unknown as AppDraftValue,
|
||||
isDraft: false
|
||||
@@ -2169,172 +2033,6 @@ function buildVariableDeployRequestBody(
|
||||
return { ...requestBody, value: secretValue }
|
||||
}
|
||||
|
||||
type DbDraftWorkspaceItemType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
|
||||
type DeployableDraft = {
|
||||
item: WorkspaceItem
|
||||
scriptMetadata?: ScriptDeployMetadata
|
||||
flowMetadata?: FlowDeployMetadata
|
||||
}
|
||||
|
||||
function isDbDraftWorkspaceItemType(type: WorkspaceItemType): type is DbDraftWorkspaceItemType {
|
||||
return type === 'script' || type === 'flow' || type === 'app'
|
||||
}
|
||||
|
||||
async function createDbDraft(
|
||||
workspace: string,
|
||||
typ: DbDraftWorkspaceItemType,
|
||||
path: string,
|
||||
value: unknown
|
||||
): Promise<void> {
|
||||
await DraftService.createDraft({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path,
|
||||
typ,
|
||||
value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteDbDraft(
|
||||
workspace: string,
|
||||
typ: DbDraftWorkspaceItemType,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
await DraftService.deleteDraft({ workspace, kind: typ, path })
|
||||
}
|
||||
|
||||
async function deleteDbDraftAndDraftOnlyAnchor(
|
||||
workspace: string,
|
||||
typ: DbDraftWorkspaceItemType,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
switch (typ) {
|
||||
case 'script': {
|
||||
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
|
||||
? await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'flow': {
|
||||
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
|
||||
? await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'app': {
|
||||
const existing = (await AppService.existsApp({ workspace, path }))
|
||||
? await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await AppService.deleteApp({ workspace, path })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDbDraftForDeploy(
|
||||
workspace: string,
|
||||
type: DbDraftWorkspaceItemType,
|
||||
path: string
|
||||
): Promise<DeployableDraft | undefined> {
|
||||
switch (type) {
|
||||
case 'script': {
|
||||
if (!(await ScriptService.existsScriptByPath({ workspace, path }))) return undefined
|
||||
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
if (!script.draft && !script.draft_only) return undefined
|
||||
const draftScript = script.draft ? { ...script, ...script.draft, path: script.path } : script
|
||||
return {
|
||||
item: scriptToItem(draftScript, true, true),
|
||||
scriptMetadata: draftScript
|
||||
}
|
||||
}
|
||||
case 'flow': {
|
||||
if (!(await FlowService.existsFlowByPath({ workspace, path }))) return undefined
|
||||
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
if (!flow.draft && !flow.draft_only) return undefined
|
||||
const draftFlow = flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
|
||||
return {
|
||||
item: flowToItem(draftFlow, true, true),
|
||||
flowMetadata: draftFlow
|
||||
}
|
||||
}
|
||||
case 'app': {
|
||||
if (!(await AppService.existsApp({ workspace, path }))) return undefined
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
if (!app.draft && !app.draft_only) return undefined
|
||||
const value = appSourceToDraftValue(app.draft ?? app, app)
|
||||
return {
|
||||
item: {
|
||||
type: 'app',
|
||||
path: app.path,
|
||||
summary: value.summary,
|
||||
value,
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDbDraftItem(
|
||||
workspace: string,
|
||||
type: DbDraftWorkspaceItemType,
|
||||
path: string
|
||||
): Promise<WorkspaceItem | undefined> {
|
||||
return (await loadDbDraftForDeploy(workspace, type, path))?.item
|
||||
}
|
||||
|
||||
async function getGlobalOrDbDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): Promise<WorkspaceItem | undefined> {
|
||||
const draft = getGlobalDraft(workspace, type, path, triggerKind)
|
||||
if (draft) return draft
|
||||
if (!isDbDraftWorkspaceItemType(type)) return undefined
|
||||
return loadDbDraftItem(workspace, type, path)
|
||||
}
|
||||
|
||||
async function getGlobalOrDbDraftForDeploy(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): Promise<DeployableDraft | undefined> {
|
||||
const item = getGlobalDraft(workspace, type, path, triggerKind)
|
||||
if (item) {
|
||||
const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind)
|
||||
switch (type) {
|
||||
case 'script':
|
||||
return {
|
||||
item,
|
||||
scriptMetadata: UserDraft.get<NewScript>('script', storagePath, { workspace })
|
||||
}
|
||||
case 'flow':
|
||||
return {
|
||||
item,
|
||||
flowMetadata: UserDraft.get<Flow>('flow', storagePath, { workspace })
|
||||
}
|
||||
default:
|
||||
return { item }
|
||||
}
|
||||
}
|
||||
if (!isDbDraftWorkspaceItemType(type)) return undefined
|
||||
return loadDbDraftForDeploy(workspace, type, path)
|
||||
}
|
||||
|
||||
function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void {
|
||||
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
|
||||
content: `Saving ${type} "${path}" as a draft...`
|
||||
@@ -2376,120 +2074,6 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra
|
||||
)
|
||||
}
|
||||
|
||||
async function saveScriptDraftToDb(
|
||||
workspace: string,
|
||||
path: string,
|
||||
draft: NewScript,
|
||||
existing: Awaited<ReturnType<typeof ScriptService.getScriptByPathWithDraft>> | undefined
|
||||
): Promise<void> {
|
||||
if (!existing) {
|
||||
await ScriptService.createScript({
|
||||
workspace,
|
||||
requestBody: {
|
||||
...draft,
|
||||
path,
|
||||
parent_hash: undefined,
|
||||
draft_only: true
|
||||
}
|
||||
})
|
||||
}
|
||||
await createDbDraft(workspace, 'script', path, draft)
|
||||
}
|
||||
|
||||
async function saveFlowDraftToDb(
|
||||
workspace: string,
|
||||
path: string,
|
||||
draft: Flow,
|
||||
existing: Awaited<ReturnType<typeof FlowService.getFlowByPathWithDraft>> | undefined
|
||||
): Promise<void> {
|
||||
if (!existing) {
|
||||
await FlowService.createFlow({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path,
|
||||
summary: draft.summary ?? '',
|
||||
description: (draft as any).description ?? '',
|
||||
value: draft.value,
|
||||
schema: draft.schema,
|
||||
tag: draft.tag,
|
||||
draft_only: true,
|
||||
ws_error_handler_muted: draft.ws_error_handler_muted,
|
||||
visible_to_runner_only: draft.visible_to_runner_only,
|
||||
on_behalf_of_email: draft.on_behalf_of_email,
|
||||
labels: draft.labels
|
||||
}
|
||||
})
|
||||
}
|
||||
await createDbDraft(workspace, 'flow', path, draft)
|
||||
}
|
||||
|
||||
function appDraftToDbValue(path: string, value: AppDraftValue): Record<string, unknown> {
|
||||
return {
|
||||
value: {
|
||||
files: value.files,
|
||||
runnables: value.runnables,
|
||||
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
},
|
||||
summary: value.summary ?? '',
|
||||
policy: value.policy,
|
||||
path,
|
||||
custom_path: value.custom_path
|
||||
}
|
||||
}
|
||||
|
||||
async function createRawAppDraftOnlyAnchor(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue
|
||||
): Promise<void> {
|
||||
await recomputeAppPolicy(value)
|
||||
const policy = value.policy
|
||||
if (!policy) {
|
||||
throw new Error(`Draft app "${path}" has no policy to save.`)
|
||||
}
|
||||
const bundle = await bundleRawAppDraft({
|
||||
workspace,
|
||||
files: value.files,
|
||||
onLog: () => {}
|
||||
})
|
||||
await AppService.createAppRaw({
|
||||
workspace,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
value: {
|
||||
files: value.files,
|
||||
runnables: value.runnables,
|
||||
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
},
|
||||
summary: value.summary ?? '',
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: value.custom_path
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function saveAppDraftToDb(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
existing: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>> | undefined
|
||||
): Promise<void> {
|
||||
const current =
|
||||
existing ??
|
||||
((await AppService.existsApp({ workspace, path }))
|
||||
? await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
: undefined)
|
||||
if (!current) {
|
||||
await createRawAppDraftOnlyAnchor(workspace, path, value)
|
||||
}
|
||||
await createDbDraft(workspace, 'app', path, appDraftToDbValue(path, value))
|
||||
}
|
||||
|
||||
async function writeScriptDraft(
|
||||
args: { path: string; summary?: string; language: ScriptLang; content: string },
|
||||
ctx: WriteDraftCtx
|
||||
@@ -2528,23 +2112,18 @@ async function writeScriptDraft(
|
||||
|
||||
await saveScriptDraftToDb(workspace, args.path, draft, existing)
|
||||
|
||||
if (existing && !existing.draft_only) {
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
UserDraft.save('script', storagePath, draft, { workspace })
|
||||
}
|
||||
|
||||
return finishDraftWrite(
|
||||
getRequiredGlobalDraft(workspace, 'script', args.path),
|
||||
existingDraft !== undefined || existing !== undefined,
|
||||
ctx
|
||||
const mirrored = syncLiveGlobalDraft(
|
||||
workspace,
|
||||
'script',
|
||||
args.path,
|
||||
draft,
|
||||
existing && !existing.draft_only
|
||||
? { remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at }
|
||||
: undefined
|
||||
)
|
||||
const stored = mirrored ?? scriptToItem(draft, true, true)
|
||||
|
||||
return finishDraftWrite(stored, existingDraft !== undefined || existing !== undefined, ctx)
|
||||
}
|
||||
|
||||
async function writeFlowDraft(
|
||||
@@ -2587,24 +2166,22 @@ async function writeFlowDraft(
|
||||
|
||||
await saveFlowDraftToDb(workspace, args.path, draft, existing)
|
||||
|
||||
if (existing && !existing.draft_only) {
|
||||
const latestVersion = await FlowService.getFlowLatestVersion({ workspace, path: args.path })
|
||||
UserDraft.setDraftAndMeta(
|
||||
'flow',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
UserDraft.save('flow', storagePath, draft, { workspace })
|
||||
}
|
||||
|
||||
return finishDraftWrite(
|
||||
getRequiredGlobalDraft(workspace, 'flow', args.path),
|
||||
existingDraft !== undefined || existing !== undefined,
|
||||
ctx
|
||||
const latestVersion =
|
||||
existing && !existing.draft_only
|
||||
? await FlowService.getFlowLatestVersion({ workspace, path: args.path })
|
||||
: undefined
|
||||
const mirrored = syncLiveGlobalDraft(
|
||||
workspace,
|
||||
'flow',
|
||||
args.path,
|
||||
draft,
|
||||
latestVersion
|
||||
? { remoteRev: latestVersion.id, remoteDraftRev: existing?.draft_created_at }
|
||||
: undefined
|
||||
)
|
||||
const stored = mirrored ?? flowToItem(draft, true, true)
|
||||
|
||||
return finishDraftWrite(stored, existingDraft !== undefined || existing !== undefined, ctx)
|
||||
}
|
||||
|
||||
async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promise<string> {
|
||||
@@ -2903,7 +2480,8 @@ async function loadScriptForFlowStep(
|
||||
moduleValue: { path: string; hash?: string },
|
||||
workspace: string
|
||||
): Promise<{ content: string; language: ScriptLang }> {
|
||||
const draft = getGlobalDraft(workspace, 'script', moduleValue.path)
|
||||
const deployableDraft = await loadWorkspaceDraftForDeploy(workspace, 'script', moduleValue.path)
|
||||
const draft = deployableDraft?.item
|
||||
if (draft) {
|
||||
if (typeof draft.value !== 'string' || !draft.language) {
|
||||
throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`)
|
||||
@@ -2921,11 +2499,15 @@ async function loadDraftFlowPreviewValue(
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<FlowValue | undefined> {
|
||||
if (!getGlobalDraft(workspace, 'flow', path)) {
|
||||
const draft = await loadWorkspaceDraftForDeploy(workspace, 'flow', path)
|
||||
if (!draft) {
|
||||
return undefined
|
||||
}
|
||||
const nestedFlow = await loadFlowDraftValue(path, workspace)
|
||||
return flowDraftValueForPreview(nestedFlow.flow)
|
||||
const flowDraft = draft.item.value
|
||||
if (flowDraft === undefined || typeof flowDraft === 'string') {
|
||||
throw new Error(`Draft flow "${path}" has no value.`)
|
||||
}
|
||||
return flowDraftValueForPreview(flowDraft as FlowDraftValue)
|
||||
}
|
||||
|
||||
async function testRunScriptByPath(
|
||||
@@ -3265,17 +2847,6 @@ async function patchAppFile(
|
||||
)
|
||||
}
|
||||
|
||||
async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
|
||||
const policy = (await updateRawAppPolicy(
|
||||
value.runnables as any,
|
||||
value.policy as any
|
||||
)) as NonNullable<AppDraftValue['policy']>
|
||||
if (!policy.execution_mode) {
|
||||
policy.execution_mode = 'publisher'
|
||||
}
|
||||
value.policy = policy
|
||||
}
|
||||
|
||||
async function writeAppRunnable(
|
||||
args: { path: string; key: string; runnable: BackendRunnableInput },
|
||||
ctx: WriteDraftCtx
|
||||
@@ -3412,7 +2983,7 @@ async function discardLocalDraft(
|
||||
throw new Error('trigger_kind is required when discarding a trigger draft.')
|
||||
}
|
||||
|
||||
const draft = await getGlobalOrDbDraft(workspace, type, path, triggerKind)
|
||||
const draft = await loadWorkspaceDraft(workspace, type, path, triggerKind)
|
||||
if (!draft) {
|
||||
throw new Error(`No draft found for ${type} "${path}".`)
|
||||
}
|
||||
@@ -3455,7 +3026,7 @@ async function deployDraft(
|
||||
throw new Error('trigger_kind is required when deploying a trigger.')
|
||||
}
|
||||
|
||||
const deployableDraft = await getGlobalOrDbDraftForDeploy(workspace, type, path, triggerKind)
|
||||
const deployableDraft = await loadWorkspaceDraftForDeploy(workspace, type, path, triggerKind)
|
||||
if (!deployableDraft) {
|
||||
throw new Error(`No draft found for ${type} "${path}".`)
|
||||
}
|
||||
@@ -3472,45 +3043,23 @@ async function deployDraft(
|
||||
|
||||
switch (type) {
|
||||
case 'script': {
|
||||
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
|
||||
? await ScriptService.getScriptByPath({ workspace, path })
|
||||
: undefined
|
||||
const requestBody = buildScriptDeployRequestBody(
|
||||
await deployScriptDraft({
|
||||
workspace,
|
||||
path,
|
||||
draft,
|
||||
existing,
|
||||
deploymentMessage,
|
||||
deployableDraft.scriptMetadata
|
||||
)
|
||||
// Infer the arg schema from the content so it matches the code, like the editor does.
|
||||
try {
|
||||
const schema = emptySchema()
|
||||
await inferArgs(requestBody.language, requestBody.content, schema)
|
||||
requestBody.schema = schema
|
||||
} catch (e) {
|
||||
console.error('Failed to infer script schema before deploy', e)
|
||||
}
|
||||
await ScriptService.createScript({ workspace, requestBody })
|
||||
draftMetadata: deployableDraft.scriptMetadata
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'flow': {
|
||||
const flowDraft = draft.value as FlowDraftValue
|
||||
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
|
||||
? await FlowService.getFlowByPath({ workspace, path })
|
||||
: undefined
|
||||
const requestBody = buildFlowDeployRequestBody(
|
||||
await deployFlowDraft({
|
||||
workspace,
|
||||
path,
|
||||
draft.summary,
|
||||
flowDraft,
|
||||
existing,
|
||||
draft,
|
||||
deploymentMessage,
|
||||
deployableDraft.flowMetadata
|
||||
)
|
||||
if (existing) {
|
||||
await FlowService.updateFlow({ workspace, path, requestBody })
|
||||
} else {
|
||||
await FlowService.createFlow({ workspace, requestBody })
|
||||
}
|
||||
draftMetadata: deployableDraft.flowMetadata
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'schedule': {
|
||||
@@ -3561,84 +3110,15 @@ async function deployDraft(
|
||||
break
|
||||
}
|
||||
case 'app': {
|
||||
const appDraft = draft.value as AppDraftValue
|
||||
const appValue: AppDraftValue = {
|
||||
...appDraft,
|
||||
files: { ...(appDraft.files ?? {}) },
|
||||
runnables: { ...(appDraft.runnables ?? {}) },
|
||||
data: appDraft.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
await recomputeAppPolicy(appValue)
|
||||
const policy = appValue.policy
|
||||
if (!policy) {
|
||||
throw new Error(`Draft app "${path}" has no policy to deploy.`)
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Bundling app "${path}"...`
|
||||
})
|
||||
const bundle = await bundleRawAppDraft({
|
||||
await deployAppDraft({
|
||||
workspace,
|
||||
files: appValue.files,
|
||||
onLog: (delta) => {
|
||||
const lines = delta
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const latest = lines[lines.length - 1]
|
||||
if (latest) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Bundling app "${path}"... ${latest}`
|
||||
})
|
||||
}
|
||||
path,
|
||||
draft,
|
||||
deploymentMessage,
|
||||
onStatus: (content) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content })
|
||||
}
|
||||
})
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Deploying app "${path}"...`
|
||||
})
|
||||
const rawAppValue = {
|
||||
files: appValue.files,
|
||||
runnables: appValue.runnables,
|
||||
data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
const summary = appValue.summary ?? draft.summary ?? ''
|
||||
if (await AppService.existsApp({ workspace, path })) {
|
||||
// Omit custom_path on update for now. The backend preserves it when absent, while
|
||||
// sending it requires admin privileges; this chat deploy path does not yet mirror
|
||||
// the raw app editor's user/admin-specific custom_path handling.
|
||||
await AppService.updateAppRaw({
|
||||
workspace,
|
||||
path,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
deployment_message: deploymentMessage
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await AppService.createAppRaw({
|
||||
workspace,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
deployment_message: deploymentMessage,
|
||||
custom_path: appValue.custom_path
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,61 @@ function preserveOnBehalfOf(email: string | undefined): true | undefined {
|
||||
return email ? true : undefined
|
||||
}
|
||||
|
||||
function deployMetadataField<T extends object, K extends keyof T>(
|
||||
draftMetadata: T | undefined,
|
||||
const SCRIPT_DEPLOY_METADATA_FIELDS = [
|
||||
'schema',
|
||||
'is_template',
|
||||
'kind',
|
||||
'tag',
|
||||
'envs',
|
||||
'concurrent_limit',
|
||||
'concurrency_time_window_s',
|
||||
'debounce_key',
|
||||
'debounce_delay_s',
|
||||
'debounce_args_to_accumulate',
|
||||
'max_total_debouncing_time',
|
||||
'max_total_debounces_amount',
|
||||
'cache_ttl',
|
||||
'cache_ignore_s3_path',
|
||||
'dedicated_worker',
|
||||
'ws_error_handler_muted',
|
||||
'priority',
|
||||
'restart_unless_cancelled',
|
||||
'timeout',
|
||||
'delete_after_secs',
|
||||
'concurrency_key',
|
||||
'visible_to_runner_only',
|
||||
'auto_kind',
|
||||
'codebase',
|
||||
'has_preprocessor',
|
||||
'on_behalf_of_email',
|
||||
'assets',
|
||||
'modules',
|
||||
'labels'
|
||||
] as const satisfies readonly (keyof ScriptDeployMetadata)[]
|
||||
|
||||
const FLOW_DEPLOY_METADATA_FIELDS = [
|
||||
'tag',
|
||||
'ws_error_handler_muted',
|
||||
'priority',
|
||||
'dedicated_worker',
|
||||
'timeout',
|
||||
'visible_to_runner_only',
|
||||
'on_behalf_of_email',
|
||||
'labels'
|
||||
] as const satisfies readonly (keyof FlowDeployMetadata)[]
|
||||
|
||||
function mergeDeployMetadata<T extends object>(
|
||||
existing: T | undefined,
|
||||
key: K
|
||||
): T[K] | undefined {
|
||||
if (draftMetadata && Object.prototype.hasOwnProperty.call(draftMetadata, key)) {
|
||||
return draftMetadata[key]
|
||||
}
|
||||
return existing?.[key]
|
||||
draftMetadata: T | undefined
|
||||
): T {
|
||||
return { ...existing, ...draftMetadata } as T
|
||||
}
|
||||
|
||||
function pickDeployMetadata<T extends object, K extends keyof T>(
|
||||
source: T,
|
||||
fields: readonly K[]
|
||||
): Pick<T, K> {
|
||||
return Object.fromEntries(fields.map((field) => [field, source[field]])) as Pick<T, K>
|
||||
}
|
||||
|
||||
export function buildScriptDeployRequestBody(
|
||||
@@ -35,87 +81,19 @@ export function buildScriptDeployRequestBody(
|
||||
}
|
||||
|
||||
const existingWithMetadata = existing as ScriptDeployMetadata | undefined
|
||||
const onBehalfOfEmail = deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'on_behalf_of_email'
|
||||
)
|
||||
const metadata = mergeDeployMetadata(existingWithMetadata, draftMetadata)
|
||||
const onBehalfOfEmail = metadata.on_behalf_of_email
|
||||
|
||||
return {
|
||||
path,
|
||||
summary:
|
||||
draft.summary ?? deployMetadataField(draftMetadata, existingWithMetadata, 'summary') ?? '',
|
||||
description: deployMetadataField(draftMetadata, existingWithMetadata, 'description') ?? '',
|
||||
summary: draft.summary ?? metadata.summary ?? '',
|
||||
description: metadata.description ?? '',
|
||||
content: draft.value,
|
||||
parent_hash: existing?.hash,
|
||||
schema: deployMetadataField(draftMetadata, existingWithMetadata, 'schema'),
|
||||
is_template: deployMetadataField(draftMetadata, existingWithMetadata, 'is_template'),
|
||||
...pickDeployMetadata(metadata, SCRIPT_DEPLOY_METADATA_FIELDS),
|
||||
language: draft.language,
|
||||
kind: deployMetadataField(draftMetadata, existingWithMetadata, 'kind'),
|
||||
tag: deployMetadataField(draftMetadata, existingWithMetadata, 'tag'),
|
||||
envs: deployMetadataField(draftMetadata, existingWithMetadata, 'envs'),
|
||||
concurrent_limit: deployMetadataField(draftMetadata, existingWithMetadata, 'concurrent_limit'),
|
||||
concurrency_time_window_s: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'concurrency_time_window_s'
|
||||
),
|
||||
debounce_key: deployMetadataField(draftMetadata, existingWithMetadata, 'debounce_key'),
|
||||
debounce_delay_s: deployMetadataField(draftMetadata, existingWithMetadata, 'debounce_delay_s'),
|
||||
debounce_args_to_accumulate: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'debounce_args_to_accumulate'
|
||||
),
|
||||
max_total_debouncing_time: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'max_total_debouncing_time'
|
||||
),
|
||||
max_total_debounces_amount: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'max_total_debounces_amount'
|
||||
),
|
||||
cache_ttl: deployMetadataField(draftMetadata, existingWithMetadata, 'cache_ttl'),
|
||||
cache_ignore_s3_path: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'cache_ignore_s3_path'
|
||||
),
|
||||
dedicated_worker: deployMetadataField(draftMetadata, existingWithMetadata, 'dedicated_worker'),
|
||||
ws_error_handler_muted: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'ws_error_handler_muted'
|
||||
),
|
||||
priority: deployMetadataField(draftMetadata, existingWithMetadata, 'priority'),
|
||||
restart_unless_cancelled: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'restart_unless_cancelled'
|
||||
),
|
||||
timeout: deployMetadataField(draftMetadata, existingWithMetadata, 'timeout'),
|
||||
delete_after_secs: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'delete_after_secs'
|
||||
),
|
||||
deployment_message: deploymentMessage,
|
||||
concurrency_key: deployMetadataField(draftMetadata, existingWithMetadata, 'concurrency_key'),
|
||||
visible_to_runner_only: deployMetadataField(
|
||||
draftMetadata,
|
||||
existingWithMetadata,
|
||||
'visible_to_runner_only'
|
||||
),
|
||||
auto_kind: deployMetadataField(draftMetadata, existingWithMetadata, 'auto_kind'),
|
||||
codebase: deployMetadataField(draftMetadata, existingWithMetadata, 'codebase'),
|
||||
has_preprocessor: deployMetadataField(draftMetadata, existingWithMetadata, 'has_preprocessor'),
|
||||
on_behalf_of_email: onBehalfOfEmail,
|
||||
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail),
|
||||
assets: deployMetadataField(draftMetadata, existingWithMetadata, 'assets'),
|
||||
modules: deployMetadataField(draftMetadata, existingWithMetadata, 'modules'),
|
||||
labels: deployMetadataField(draftMetadata, existingWithMetadata, 'labels')
|
||||
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,23 +115,17 @@ export function buildFlowDeployRequestBody(
|
||||
deploymentMessage: string | undefined,
|
||||
draftMetadata?: FlowDeployMetadata
|
||||
): FlowDeployRequestBody {
|
||||
const onBehalfOfEmail = deployMetadataField(draftMetadata, existing, 'on_behalf_of_email')
|
||||
const metadata = mergeDeployMetadata(existing, draftMetadata)
|
||||
const onBehalfOfEmail = metadata.on_behalf_of_email
|
||||
|
||||
return {
|
||||
path,
|
||||
summary: draftSummary ?? deployMetadataField(draftMetadata, existing, 'summary') ?? '',
|
||||
description: deployMetadataField(draftMetadata, existing, 'description') ?? '',
|
||||
summary: draftSummary ?? metadata.summary ?? '',
|
||||
description: metadata.description ?? '',
|
||||
value: flowValueWithDraftGroups(flowDraft),
|
||||
schema: flowDraft.schema ?? deployMetadataField(draftMetadata, existing, 'schema') ?? {},
|
||||
tag: deployMetadataField(draftMetadata, existing, 'tag'),
|
||||
ws_error_handler_muted: deployMetadataField(draftMetadata, existing, 'ws_error_handler_muted'),
|
||||
priority: deployMetadataField(draftMetadata, existing, 'priority'),
|
||||
dedicated_worker: deployMetadataField(draftMetadata, existing, 'dedicated_worker'),
|
||||
timeout: deployMetadataField(draftMetadata, existing, 'timeout'),
|
||||
visible_to_runner_only: deployMetadataField(draftMetadata, existing, 'visible_to_runner_only'),
|
||||
on_behalf_of_email: onBehalfOfEmail,
|
||||
schema: flowDraft.schema ?? metadata.schema ?? {},
|
||||
...pickDeployMetadata(metadata, FLOW_DEPLOY_METADATA_FIELDS),
|
||||
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail),
|
||||
labels: deployMetadataField(draftMetadata, existing, 'labels'),
|
||||
deployment_message: deploymentMessage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +364,32 @@ export function saveGlobalAppDraft(
|
||||
return stored
|
||||
}
|
||||
|
||||
export function syncLiveGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
value: unknown,
|
||||
meta?: UserDraftMeta,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
if (!itemKind) return undefined
|
||||
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (liveDraft?.storagePath !== storagePath) {
|
||||
UserDraft.remove(itemKind, storagePath, { workspace })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalized = type === 'app' ? normalizeAppDraftValue(value as AppDraftValue) : value
|
||||
if (meta) {
|
||||
UserDraft.setDraftAndMeta(itemKind, storagePath, normalized, meta, { workspace })
|
||||
} else {
|
||||
UserDraft.save(itemKind, storagePath, normalized, { workspace })
|
||||
}
|
||||
return getGlobalDraft(workspace, type, path, triggerKind)
|
||||
}
|
||||
|
||||
type DeleteGlobalDraftOptions = {
|
||||
preserveLiveDraft?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import type { AppWithLastVersion, Flow, ListableApp, NewScript, Script } from '$lib/gen/types.gen'
|
||||
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { bundleRawAppDraft } from './rawAppBundlerBridge'
|
||||
import type {
|
||||
AppDraftValue,
|
||||
FlowDraftValue,
|
||||
TriggerKind,
|
||||
WorkspaceItem,
|
||||
WorkspaceItemType
|
||||
} from './workspaceItems'
|
||||
import {
|
||||
buildFlowDeployRequestBody,
|
||||
buildScriptDeployRequestBody,
|
||||
type FlowDeployMetadata,
|
||||
type ScriptDeployMetadata
|
||||
} from './deployRequests'
|
||||
import { getGlobalDraft, getGlobalDraftStoragePath, syncLiveGlobalDraft } from './userDraftAdapter'
|
||||
|
||||
export type DraftFlagged = {
|
||||
has_draft?: boolean
|
||||
draft_only?: boolean
|
||||
}
|
||||
|
||||
export type ReadableScript = Pick<Script, 'path' | 'summary' | 'language' | 'content'> &
|
||||
DraftFlagged
|
||||
|
||||
export type DbDraftWorkspaceItemType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
|
||||
|
||||
export type DeployableDraft = {
|
||||
item: WorkspaceItem
|
||||
scriptMetadata?: ScriptDeployMetadata
|
||||
flowMetadata?: FlowDeployMetadata
|
||||
}
|
||||
|
||||
type DeployStatusHandler = (content: string) => void
|
||||
|
||||
type LoadedAppDraftValue = {
|
||||
value: AppDraftValue
|
||||
meta?: UserDraftMeta
|
||||
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
|
||||
}
|
||||
|
||||
export function scriptToItem(
|
||||
script: ReadableScript,
|
||||
includeValue: boolean,
|
||||
isDraft = !!script.has_draft || !!script.draft_only
|
||||
): WorkspaceItem {
|
||||
return {
|
||||
type: 'script',
|
||||
path: script.path,
|
||||
summary: script.summary,
|
||||
language: script.language,
|
||||
value: includeValue ? script.content : undefined,
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
export function flowToItem(
|
||||
flow: Pick<Flow, 'path' | 'summary' | 'value' | 'schema'> & DraftFlagged,
|
||||
includeValue: boolean,
|
||||
isDraft = !!flow.has_draft || !!flow.draft_only
|
||||
): WorkspaceItem {
|
||||
return {
|
||||
type: 'flow',
|
||||
path: flow.path,
|
||||
summary: flow.summary,
|
||||
value: includeValue
|
||||
? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null }
|
||||
: undefined,
|
||||
isDraft
|
||||
}
|
||||
}
|
||||
|
||||
export function appToItem(
|
||||
app: (ListableApp | AppWithLastVersion) & DraftFlagged,
|
||||
includeValue: boolean
|
||||
): WorkspaceItem {
|
||||
return {
|
||||
type: 'app',
|
||||
path: app.path,
|
||||
summary: app.summary,
|
||||
value: includeValue ? ((app as AppWithLastVersion).value as AppDraftValue) : undefined,
|
||||
isDraft: !!app.has_draft || !!app.draft_only
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadScriptWithDbDraft(
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<ReadableScript> {
|
||||
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
return script.draft ? { ...script, ...script.draft, path: script.path } : script
|
||||
}
|
||||
|
||||
export async function loadFlowWithDbDraft(
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<Pick<Flow, 'path' | 'summary' | 'value' | 'schema'> & DraftFlagged> {
|
||||
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
return flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
|
||||
}
|
||||
|
||||
export function isDbDraftWorkspaceItemType(
|
||||
type: WorkspaceItemType
|
||||
): type is DbDraftWorkspaceItemType {
|
||||
return type === 'script' || type === 'flow' || type === 'app'
|
||||
}
|
||||
|
||||
async function createDbDraft(
|
||||
workspace: string,
|
||||
typ: DbDraftWorkspaceItemType,
|
||||
path: string,
|
||||
value: unknown
|
||||
): Promise<void> {
|
||||
await DraftService.createDraft({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path,
|
||||
typ,
|
||||
value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteDbDraft(
|
||||
workspace: string,
|
||||
typ: DbDraftWorkspaceItemType,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
await DraftService.deleteDraft({ workspace, kind: typ, path })
|
||||
}
|
||||
|
||||
export async function deleteDbDraftAndDraftOnlyAnchor(
|
||||
workspace: string,
|
||||
typ: DbDraftWorkspaceItemType,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
switch (typ) {
|
||||
case 'script': {
|
||||
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
|
||||
? await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'flow': {
|
||||
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
|
||||
? await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'app': {
|
||||
const existing = (await AppService.existsApp({ workspace, path }))
|
||||
? await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
: undefined
|
||||
await deleteDbDraft(workspace, typ, path)
|
||||
if (existing?.draft_only) {
|
||||
await AppService.deleteApp({ workspace, path })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDbDraftForDeploy(
|
||||
workspace: string,
|
||||
type: DbDraftWorkspaceItemType,
|
||||
path: string
|
||||
): Promise<DeployableDraft | undefined> {
|
||||
switch (type) {
|
||||
case 'script': {
|
||||
if (!(await ScriptService.existsScriptByPath({ workspace, path }))) return undefined
|
||||
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
|
||||
if (!script.draft && !script.draft_only) return undefined
|
||||
const draftScript = script.draft ? { ...script, ...script.draft, path: script.path } : script
|
||||
return {
|
||||
item: scriptToItem(draftScript, true, true),
|
||||
scriptMetadata: draftScript
|
||||
}
|
||||
}
|
||||
case 'flow': {
|
||||
if (!(await FlowService.existsFlowByPath({ workspace, path }))) return undefined
|
||||
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
|
||||
if (!flow.draft && !flow.draft_only) return undefined
|
||||
const draftFlow = flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow
|
||||
return {
|
||||
item: flowToItem(draftFlow, true, true),
|
||||
flowMetadata: draftFlow
|
||||
}
|
||||
}
|
||||
case 'app': {
|
||||
if (!(await AppService.existsApp({ workspace, path }))) return undefined
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
if (!app.draft && !app.draft_only) return undefined
|
||||
const value = appSourceToDraftValue(app.draft ?? app, app)
|
||||
return {
|
||||
item: {
|
||||
type: 'app',
|
||||
path: app.path,
|
||||
summary: value.summary,
|
||||
value,
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDbDraftItem(
|
||||
workspace: string,
|
||||
type: DbDraftWorkspaceItemType,
|
||||
path: string
|
||||
): Promise<WorkspaceItem | undefined> {
|
||||
return (await loadDbDraftForDeploy(workspace, type, path))?.item
|
||||
}
|
||||
|
||||
export async function loadWorkspaceDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): Promise<WorkspaceItem | undefined> {
|
||||
const draft = getGlobalDraft(workspace, type, path, triggerKind)
|
||||
if (draft) return draft
|
||||
if (!isDbDraftWorkspaceItemType(type)) return undefined
|
||||
return loadDbDraftItem(workspace, type, path)
|
||||
}
|
||||
|
||||
export async function loadWorkspaceDraftForDeploy(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): Promise<DeployableDraft | undefined> {
|
||||
const item = getGlobalDraft(workspace, type, path, triggerKind)
|
||||
if (item) {
|
||||
const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind)
|
||||
switch (type) {
|
||||
case 'script':
|
||||
return {
|
||||
item,
|
||||
scriptMetadata: UserDraft.get<NewScript>('script', storagePath, { workspace })
|
||||
}
|
||||
case 'flow':
|
||||
return {
|
||||
item,
|
||||
flowMetadata: UserDraft.get<Flow>('flow', storagePath, { workspace })
|
||||
}
|
||||
default:
|
||||
return { item }
|
||||
}
|
||||
}
|
||||
if (!isDbDraftWorkspaceItemType(type)) return undefined
|
||||
return loadDbDraftForDeploy(workspace, type, path)
|
||||
}
|
||||
|
||||
export async function deployScriptDraft(args: {
|
||||
workspace: string
|
||||
path: string
|
||||
draft: WorkspaceItem
|
||||
deploymentMessage?: string
|
||||
draftMetadata?: ScriptDeployMetadata
|
||||
}): Promise<void> {
|
||||
const { workspace, path, draft, deploymentMessage, draftMetadata } = args
|
||||
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
|
||||
? await ScriptService.getScriptByPath({ workspace, path })
|
||||
: undefined
|
||||
const requestBody = buildScriptDeployRequestBody(
|
||||
path,
|
||||
draft,
|
||||
existing,
|
||||
deploymentMessage,
|
||||
draftMetadata
|
||||
)
|
||||
try {
|
||||
const schema = emptySchema()
|
||||
await inferArgs(requestBody.language, requestBody.content, schema)
|
||||
requestBody.schema = schema
|
||||
} catch (e) {
|
||||
console.error('Failed to infer script schema before deploy', e)
|
||||
}
|
||||
await ScriptService.createScript({ workspace, requestBody })
|
||||
}
|
||||
|
||||
export async function deployFlowDraft(args: {
|
||||
workspace: string
|
||||
path: string
|
||||
draft: WorkspaceItem
|
||||
deploymentMessage?: string
|
||||
draftMetadata?: FlowDeployMetadata
|
||||
}): Promise<void> {
|
||||
const { workspace, path, draft, deploymentMessage, draftMetadata } = args
|
||||
const flowDraft = draft.value as FlowDraftValue
|
||||
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
|
||||
? await FlowService.getFlowByPath({ workspace, path })
|
||||
: undefined
|
||||
const requestBody = buildFlowDeployRequestBody(
|
||||
path,
|
||||
draft.summary,
|
||||
flowDraft,
|
||||
existing,
|
||||
deploymentMessage,
|
||||
draftMetadata
|
||||
)
|
||||
if (existing) {
|
||||
await FlowService.updateFlow({ workspace, path, requestBody })
|
||||
} else {
|
||||
await FlowService.createFlow({ workspace, requestBody })
|
||||
}
|
||||
}
|
||||
|
||||
export async function deployAppDraft(args: {
|
||||
workspace: string
|
||||
path: string
|
||||
draft: WorkspaceItem
|
||||
deploymentMessage?: string
|
||||
onStatus?: DeployStatusHandler
|
||||
}): Promise<void> {
|
||||
const { workspace, path, draft, deploymentMessage, onStatus } = args
|
||||
const appDraft = draft.value as AppDraftValue
|
||||
const appValue: AppDraftValue = {
|
||||
...appDraft,
|
||||
files: { ...(appDraft.files ?? {}) },
|
||||
runnables: { ...(appDraft.runnables ?? {}) },
|
||||
data: appDraft.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
await recomputeAppPolicy(appValue)
|
||||
const policy = appValue.policy
|
||||
if (!policy) {
|
||||
throw new Error(`Draft app "${path}" has no policy to deploy.`)
|
||||
}
|
||||
|
||||
onStatus?.(`Bundling app "${path}"...`)
|
||||
const bundle = await bundleRawAppDraft({
|
||||
workspace,
|
||||
files: appValue.files,
|
||||
onLog: (delta) => {
|
||||
const lines = delta
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const latest = lines[lines.length - 1]
|
||||
if (latest) {
|
||||
onStatus?.(`Bundling app "${path}"... ${latest}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onStatus?.(`Deploying app "${path}"...`)
|
||||
const rawAppValue = {
|
||||
files: appValue.files,
|
||||
runnables: appValue.runnables,
|
||||
data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
const summary = appValue.summary ?? draft.summary ?? ''
|
||||
if (await AppService.existsApp({ workspace, path })) {
|
||||
// Omit custom_path on update for now. The backend preserves it when absent, while
|
||||
// sending it requires admin privileges; this chat deploy path does not yet mirror
|
||||
// the raw app editor's user/admin-specific custom_path handling.
|
||||
await AppService.updateAppRaw({
|
||||
workspace,
|
||||
path,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
deployment_message: deploymentMessage
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await AppService.createAppRaw({
|
||||
workspace,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
value: rawAppValue,
|
||||
summary,
|
||||
policy,
|
||||
deployment_message: deploymentMessage,
|
||||
custom_path: appValue.custom_path
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveScriptDraftToDb(
|
||||
workspace: string,
|
||||
path: string,
|
||||
draft: NewScript,
|
||||
existing: Awaited<ReturnType<typeof ScriptService.getScriptByPathWithDraft>> | undefined
|
||||
): Promise<void> {
|
||||
if (!existing) {
|
||||
await ScriptService.createScript({
|
||||
workspace,
|
||||
requestBody: {
|
||||
...draft,
|
||||
path,
|
||||
parent_hash: undefined,
|
||||
draft_only: true
|
||||
}
|
||||
})
|
||||
}
|
||||
await createDbDraft(workspace, 'script', path, draft)
|
||||
}
|
||||
|
||||
export async function saveFlowDraftToDb(
|
||||
workspace: string,
|
||||
path: string,
|
||||
draft: Flow,
|
||||
existing: Awaited<ReturnType<typeof FlowService.getFlowByPathWithDraft>> | undefined
|
||||
): Promise<void> {
|
||||
if (!existing) {
|
||||
await FlowService.createFlow({
|
||||
workspace,
|
||||
requestBody: {
|
||||
path,
|
||||
summary: draft.summary ?? '',
|
||||
description: (draft as any).description ?? '',
|
||||
value: draft.value,
|
||||
schema: draft.schema,
|
||||
tag: draft.tag,
|
||||
draft_only: true,
|
||||
ws_error_handler_muted: draft.ws_error_handler_muted,
|
||||
visible_to_runner_only: draft.visible_to_runner_only,
|
||||
on_behalf_of_email: draft.on_behalf_of_email,
|
||||
labels: draft.labels
|
||||
}
|
||||
})
|
||||
}
|
||||
await createDbDraft(workspace, 'flow', path, draft)
|
||||
}
|
||||
|
||||
function appDraftToDbValue(path: string, value: AppDraftValue): Record<string, unknown> {
|
||||
return {
|
||||
value: {
|
||||
files: value.files,
|
||||
runnables: value.runnables,
|
||||
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
},
|
||||
summary: value.summary ?? '',
|
||||
policy: value.policy,
|
||||
path,
|
||||
custom_path: value.custom_path
|
||||
}
|
||||
}
|
||||
|
||||
async function createRawAppDraftOnlyAnchor(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue
|
||||
): Promise<void> {
|
||||
await recomputeAppPolicy(value)
|
||||
const policy = value.policy
|
||||
if (!policy) {
|
||||
throw new Error(`Draft app "${path}" has no policy to save.`)
|
||||
}
|
||||
const bundle = await bundleRawAppDraft({
|
||||
workspace,
|
||||
files: value.files,
|
||||
onLog: () => {}
|
||||
})
|
||||
await AppService.createAppRaw({
|
||||
workspace,
|
||||
formData: {
|
||||
app: {
|
||||
path,
|
||||
value: {
|
||||
files: value.files,
|
||||
runnables: value.runnables,
|
||||
data: value.data ?? { ...DEFAULT_RAW_APP_DATA }
|
||||
},
|
||||
summary: value.summary ?? '',
|
||||
policy,
|
||||
draft_only: true,
|
||||
custom_path: value.custom_path
|
||||
},
|
||||
js: bundle.js,
|
||||
css: bundle.css
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function saveAppDraftToDb(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
existing: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>> | undefined
|
||||
): Promise<void> {
|
||||
const current =
|
||||
existing ??
|
||||
((await AppService.existsApp({ workspace, path }))
|
||||
? await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
: undefined)
|
||||
if (!current) {
|
||||
await createRawAppDraftOnlyAnchor(workspace, path, value)
|
||||
}
|
||||
await createDbDraft(workspace, 'app', path, appDraftToDbValue(path, value))
|
||||
}
|
||||
|
||||
function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
|
||||
if (value.data?.creation) {
|
||||
return {
|
||||
tables: value.data.tables ?? [],
|
||||
datatable: value.data.creation.datatable,
|
||||
schema: value.data.creation.schema
|
||||
}
|
||||
}
|
||||
if (value.data) {
|
||||
return value.data
|
||||
}
|
||||
if (value.datatables) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
|
||||
}
|
||||
if (value.dataTableRefs) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
|
||||
}
|
||||
return { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
|
||||
function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
|
||||
const value = (app.value ?? {}) as Record<string, any>
|
||||
return {
|
||||
summary: app.summary ?? '',
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: normalizeRawAppData(value),
|
||||
policy: app.policy ?? fallback?.policy,
|
||||
custom_path: app.custom_path ?? fallback?.custom_path
|
||||
}
|
||||
}
|
||||
|
||||
function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta {
|
||||
return {
|
||||
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined,
|
||||
remoteDraftRev: app.draft_created_at
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
|
||||
const draft = getGlobalDraft(workspace, 'app', path)
|
||||
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
|
||||
return draft.value as AppDraftValue
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
return appSourceToDraftValue(app.draft ?? app, app)
|
||||
}
|
||||
|
||||
export async function loadAppDraftValue(
|
||||
path: string,
|
||||
workspace: string
|
||||
): Promise<LoadedAppDraftValue> {
|
||||
const draft = getGlobalDraft(workspace, 'app', path)
|
||||
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
|
||||
return { value: draft.value as AppDraftValue }
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPathWithDraft({ workspace, path })
|
||||
const value = appSourceToDraftValue(app.draft ?? app, app)
|
||||
return { value, meta: appDraftMeta(app), existing: app }
|
||||
}
|
||||
|
||||
export async function saveAppDraft(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
meta?: UserDraftMeta,
|
||||
existing?: Awaited<ReturnType<typeof AppService.getAppByPathWithDraft>>
|
||||
): Promise<WorkspaceItem> {
|
||||
await saveAppDraftToDb(workspace, path, value, existing)
|
||||
const mirrored = syncLiveGlobalDraft(workspace, 'app', path, value, meta)
|
||||
if (mirrored) return mirrored
|
||||
return {
|
||||
type: 'app',
|
||||
path,
|
||||
summary: value.summary,
|
||||
value,
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
export async function recomputeAppPolicy(value: AppDraftValue): Promise<void> {
|
||||
const policy = (await updateRawAppPolicy(
|
||||
value.runnables as any,
|
||||
value.policy as any
|
||||
)) as NonNullable<AppDraftValue['policy']>
|
||||
if (!policy.execution_mode) {
|
||||
policy.execution_mode = 'publisher'
|
||||
}
|
||||
value.policy = policy
|
||||
}
|
||||
Reference in New Issue
Block a user