From 458d89802442b45d3511e40935a72186c16c80f8 Mon Sep 17 00:00:00 2001 From: centdix Date: Wed, 3 Jun 2026 12:14:21 +0200 Subject: [PATCH] refactor: simplify ai chat db draft deploys --- .../copilot/chat/global/core.test.ts | 273 +++++-- .../components/copilot/chat/global/core.ts | 670 ++---------------- .../copilot/chat/global/deployRequests.ts | 160 ++--- .../copilot/chat/global/userDraftAdapter.ts | 26 + .../copilot/chat/global/workspaceDrafts.ts | 611 ++++++++++++++++ 5 files changed, 995 insertions(+), 745 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 99467a4ebd..444e4882bc 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -166,6 +166,141 @@ const toolCallbacks: ToolCallbacks = { removeToolStatus: vi.fn() } +const scriptDrafts = new Map() +const flowDrafts = new Map() +const appDrafts = new Map() + +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('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('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('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('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', diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index d56a17ea04..8c58c0fd59 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -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 & 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 & 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 { - 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> { - 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.` placeholders. @@ -896,12 +851,6 @@ type AppMetadata = { data?: any } -type LoadedAppDraftValue = { - value: AppDraftValue - meta?: UserDraftMeta - existing?: Awaited> -} - 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): 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 - 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 { - 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 { - 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> -): Promise { - 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 -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 { - await DraftService.createDraft({ - workspace, - requestBody: { - path, - typ, - value - } - }) -} - -async function deleteDbDraft( - workspace: string, - typ: DbDraftWorkspaceItemType, - path: string -): Promise { - await DraftService.deleteDraft({ workspace, kind: typ, path }) -} - -async function deleteDbDraftAndDraftOnlyAnchor( - workspace: string, - typ: DbDraftWorkspaceItemType, - path: string -): Promise { - 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 { - 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 { - return (await loadDbDraftForDeploy(workspace, type, path))?.item -} - -async function getGlobalOrDbDraft( - workspace: string, - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind -): Promise { - 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 { - 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('script', storagePath, { workspace }) - } - case 'flow': - return { - item, - flowMetadata: UserDraft.get('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> | undefined -): Promise { - 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> | undefined -): Promise { - 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 { - 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 { - 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> | undefined -): Promise { - 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 { @@ -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 { - 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 { - const policy = (await updateRawAppPolicy( - value.runnables as any, - value.policy as any - )) as NonNullable - 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 } } diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts index 0c0f37058b..3107b12b07 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts @@ -12,15 +12,61 @@ function preserveOnBehalfOf(email: string | undefined): true | undefined { return email ? true : undefined } -function deployMetadataField( - 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( 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( + source: T, + fields: readonly K[] +): Pick { + return Object.fromEntries(fields.map((field) => [field, source[field]])) as Pick } 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 } } diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 9a6d9125f4..57d9998187 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -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 } diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts b/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts new file mode 100644 index 0000000000..d0c1962837 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts @@ -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 & + DraftFlagged + +export type DbDraftWorkspaceItemType = Extract + +export type DeployableDraft = { + item: WorkspaceItem + scriptMetadata?: ScriptDeployMetadata + flowMetadata?: FlowDeployMetadata +} + +type DeployStatusHandler = (content: string) => void + +type LoadedAppDraftValue = { + value: AppDraftValue + meta?: UserDraftMeta + existing?: Awaited> +} + +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 & 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 { + 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 & 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 { + await DraftService.createDraft({ + workspace, + requestBody: { + path, + typ, + value + } + }) +} + +async function deleteDbDraft( + workspace: string, + typ: DbDraftWorkspaceItemType, + path: string +): Promise { + await DraftService.deleteDraft({ workspace, kind: typ, path }) +} + +export async function deleteDbDraftAndDraftOnlyAnchor( + workspace: string, + typ: DbDraftWorkspaceItemType, + path: string +): Promise { + 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 { + 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 { + return (await loadDbDraftForDeploy(workspace, type, path))?.item +} + +export async function loadWorkspaceDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): Promise { + 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 { + 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('script', storagePath, { workspace }) + } + case 'flow': + return { + item, + flowMetadata: UserDraft.get('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 { + 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 { + 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 { + 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> | undefined +): Promise { + 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> | undefined +): Promise { + 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 { + 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 { + 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> | undefined +): Promise { + 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): 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 + 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 { + 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 { + 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> +): Promise { + 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 { + const policy = (await updateRawAppPolicy( + value.runnables as any, + value.policy as any + )) as NonNullable + if (!policy.execution_mode) { + policy.execution_mode = 'publisher' + } + value.policy = policy +}