diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index aca63e4b32..c8514e1fb6 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -341,13 +341,10 @@ let deployedAny = false for (const item of toDeploy) { deploymentStatus[item.key] = { status: 'loading' } - const res = await deployDraft( - item.draftKind, - item.path, - currentWorkspaceId, - item.draft_only, - item.raw_app - ) + const res = await deployDraft(item.draftKind, item.path, currentWorkspaceId, { + draftOnly: item.draft_only, + rawApp: item.raw_app + }) if (res.success) { deploymentStatus[item.key] = { status: 'deployed' } deployedAny = true 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 50ad2547f0..271271836b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -605,6 +605,178 @@ describe('global AI tools', () => { expect(VariableService.updateVariable).not.toHaveBeenCalled() }) + it('deploys every field of a script draft (not just content/summary)', async () => { + // The deploy delegates to the shared deployer, which reads the full persisted + // draft via getScriptByPath(getDraft) and deploys all of it. Config fields + // (tag/priority/schema/description/concurrency) were previously dropped, + // sourced from the deployed version instead. + seedBackendDraft( + 'script', + 'f/scripts/full', + { path: 'f/scripts/full', content: 'export async function main() {}', language: 'bun' }, + { workspace: WORKSPACE } + ) + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + hash: 1234, + path: 'f/scripts/full', + summary: 'Full script', + description: 'desc', + content: 'export async function main() {}', + schema: { foo: 'bar' }, + language: 'bun', + kind: 'script', + tag: 'custom-tag', + priority: 7, + concurrent_limit: 3, + draft_only: true + } as any) + + await callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/full' }) + + expect(ScriptService.createScript).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'f/scripts/full', + content: 'export async function main() {}', + summary: 'Full script', + description: 'desc', + schema: { foo: 'bar' }, + language: 'bun', + tag: 'custom-tag', + priority: 7, + concurrent_limit: 3, + parent_hash: 1234 + }) + }) + // Editor-only / server-managed draft keys must not leak into the deploy body. + const calls = vi.mocked(ScriptService.createScript).mock.calls + const body = calls[calls.length - 1][0].requestBody as any + expect(body.draft_only).toBeUndefined() + }) + + it('deploys every config field of a flow draft via createFlow', async () => { + seedBackendDraft( + 'flow', + 'f/flows/full', + { summary: 'Full flow', description: 'flow desc', value: { modules: [] }, schema: {} }, + { workspace: WORKSPACE } + ) + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + path: 'f/flows/full', + summary: 'Full flow', + description: 'flow desc', + value: { modules: [] }, + schema: { x: 1 }, + tag: 'flow-tag', + dedicated_worker: true + } as any) + + await callGlobalTool('deploy_workspace_item', { type: 'flow', path: 'f/flows/full' }) + + // No deployed flow row (existsFlowByPath defaults to false) → create. + expect(FlowService.createFlow).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'f/flows/full', + summary: 'Full flow', + description: 'flow desc', + value: { modules: [] }, + schema: { x: 1 }, + tag: 'flow-tag', + dedicated_worker: true + }) + }) + expect(FlowService.updateFlow).not.toHaveBeenCalled() + }) + + it('deploys an editor draft_only script at its chosen path, not its synthetic storage key', async () => { + // A new script created in the editor lives at a synthetic `u/{user}/draft_{uuid}` + // storage key while its chosen path is in the draft value. The chat addresses + // it by the chosen (display) path; deploy must resolve to the storage key so the + // shared deployer can read the draft via getScriptByPath, then deploy at the + // chosen path. Reading at the chosen path would 404. + const storageKey = 'u/admin/draft_abc123' + const chosenPath = 'f/team/chosen_path' + seedBackendDraft( + 'script', + storageKey, + { + path: chosenPath, + summary: 'New script', + description: '', + content: 'export async function main() {}', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script' + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'script', + storagePath: storageKey, + effectivePath: chosenPath + }) + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: chosenPath, + summary: 'New script', + description: '', + content: 'export async function main() {}', + schema: {}, + language: 'bun', + kind: 'script' + } as any) + + const flushSpy = vi.spyOn(UserDraftDbSyncer, 'flush') + + await callGlobalTool('deploy_workspace_item', { type: 'script', path: chosenPath }) + + // Any pending editor autosave is flushed at the storage key before delegating, + // so the shared deployer reads the latest value, not a stale persisted draft. + expect(flushSpy).toHaveBeenCalledWith( + expect.objectContaining({ workspace: WORKSPACE, itemKind: 'script', path: storageKey }) + ) + // The draft is read at the STORAGE key (the chosen path would 404)… + expect(ScriptService.getScriptByPath).toHaveBeenCalledWith( + expect.objectContaining({ workspace: WORKSPACE, path: storageKey, getDraft: true }) + ) + // …and deployed at the chosen path. + expect(ScriptService.createScript).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ path: chosenPath }) + }) + }) + + it('aborts deploy when the pre-deploy draft flush hit a conflict', async () => { + // flush() resolves even when the save recorded a conflict; deploy must abort + // rather than publish the stale persisted draft. + seedBackendDraft( + 'script', + 'f/scripts/conflicted', + { + path: 'f/scripts/conflicted', + summary: '', + description: '', + content: 'export async function main() {}', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script' + }, + { workspace: WORKSPACE } + ) + const conflictSpy = vi + .spyOn(UserDraftDbSyncer, 'getConflict') + .mockReturnValue({ conflict: { serverTimestamp: '2026', localLastSync: null } } as any) + + await expect( + callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/conflicted' }) + ).rejects.toThrow(/conflicting/) + expect(ScriptService.createScript).not.toHaveBeenCalled() + conflictSpy.mockRestore() + }) + it('writes script drafts into UserDraft', async () => { const content = 'export async function main() {\n\treturn "hello"\n}' @@ -1383,9 +1555,7 @@ describe('global AI tools', () => { file_path: '/min.tsx' }) - expect(result).toContain( - 'lines 1-3 of 3, truncated to the first 50000 of 90002 chars.' - ) + expect(result).toContain('lines 1-3 of 3, truncated to the first 50000 of 90002 chars.') expect(result).toContain('the file is likely minified') expect(result.split('\n\n')[1]).toHaveLength(50_000) }) @@ -1401,9 +1571,7 @@ describe('global AI tools', () => { file_path: '/generated.js' }) - expect(result).toContain( - 'lines 1-1 of 1, truncated to the first 50000 of 60000 chars.' - ) + expect(result).toContain('lines 1-1 of 1, truncated to the first 50000 of 60000 chars.') expect(result).toContain('re-read with a smaller limit') expect(result.split('\n\n')[1]).toBe('x'.repeat(50_000)) }) @@ -1488,8 +1656,7 @@ describe('global AI tools', () => { versions: [5], value: { files: { - '/lib/aggregations.ts': - 'export function computeRevenue(o) {\n return o.unitPrice\n}\n', + '/lib/aggregations.ts': 'export function computeRevenue(o) {\n return o.unitPrice\n}\n', '/components/SummaryPanel.tsx': 'import { computeRevenue } from "../lib/aggregations"\nconst total = computeRevenue(order)\n', '/components/OrdersTable.tsx': 'const r = computeRevenue(row)\n// renders revenue\n', @@ -1787,6 +1954,9 @@ describe('global AI tools', () => { { workspace: WORKSPACE } ) + // getAppByPath resolves with no draft_path → deploy at the item's own path. + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any) + const raw = await callGlobalTool('deploy_workspace_item', { type: 'app', path: 'f/apps/report', @@ -1832,6 +2002,93 @@ describe('global AI tools', () => { }) }) + it('deploys an editor raw app draft at its draft_path, not its synthetic storage key', async () => { + // An editor-created draft_only raw app lives at a synthetic storage key with + // its chosen path in `draft_path`; deploy must resolve to the storage key, + // read draft_path, and create the app there — not at the synthetic key. + const storageKey = 'u/admin/draft_app999' + const chosenPath = 'f/team/chosen_app' + seedBackendDraft( + 'raw_app', + storageKey, + { + summary: 'Editor app', + files: { '/App.tsx': 'export default () => null' }, + runnables: {}, + data: { tables: [] }, + draft_path: chosenPath + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'raw_app', + storagePath: storageKey, + effectivePath: chosenPath + }) + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ + draft: { draft_path: chosenPath } + } as any) + const flushSpy = vi.spyOn(UserDraftDbSyncer, 'flush') + + await callGlobalTool('deploy_workspace_item', { type: 'app', path: chosenPath }) + + // The draft is flushed at the storage key before the draft_path read, so a + // not-yet-saved editor rename isn't read stale. + expect(flushSpy).toHaveBeenCalledWith( + expect.objectContaining({ workspace: WORKSPACE, itemKind: 'raw_app', path: storageKey }) + ) + // draft_path is read from the backend draft at the storage key… + expect(AppService.getAppByPath).toHaveBeenCalledWith( + expect.objectContaining({ + workspace: WORKSPACE, + path: storageKey, + getDraft: true, + rawApp: true + }) + ) + // …and the app is created at the chosen path, not the synthetic key. + expect(AppService.createAppRaw).toHaveBeenCalledWith( + expect.objectContaining({ + formData: expect.objectContaining({ + app: expect.objectContaining({ path: chosenPath }) + }) + }) + ) + }) + + it('aborts a raw app deploy when the draft_path lookup fails (non-404)', async () => { + // A real lookup failure (network/5xx) must abort, not silently fall back to the + // storage path and deploy there. Only a 404 justifies the storage-path fallback. + seedBackendDraft( + 'raw_app', + 'u/admin/draft_appfail', + { + summary: 'Editor app', + files: { '/App.tsx': 'export default () => null' }, + runnables: {}, + data: { tables: [] }, + draft_path: 'f/team/chosen_app' + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'raw_app', + storagePath: 'u/admin/draft_appfail', + effectivePath: 'f/team/chosen_app' + }) + vi.mocked(AppService.getAppByPath).mockRejectedValueOnce( + Object.assign(new Error('server error'), { status: 500 }) + ) + + await expect( + callGlobalTool('deploy_workspace_item', { type: 'app', path: 'f/team/chosen_app' }) + ).rejects.toThrow() + expect(AppService.createAppRaw).not.toHaveBeenCalled() + expect(AppService.updateAppRaw).not.toHaveBeenCalled() + }) + it('deploys an existing raw app draft by bundling files and updating the raw app', async () => { vi.mocked(AppService.existsApp).mockResolvedValueOnce(true) seedBackendDraft( @@ -1848,6 +2105,8 @@ describe('global AI tools', () => { { workspace: WORKSPACE } ) + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any) + await callGlobalTool('deploy_workspace_item', { type: 'app', path: 'f/apps/report' @@ -1892,6 +2151,8 @@ describe('global AI tools', () => { { workspace: WORKSPACE } ) + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any) + await callGlobalTool( 'deploy_workspace_item', { type: 'app', path: 'f/apps/report' }, diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 10f2fce49a..9fd074cebd 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -99,9 +99,10 @@ import { type WorkspaceItem, type WorkspaceItemType } from './workspaceItems' -import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' import { userStore } from '$lib/stores' import { get } from 'svelte/store' +import { deployDraft as deployDraftToWorkspace } from '$lib/utils_draft_deploy' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { bundleRawAppDraft } from './rawAppBundlerBridge' import { clearEphemeralSecretVariableDraftValue, @@ -2505,7 +2506,7 @@ function finishDraftWrite( type WriteSpec = { probe: (workspace: string, path: string) => Promise fetchDeployed: (workspace: string, path: string) => Promise - buildDraft: (base: T | undefined, args: A, path: string) => T + buildDraft: (base: T | undefined, args: A, path: string) => T | Promise beforePersist?: (workspace: string, args: A) => void } @@ -2528,7 +2529,7 @@ async function writeDraft( existed = true } - const draft = spec.buildDraft(base, args, path) + const draft = await spec.buildDraft(base, args, path) spec.beforePersist?.(workspace, args) const result = await persistGlobalDraft(workspace, type, path, draft, { @@ -2552,8 +2553,8 @@ const SCRIPT_SPEC: WriteSpec = { const existing = await ScriptService.getScriptByPath({ workspace, path }) return { ...(existing as unknown as NewScript), parent_hash: existing.hash } }, - buildDraft: (base, args, path) => - base + buildDraft: async (base, args, path) => { + const draft: NewScript = base ? { ...structuredClone(base), path, @@ -2571,6 +2572,18 @@ const SCRIPT_SPEC: WriteSpec = { language: args.language, kind: 'script' } + // Infer the arg schema from the content at save time, like the editor does, + // so the persisted draft is the single source of truth at deploy. Keep the + // previous schema (or empty) on failure rather than blanking it. + try { + const schema = emptySchema() + await inferArgs(draft.language, draft.content, schema) + draft.schema = schema + } catch (e) { + console.error('Failed to infer script schema before saving draft', e) + } + return draft + } } function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise { @@ -3258,7 +3271,9 @@ async function searchApp( const header = `${totalMatchCount} match${ totalMatchCount === 1 ? '' : 'es' } in ${fileCount} file${fileCount === 1 ? '' : 's'}${ - truncated ? ` (showing the first ${maxMatches}; narrow with file_glob or a more specific query)` : '' + truncated + ? ` (showing the first ${maxMatches}; narrow with file_glob or a more specific query)` + : '' }` const out: string[] = [header] @@ -3580,6 +3595,29 @@ async function discardLocalDraft( ) } +// Flush a draft's pending editor autosave, then verify it actually landed before +// the caller re-reads the persisted draft. `flush()` resolves even when the save +// recorded a conflict (server has a newer version) or failed (network/5xx) — it +// does not throw — so without this check a deploy could publish a stale/conflicting +// draft. Abort with a clear message instead. +async function flushDraftOrThrow( + query: Parameters[0], + label: string +): Promise { + await UserDraftDbSyncer.flush(query) + if (UserDraftDbSyncer.getConflict(query).conflict) { + throw new Error( + `Cannot deploy ${label}: the draft has a conflicting newer version on the server. Open it in the editor and resolve the conflict first.` + ) + } + const { state, failureMessage } = UserDraftDbSyncer.getState(query) + if (state === 'failed') { + throw new Error( + `Cannot deploy ${label}: saving the latest draft failed (${failureMessage ?? 'unknown error'}). Retry once the draft saves.` + ) + } +} + async function deployDraft( args: { type: WorkspaceItemType @@ -3610,169 +3648,208 @@ async function deployDraft( let actions: ToolDisplayAction[] | undefined - switch (type) { - case 'script': { - const existing = (await ScriptService.existsScriptByPath({ workspace, path })) - ? await ScriptService.getScriptByPath({ workspace, path }) - : undefined - const requestBody = buildScriptDeployRequestBody(path, draft, existing, deploymentMessage) - // 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 }) - break + if (type === 'script' || type === 'flow') { + // Promote the full persisted draft via the shared deploy module — the same + // "promote a draft to deployed" code the compare page's Review & Deploy uses. + // It deploys every field of the draft; the previous local builders dropped + // most config fields (tag, priority, schema, description, concurrency…), + // reading them from the already-deployed version instead. The other kinds + // below already deploy their draft value directly, so only script/flow need + // this. Scripts always create (with parent_hash); a flow on a deployed item + // updates, a draft-only flow (no flow row) is created. + // Address the draft by its STORAGE path: a draft_only item created in the + // editor lives at a synthetic `u/{user}/draft_{uuid}` key while its chosen + // path is held in the draft value. The shared deployer reads the draft via + // getScriptByPath/getFlowByPath at the path we pass (then deploys at the + // draft's own `path`), so passing the display/chosen path would 404. For a + // draft on a deployed item the storage path is just the item path. + const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind) + // The shared deployer re-reads the persisted DB draft, but an open editor's + // edit may still be parked in a debounced/disabled autosave. Flush it first so + // we deploy the latest value (not a stale persisted one) — and so the + // post-deploy draft delete doesn't drop an unsaved edit. `flush` always saves + // the parked value (like Ctrl/Cmd+S), since the user explicitly asked to deploy. + await flushDraftOrThrow({ workspace, itemKind: type, path: storagePath }, `${type} "${path}"`) + const draftOnly = + type === 'flow' + ? !(await FlowService.existsFlowByPath({ workspace, path: storagePath })) + : false + const result = await deployDraftToWorkspace(type, storagePath, workspace, { + draftOnly, + deploymentMessage + }) + if (!result.success) { + throw new Error(result.error ?? `Failed to deploy ${type} "${path}".`) } - case 'flow': { - 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 - ) - if (existing) { - await FlowService.updateFlow({ workspace, path, requestBody }) - } else { - await FlowService.createFlow({ workspace, requestBody }) - } - break - } - case 'schedule': { - const requestBody = draft.value as any - if (await ScheduleService.existsSchedule({ workspace, path })) { - await ScheduleService.updateSchedule({ workspace, path, requestBody }) - } else { - await ScheduleService.createSchedule({ workspace, requestBody }) - } - actions = [createOpenScheduleAction(path, requestBody.is_flow ? 'flow' : 'script')] - break - } - case 'trigger': { - const service = triggerServices[triggerKind!] - const requestBody = draft.value as { is_flow?: boolean } - if (await service.exists({ workspace, path })) { - await service.update({ workspace, path, requestBody }) - } else { - await service.create({ workspace, requestBody }) - } - actions = [ - createOpenTriggerAction(triggerKind!, path, requestBody.is_flow ? 'flow' : 'script') - ] - break - } - case 'resource': { - const requestBody = draft.value as any - if (await ResourceService.existsResource({ workspace, path })) { - await ResourceService.updateResource({ workspace, path, requestBody }) - } else { - await ResourceService.createResource({ workspace, requestBody }) - } - actions = [createOpenResourceAction(path)] - break - } - case 'variable': { - const requestBody = buildVariableDeployRequestBody( - workspace, - path, - draft.value as CreateVariable - ) - if (await VariableService.existsVariable({ workspace, path })) { - await VariableService.updateVariable({ workspace, path, requestBody }) - } else { - await VariableService.createVariable({ workspace, requestBody }) - } - actions = [createOpenVariableAction(path)] - 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({ - 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}` - }) - } + } else { + switch (type) { + case 'schedule': { + const requestBody = draft.value as any + if (await ScheduleService.existsSchedule({ workspace, path })) { + await ScheduleService.updateSchedule({ workspace, path, requestBody }) + } else { + await ScheduleService.createSchedule({ workspace, requestBody }) } - }) - - toolCallbacks.setToolStatus(toolId, { - content: `Deploying app "${path}"...` - }) - const rawAppValue = { - files: appValue.files, - runnables: appValue.runnables, - data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA } + actions = [createOpenScheduleAction(path, requestBody.is_flow ? 'flow' : 'script')] + break } - 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({ + case 'trigger': { + const service = triggerServices[triggerKind!] + const requestBody = draft.value as { is_flow?: boolean } + if (await service.exists({ workspace, path })) { + await service.update({ workspace, path, requestBody }) + } else { + await service.create({ workspace, requestBody }) + } + actions = [ + createOpenTriggerAction(triggerKind!, path, requestBody.is_flow ? 'flow' : 'script') + ] + break + } + case 'resource': { + const requestBody = draft.value as any + if (await ResourceService.existsResource({ workspace, path })) { + await ResourceService.updateResource({ workspace, path, requestBody }) + } else { + await ResourceService.createResource({ workspace, requestBody }) + } + actions = [createOpenResourceAction(path)] + break + } + case 'variable': { + // The chat keeps secret draft values only in memory (the DB draft + // stores `''`); buildVariableDeployRequestBody re-injects the ephemeral + // secret, so this can't go through the DB-reading shared deployer. + const requestBody = buildVariableDeployRequestBody( 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 - } - }) + draft.value as CreateVariable + ) + if (await VariableService.existsVariable({ workspace, path })) { + await VariableService.updateVariable({ workspace, path, requestBody }) + } else { + await VariableService.createVariable({ workspace, requestBody }) + } + actions = [createOpenVariableAction(path)] + break + } + case 'app': { + // Raw apps store a flat AppDraftValue (files/runnables at top level), + // not the deployed app's nested `value` shape the shared raw-app + // deployer reads, so they deploy through the chat's own bundle path. + 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({ + 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}` + }) + } + } + }) + + 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 ?? '' + // Deploy at the draft's chosen path. A draft_only raw app created in the + // editor lives at a synthetic `u/{user}/draft_{uuid}` storage key with its + // chosen path in the raw_app draft's `draft_path`; the chat's AppDraftValue + // doesn't carry it, so read it from the backend draft. For a chat-created app + // (real path, no draft_path) or a draft on a deployed app, the storage path + // is the deploy path. Same storage-path resolution as script/flow. + const storagePath = getGlobalDraftStoragePath(workspace, 'app', path) + // `draft_path` is read from the persisted backend draft below, but an + // editor rename may still be parked in a debounced/disabled autosave. + // Flush first (like script/flow) so we read the latest chosen path. + await flushDraftOrThrow( + { workspace, itemKind: 'raw_app', path: storagePath }, + `app "${path}"` + ) + let targetPath = storagePath + try { + const row = (await AppService.getAppByPath({ + workspace, + path: storagePath, + getDraft: true, + rawApp: true + })) as { draft?: { draft_path?: string; path?: string }; draft_path?: string } + targetPath = row?.draft?.draft_path ?? row?.draft?.path ?? row?.draft_path ?? storagePath + } catch (e) { + // Only a missing item (404) justifies falling back to the storage path; + // a real lookup failure (network/5xx) must abort rather than silently + // deploy to the wrong path. + if ((e as { status?: number } | null | undefined)?.status === 404) { + targetPath = storagePath + } else { + throw e + } + } + if (await AppService.existsApp({ workspace, path: targetPath })) { + // 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: targetPath, + formData: { + app: { + path: targetPath, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage + }, + js: bundle.js, + css: bundle.css + } + }) + } else { + await AppService.createAppRaw({ + workspace, + formData: { + app: { + path: targetPath, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage, + custom_path: appValue.custom_path + }, + js: bundle.js, + css: bundle.css + } + }) + } + break } - break } } diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts deleted file mode 100644 index 61a6827cbf..0000000000 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Flow, NewScript, Script } from '$lib/gen/types.gen' -import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' -import type { WorkspaceItem } from './workspaceItems' - -describe('global AI deploy request builders', () => { - it('preserves existing script metadata while replacing draft-controlled fields', () => { - const existing = { - hash: 'parent-hash', - path: 'f/demo/script', - summary: 'existing summary', - description: 'existing description', - content: 'old content', - schema: { properties: { name: { type: 'string' } } }, - is_template: true, - language: 'bun', - kind: 'script', - tag: 'node', - envs: ['ENV_A'], - concurrent_limit: 3, - concurrency_time_window_s: 60, - concurrency_key: 'key', - debounce_key: 'debounce', - debounce_delay_s: 5, - debounce_args_to_accumulate: ['ids'], - max_total_debouncing_time: 120, - max_total_debounces_amount: 4, - cache_ttl: 30, - cache_ignore_s3_path: true, - dedicated_worker: true, - ws_error_handler_muted: true, - priority: 9, - restart_unless_cancelled: true, - timeout: 300, - delete_after_secs: 600, - visible_to_runner_only: true, - auto_kind: 'script', - codebase: 'repo', - has_preprocessor: true, - on_behalf_of_email: 'deployer@example.com', - assets: [{ path: 's3://bucket/key', kind: 's3object' }], - modules: { 'helper.ts': { content: 'export const helper = 1', language: 'bun' } }, - labels: ['prod'], - lock: 'stale lock' - } as unknown as Script & Partial - const draft: WorkspaceItem = { - type: 'script', - path: 'f/demo/script', - summary: 'draft summary', - language: 'bun', - value: 'new content', - isDraft: true - } - - const requestBody = buildScriptDeployRequestBody('f/demo/script', draft, existing, 'ai deploy') - - expect(requestBody).toMatchObject({ - path: 'f/demo/script', - parent_hash: 'parent-hash', - summary: 'draft summary', - description: 'existing description', - content: 'new content', - schema: existing.schema, - tag: 'node', - envs: ['ENV_A'], - concurrent_limit: 3, - concurrency_time_window_s: 60, - concurrency_key: 'key', - debounce_key: 'debounce', - debounce_delay_s: 5, - debounce_args_to_accumulate: ['ids'], - max_total_debouncing_time: 120, - max_total_debounces_amount: 4, - cache_ttl: 30, - cache_ignore_s3_path: true, - dedicated_worker: true, - ws_error_handler_muted: true, - priority: 9, - restart_unless_cancelled: true, - timeout: 300, - delete_after_secs: 600, - visible_to_runner_only: true, - auto_kind: 'script', - codebase: 'repo', - has_preprocessor: true, - on_behalf_of_email: 'deployer@example.com', - preserve_on_behalf_of: true, - assets: existing.assets, - modules: existing.modules, - labels: ['prod'], - deployment_message: 'ai deploy' - }) - expect(requestBody.lock).toBeUndefined() - }) - - it('preserves existing flow metadata and uses draft value/schema overrides', () => { - const existing = { - path: 'f/demo/flow', - summary: 'existing summary', - description: 'existing description', - value: { modules: [] }, - schema: { required: ['name'] }, - tag: 'python', - ws_error_handler_muted: true, - priority: 7, - dedicated_worker: true, - timeout: 60, - visible_to_runner_only: true, - on_behalf_of_email: 'deployer@example.com', - labels: ['critical'] - } as unknown as Flow - const draftValue = { - value: { modules: [{ id: 'step', value: { type: 'identity' } }] }, - schema: { properties: { name: { type: 'string' } } }, - groups: [{ start_id: 'step', end_id: 'step', summary: 'Group' }] - } - - const requestBody = buildFlowDeployRequestBody( - 'f/demo/flow', - undefined, - draftValue as any, - existing, - 'ai deploy' - ) - - expect(requestBody).toMatchObject({ - path: 'f/demo/flow', - summary: 'existing summary', - description: 'existing description', - schema: draftValue.schema, - tag: 'python', - ws_error_handler_muted: true, - priority: 7, - dedicated_worker: true, - timeout: 60, - visible_to_runner_only: true, - on_behalf_of_email: 'deployer@example.com', - preserve_on_behalf_of: true, - labels: ['critical'], - deployment_message: 'ai deploy' - }) - expect(requestBody.value.modules).toHaveLength(1) - expect(requestBody.value.groups).toEqual(draftValue.groups) - }) - - it('deploys a draft-set flow description, overriding the existing one', () => { - const existing = { - path: 'f/demo/flow', - summary: 'existing summary', - description: 'existing description', - value: { modules: [] }, - schema: {} - } as unknown as Flow - - const requestBody = buildFlowDeployRequestBody( - 'f/demo/flow', - undefined, - { - value: { modules: [] }, - schema: null, - groups: null, - description: 'draft-set description' - } as any, - existing, - undefined - ) - - expect(requestBody.description).toBe('draft-set description') - }) - - it('falls back to existing flow schema when the draft has no schema', () => { - const existing = { - path: 'f/demo/flow', - summary: 'existing summary', - value: { modules: [] }, - schema: { properties: { existing: { type: 'boolean' } } } - } as unknown as Flow - - const requestBody = buildFlowDeployRequestBody( - 'f/demo/flow', - 'draft summary', - { value: { modules: [] }, schema: null, groups: null }, - existing, - undefined - ) - - expect(requestBody.summary).toBe('draft summary') - expect(requestBody.schema).toBe(existing.schema) - }) -}) diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts deleted file mode 100644 index 5fe44566c9..0000000000 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen' -import type { FlowDraftValue, WorkspaceItem } from './workspaceItems' - -type ScriptWithDeployMetadata = Script & Partial> - -export type FlowDeployRequestBody = OpenFlowWPath & { - deployment_message?: string -} - -function preserveOnBehalfOf(email: string | undefined): true | undefined { - return email ? true : undefined -} - -export function buildScriptDeployRequestBody( - path: string, - draft: WorkspaceItem, - existing: Script | undefined, - deploymentMessage: string | undefined -): NewScript { - if (typeof draft.value !== 'string' || !draft.language) { - throw new Error(`Draft script "${path}" is missing content or language.`) - } - - const existingWithMetadata = existing as ScriptWithDeployMetadata | undefined - - return { - path, - summary: draft.summary ?? existing?.summary ?? '', - description: existing?.description ?? '', - content: draft.value, - parent_hash: existing?.hash, - schema: existing?.schema, - is_template: existing?.is_template, - language: draft.language, - kind: existing?.kind, - tag: existing?.tag, - envs: existing?.envs, - concurrent_limit: existing?.concurrent_limit, - concurrency_time_window_s: existing?.concurrency_time_window_s, - debounce_key: existing?.debounce_key, - debounce_delay_s: existing?.debounce_delay_s, - debounce_args_to_accumulate: existing?.debounce_args_to_accumulate, - max_total_debouncing_time: existing?.max_total_debouncing_time, - max_total_debounces_amount: existing?.max_total_debounces_amount, - cache_ttl: existing?.cache_ttl, - cache_ignore_s3_path: existingWithMetadata?.cache_ignore_s3_path, - dedicated_worker: existing?.dedicated_worker, - ws_error_handler_muted: existing?.ws_error_handler_muted, - priority: existing?.priority, - restart_unless_cancelled: existing?.restart_unless_cancelled, - timeout: existing?.timeout, - delete_after_secs: existing?.delete_after_secs, - deployment_message: deploymentMessage, - concurrency_key: existing?.concurrency_key, - visible_to_runner_only: existing?.visible_to_runner_only, - auto_kind: existing?.auto_kind, - codebase: existing?.codebase, - has_preprocessor: existing?.has_preprocessor, - on_behalf_of_email: existing?.on_behalf_of_email, - preserve_on_behalf_of: preserveOnBehalfOf(existing?.on_behalf_of_email), - assets: existingWithMetadata?.assets, - modules: existing?.modules, - labels: existing?.labels - } -} - -function flowValueWithDraftGroups(flowDraft: FlowDraftValue): FlowDraftValue['value'] { - if (flowDraft.groups === undefined) { - return flowDraft.value - } - return { - ...flowDraft.value, - groups: flowDraft.groups ?? undefined - } -} - -export function buildFlowDeployRequestBody( - path: string, - draftSummary: string | undefined, - flowDraft: FlowDraftValue, - existing: Flow | undefined, - deploymentMessage: string | undefined -): FlowDeployRequestBody { - return { - path, - summary: draftSummary ?? existing?.summary ?? '', - description: flowDraft.description ?? existing?.description ?? '', - value: flowValueWithDraftGroups(flowDraft), - schema: flowDraft.schema ?? existing?.schema ?? {}, - tag: existing?.tag, - ws_error_handler_muted: existing?.ws_error_handler_muted, - priority: existing?.priority, - dedicated_worker: existing?.dedicated_worker, - timeout: existing?.timeout, - visible_to_runner_only: existing?.visible_to_runner_only, - on_behalf_of_email: existing?.on_behalf_of_email, - preserve_on_behalf_of: preserveOnBehalfOf(existing?.on_behalf_of_email), - labels: existing?.labels, - deployment_message: deploymentMessage - } -} diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 6781818bb5..47d9e1096b 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -276,9 +276,9 @@ export async function deployDraft( kind: DraftKind, path: string, workspace: string, - draftOnly = false, - rawApp = false + opts: { draftOnly?: boolean; rawApp?: boolean; deploymentMessage?: string } = {} ): Promise { + const { draftOnly = false, rawApp = false, deploymentMessage } = opts try { if (kind === 'raw_app' || (kind === 'app' && rawApp)) { // Raw apps bundle their source files and deploy via the raw-app @@ -286,7 +286,7 @@ export async function deployDraft( // `kind === 'app'` + `rawApp` (editor). Must route here: the // visual-app branch would `updateApp` with no `value` (RawAppDraft // has none) and silently drop the draft's files. - await deployRawAppDraft(workspace, path) + await deployRawAppDraft(workspace, path, deploymentMessage) } else if (kind === 'script') { const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any const d = r.draft ?? r @@ -297,7 +297,12 @@ export async function deployDraft( // the editor: createScript at the new path with parent_hash links lineage). await ScriptService.createScript({ workspace, - requestBody: { ...rest, path: scriptPath, parent_hash: r.hash } + requestBody: { + ...rest, + path: scriptPath, + parent_hash: r.hash, + deployment_message: deploymentMessage + } }) // Then deploy any draft trigger edits, so they aren't dropped with the draft. await deployDraftTriggers(draftTriggers, workspace, scriptPath, true) @@ -319,7 +324,8 @@ export async function deployDraft( ws_error_handler_muted: d.ws_error_handler_muted, visible_to_runner_only: d.visible_to_runner_only, on_behalf_of_email: d.on_behalf_of_email, - labels: d.labels + labels: d.labels, + deployment_message: deploymentMessage } // Draft-only flows have NO flow row (they live solely in the // draft table), so they deploy via createFlow; a draft on a @@ -360,7 +366,8 @@ export async function deployDraft( // Honor the draft's intended path; `draft_path` holds the user-typed path // for a never-deployed app parked at a `u/{user}/draft_{uuid}` storage key. path: draftPath ?? r.path ?? path, - custom_path: isAdmin ? (r.custom_path ?? '') : undefined + custom_path: isAdmin ? (r.custom_path ?? '') : undefined, + deployment_message: deploymentMessage } // Same as flows: draft-only apps have no app row → create; // drafts on a deployed app update it.