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 14d4e76b79..b80a880d17 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -714,6 +714,80 @@ describe('global AI tools', () => { }) }) + it('reads a draft-only DB script anchor (no draft row) as a draft', async () => { + // A bare draft_only anchor with no draft row resolves to source 'deployed' + // (its own value is the only version), yet it IS a draft. + __seedDbRow('script', 'f/scripts/draft-only', { + path: 'f/scripts/draft-only', + hash: 'draft-anchor-hash', + draft_only: true, + summary: 'draft-only summary', + description: 'draft-only description', + content: 'draft-only content', + language: 'bun', + kind: 'script' + }) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'script', + path: 'f/scripts/draft-only' + }) + expect(JSON.parse(raw)).toEqual({ + type: 'script', + path: 'f/scripts/draft-only', + summary: 'draft-only summary', + language: 'bun', + value: 'draft-only content', + isDraft: true + }) + }) + + it('reads a draft-only DB flow anchor (no draft row) as a draft', async () => { + __seedDbRow('flow', 'f/flows/draft-only', { + path: 'f/flows/draft-only', + draft_only: true, + summary: 'draft-only flow summary', + value: { modules: [{ id: 'draft_only_step', value: { type: 'identity' } }] }, + schema: { type: 'object', properties: { draftOnly: { type: 'boolean' } } } + }) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'flow', + path: 'f/flows/draft-only' + }) + expect(JSON.parse(raw)).toMatchObject({ + type: 'flow', + path: 'f/flows/draft-only', + summary: 'draft-only flow summary', + isDraft: true, + value: { + modules: [{ id: 'draft_only_step', value: { type: 'identity' } }], + schema: { type: 'object', properties: { draftOnly: { type: 'boolean' } } } + } + }) + }) + + it('reads a draft-only DB app anchor (no draft row) as a draft', async () => { + // The app anchor's `value` nests the raw app the way appSourceToDraftValue reads. + __seedDbRow('app', 'f/apps/draft-only', { + path: 'f/apps/draft-only', + draft_only: true, + summary: 'draft-only app summary', + value: { files: { '/index.tsx': 'console.log("draft")' }, runnables: {}, data: { tables: [] } } + }) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'app', + path: 'f/apps/draft-only' + }) + expect(JSON.parse(raw)).toMatchObject({ + type: 'app', + path: 'f/apps/draft-only', + summary: 'draft-only app summary', + isDraft: true + }) + }) + it('applies path_prefix to local (live-editor) drafts before enforcing the result limit', async () => { // list_workspace_items still surfaces live-editor localStorage drafts, so // drive it through live editors rather than DB-only writes. @@ -828,6 +902,47 @@ describe('global AI tools', () => { }) }) + it('list_workspace_items shows the fresh DB draft summary, not the stale anchor', async () => { + // The draft_only anchor is intentionally NOT updated on a re-save, so its + // list summary goes stale. A newer DB draft exists; the list must surface + // the draft's summary and flag the row as a draft. + __seedDbRow('script', 'f/scripts/stale-list-summary', { + path: 'f/scripts/stale-list-summary', + draft_only: true, + draft: { + path: 'f/scripts/stale-list-summary', + summary: 'Fresh DB draft summary', + description: '', + content: 'export async function main() { return "fresh" }', + language: 'bun', + kind: 'script' + } + }) + vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([ + { + path: 'f/scripts/stale-list-summary', + summary: 'Old deployed summary', + language: 'bun', + has_draft: true + } + ] as any) + + const raw = await callGlobalTool('list_workspace_items', { + types: ['script'], + query: 'Fresh DB draft' + }) + + expect(JSON.parse(raw)).toEqual([ + expect.objectContaining({ + type: 'script', + path: 'f/scripts/stale-list-summary', + summary: 'Fresh DB draft summary', + isDraft: true + }) + ]) + expect(raw).not.toContain('Old deployed summary') + }) + it('lists and edits the live script editor draft through its effective path', async () => { UserDraft.save( 'script', @@ -1073,8 +1188,41 @@ describe('global AI tools', () => { }) expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({ workspace: WORKSPACE, - path: 'f/scripts/db-only' + path: 'f/scripts/db-only', + keepCaptures: true }) + // Anchor deletion happens BEFORE the draft row deletion (failure-resilient). + expect(vi.mocked(ScriptService.deleteScriptByPath).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(DraftService.deleteDraft).mock.invocationCallOrder[0] + ) + }) + + it('keeps the DB draft when draft_only anchor deletion fails', async () => { + // A draft_only script whose anchor delete is rejected (e.g. deployment rules). + // Because the anchor is deleted first, the draft row must remain untouched. + __seedDbRow('script', 'f/scripts/blocked-discard', { + path: 'f/scripts/blocked-discard', + draft_only: true, + draft: { + path: 'f/scripts/blocked-discard', + summary: 'Temporary draft', + content: 'export async function main() { return 1 }', + language: 'bun', + kind: 'script' + } + }) + vi.mocked(ScriptService.deleteScriptByPath).mockRejectedValueOnce( + new Error('deployment rules blocked deletion') + ) + + await expect( + callGlobalTool('discard_draft', { + type: 'script', + path: 'f/scripts/blocked-discard' + }) + ).rejects.toThrow('deployment rules blocked deletion') + + expect(DraftService.deleteDraft).not.toHaveBeenCalled() }) it('discards a DB draft over a deployed flow without deleting the deployed item', async () => { @@ -1126,7 +1274,8 @@ describe('global AI tools', () => { }) expect(FlowService.deleteFlowByPath).toHaveBeenCalledWith({ workspace: WORKSPACE, - path: 'f/flows/db-only' + path: 'f/flows/db-only', + keepCaptures: true }) }) @@ -1921,6 +2070,181 @@ describe('global AI tools', () => { expect(DraftService.deleteDraft).not.toHaveBeenCalled() }) + it('deploys DB script drafts with the draft metadata (not the deployed version)', async () => { + // A deployed script with a DB draft whose metadata (tag/timeout/labels) + // differs. The deploy must carry the DRAFT's metadata, parent on the + // deployed hash, and re-infer the schema from the draft content. + __seedDbRow('script', 'f/scripts/existing', { + path: 'f/scripts/existing', + hash: 'deployed-hash', + draft_only: false, + summary: 'deployed summary', + description: 'deployed description', + content: 'deployed content', + language: 'bun', + kind: 'script', + tag: 'deployed-tag', + envs: ['DEPLOYED_ENV'], + timeout: 60, + visible_to_runner_only: true, + labels: ['deployed'], + draft: { + path: 'f/scripts/existing', + summary: 'db draft summary', + description: 'db draft description', + content: 'export async function main(x: number) { return x }', + language: 'bun', + kind: 'script', + tag: 'draft-tag', + envs: [], + timeout: 0, + visible_to_runner_only: false, + labels: [] + } + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/existing', + deployment_message: 'ship db draft' + }) + + expect(ScriptService.createScript).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'f/scripts/existing', + parent_hash: 'deployed-hash', + summary: 'db draft summary', + description: 'db draft description', + content: 'export async function main(x: number) { return x }', + language: 'bun', + tag: 'draft-tag', + envs: [], + timeout: 0, + visible_to_runner_only: false, + labels: [], + deployment_message: 'ship db draft' + }) + }) + // Schema is re-inferred from the draft content. + const body = vi.mocked(ScriptService.createScript).mock.calls[0][0].requestBody as any + expect(body.schema?.properties).toHaveProperty('x') + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + }) + + it('deploys a renamed script draft to the draft target path', async () => { + // The DB draft carries a renamed path; deploy must target that path and + // clean up the stale old-lookup-path draft + draft_only anchor. + __seedDbRow('script', 'f/scripts/old-name', { + path: 'f/scripts/old-name', + hash: 'old-script-hash', + draft_only: false, + summary: 'deployed summary', + description: 'deployed description', + content: 'deployed content', + language: 'bun', + kind: 'script', + draft: { + path: 'f/scripts/new-name', + summary: 'renamed draft summary', + description: 'renamed draft description', + content: 'renamed draft content', + language: 'bun', + kind: 'script' + } + }) + + const raw = await callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/old-name' + }) + + expect(ScriptService.createScript).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'f/scripts/new-name', + parent_hash: 'old-script-hash', + summary: 'renamed draft summary', + description: 'renamed draft description', + content: 'renamed draft content' + }) + }) + // Deployed version existed -> the old anchor is NOT deleted, only the stale + // draft row at the old path. + expect(ScriptService.deleteScriptByPath).not.toHaveBeenCalled() + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'script', + path: 'f/scripts/old-name' + }) + expect(JSON.parse(raw)).toMatchObject({ + success: true, + type: 'script', + path: 'f/scripts/new-name' + }) + }) + + it('removes the old draft_only anchor when deploying a renamed draft-only script', async () => { + // A never-deployed (draft_only) script renamed in its draft. Deploy targets + // the new path and must remove the stale old-path draft AND its anchor. + __seedDbRow('script', 'f/scripts/old-only', { + path: 'f/scripts/old-only', + draft_only: true, + draft: { + path: 'f/scripts/new-only', + summary: 'renamed draft-only summary', + description: '', + content: 'export async function main() { return 1 }', + language: 'bun', + kind: 'script' + } + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/old-only' + }) + + expect(ScriptService.createScript).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ path: 'f/scripts/new-only' }) + }) + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'script', + path: 'f/scripts/old-only' + }) + expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/scripts/old-only', + keepCaptures: true + }) + }) + + it('does NOT call deleteDraft on a non-rename deploy (the backend handles it)', async () => { + __seedDbRow('script', 'f/scripts/same-path', { + path: 'f/scripts/same-path', + draft_only: true, + draft: { + path: 'f/scripts/same-path', + summary: 'DB draft', + description: '', + content: 'export async function main() { return 1 }', + language: 'bun', + kind: 'script' + } + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/same-path' + }) + + expect(ScriptService.createScript).toHaveBeenCalledTimes(1) + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + expect(ScriptService.deleteScriptByPath).not.toHaveBeenCalled() + }) + it('errors when deploying a script that has only a deployed version (no draft)', async () => { __seedDbRow('script', 'f/scripts/deployed-only', { path: 'f/scripts/deployed-only', @@ -2033,6 +2357,110 @@ describe('global AI tools', () => { expect(DraftService.deleteDraft).not.toHaveBeenCalled() }) + it('deploys DB flow drafts with the draft metadata (not the deployed version)', async () => { + vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) + __seedDbRow('flow', 'f/flows/existing', { + path: 'f/flows/existing', + draft_only: false, + summary: 'deployed summary', + description: 'deployed description', + value: { modules: [{ id: 'deployed_step', value: { type: 'identity' } }] }, + schema: { type: 'object', properties: { deployed: { type: 'boolean' } } }, + tag: 'deployed-tag', + timeout: 60, + visible_to_runner_only: true, + labels: ['deployed'], + draft: { + path: 'f/flows/existing', + summary: 'db draft summary', + description: 'db draft description', + value: { modules: [{ id: 'draft_step', value: { type: 'identity' } }] }, + schema: { type: 'object', properties: { draft: { type: 'string' } } }, + tag: 'draft-tag', + timeout: 0, + visible_to_runner_only: false, + labels: [] + } + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'flow', + path: 'f/flows/existing', + deployment_message: 'ship db draft' + }) + + expect(FlowService.updateFlow).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/flows/existing', + requestBody: expect.objectContaining({ + path: 'f/flows/existing', + summary: 'db draft summary', + description: 'db draft description', + schema: { type: 'object', properties: { draft: { type: 'string' } } }, + tag: 'draft-tag', + timeout: 0, + visible_to_runner_only: false, + labels: [], + deployment_message: 'ship db draft' + }) + }) + expect(vi.mocked(FlowService.updateFlow).mock.calls[0]?.[0].requestBody.value.modules).toEqual([ + { id: 'draft_step', value: { type: 'identity' } } + ]) + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + }) + + it('deploys a renamed flow draft through the original route path', async () => { + vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) + __seedDbRow('flow', 'f/flows/old-name', { + path: 'f/flows/old-name', + draft_only: false, + summary: 'deployed summary', + description: 'deployed description', + value: { modules: [{ id: 'deployed_step', value: { type: 'identity' } }] }, + schema: { type: 'object', properties: { deployed: { type: 'boolean' } } }, + draft: { + path: 'f/flows/new-name', + summary: 'renamed draft summary', + description: 'renamed draft description', + value: { modules: [{ id: 'draft_step', value: { type: 'identity' } }] }, + schema: { type: 'object', properties: { draft: { type: 'string' } } } + } + }) + + const raw = await callGlobalTool('deploy_workspace_item', { + type: 'flow', + path: 'f/flows/old-name' + }) + + // Original path in the URL, renamed path in the body. + expect(FlowService.updateFlow).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/flows/old-name', + requestBody: expect.objectContaining({ + path: 'f/flows/new-name', + summary: 'renamed draft summary', + description: 'renamed draft description', + schema: { type: 'object', properties: { draft: { type: 'string' } } } + }) + }) + expect(vi.mocked(FlowService.updateFlow).mock.calls[0]?.[0].requestBody.value.modules).toEqual([ + { id: 'draft_step', value: { type: 'identity' } } + ]) + // Deployed version existed -> only the stale old-path draft is removed. + expect(FlowService.deleteFlowByPath).not.toHaveBeenCalled() + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'flow', + path: 'f/flows/old-name' + }) + expect(JSON.parse(raw)).toMatchObject({ + success: true, + type: 'flow', + path: 'f/flows/new-name' + }) + }) + it('errors when deploying a flow that has only a deployed version (no draft)', async () => { __seedDbRow('flow', 'f/flows/deployed-only', { path: 'f/flows/deployed-only', @@ -2162,6 +2590,68 @@ describe('global AI tools', () => { expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) + it('deploys a renamed app draft through the original route path', async () => { + vi.mocked(AppService.existsApp).mockResolvedValueOnce(true) + // A deployed app whose DB draft carries a renamed path. The draft `value` + // nests the raw app + the renamed `path`, the way appSourceToDraftValue reads. + __seedDbRow('app', 'f/apps/old-report', { + path: 'f/apps/old-report', + summary: 'deployed report', + versions: [1], + value: { files: {}, runnables: {}, data: { tables: [] } }, + policy: { execution_mode: 'anonymous' }, + draft: { + path: 'f/apps/new-report', + summary: 'Renamed report', + value: { + files: { '/index.tsx': 'console.log("renamed")' }, + runnables: {}, + data: { tables: ['renamed'] } + }, + policy: { execution_mode: 'anonymous' } + } + }) + + const raw = await callGlobalTool('deploy_workspace_item', { + type: 'app', + path: 'f/apps/old-report' + }) + + // Original path in the URL, renamed path in the body. + expect(AppService.updateAppRaw).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/apps/old-report', + formData: { + app: { + path: 'f/apps/new-report', + value: { + files: { '/index.tsx': 'console.log("renamed")' }, + runnables: {}, + data: { tables: ['renamed'] } + }, + summary: 'Renamed report', + policy: expect.objectContaining({ execution_mode: 'anonymous' }), + deployment_message: undefined + }, + js: 'bundled js', + css: 'bundled css' + } + }) + expect(AppService.createAppRaw).not.toHaveBeenCalled() + // Deployed version existed -> only the stale old-path draft is removed. + expect(AppService.deleteApp).not.toHaveBeenCalled() + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'app', + path: 'f/apps/old-report' + }) + expect(JSON.parse(raw)).toMatchObject({ + success: true, + type: 'app', + path: 'f/apps/new-report' + }) + }) + it('notifies the session preview (as raw_app) after deploying a raw app', async () => { const onDeployed = vi.fn() setDeployedInSessionHandler(onDeployed) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index bd335e15ed..b112a641cf 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -95,7 +95,6 @@ import { type WorkspaceItem, type WorkspaceItemType } from './workspaceItems' -import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' import { userStore } from '$lib/stores' import { get } from 'svelte/store' import { bundleRawAppDraft } from './rawAppBundlerBridge' @@ -1182,7 +1181,9 @@ async function readWorkspaceItem( path, summary: loaded.value.summary, value: metadata as unknown as AppDraftValue, - isDraft: loaded.source === 'live' || loaded.source === 'db' + // A bare `draft_only` anchor (no draft row) reads as 'deployed' but is + // a draft — flag it via `draftOnly` too. + isDraft: loaded.source === 'live' || loaded.source === 'db' || loaded.draftOnly } } } @@ -1202,18 +1203,45 @@ async function readScriptOrFlowItem( // Call `loadDraft` with the literal type in each branch so its return value is // narrowed to NewScript / Flow (a `'script' | 'flow'` arg would leave it a // `NewScript | Flow` union that neither converter accepts). + // A bare `draft_only` anchor with no draft row reads `source: 'deployed'` + // (the anchor's own value is the only version), yet it IS a draft — flag it. if (type === 'script') { const loaded = await loadDraft('script', path, workspace) if (!loaded.value) throw new Error(`Script "${path}" not found.`) - const isDraft = loaded.source === 'live' || loaded.source === 'db' + const isDraft = loaded.source === 'live' || loaded.source === 'db' || loaded.draftOnly return { ...scriptToWorkspaceItem(path, loaded.value), isDraft } } const loaded = await loadDraft('flow', path, workspace) if (!loaded.value) throw new Error(`Flow "${path}" not found.`) - const isDraft = loaded.source === 'live' || loaded.source === 'db' + const isDraft = loaded.source === 'live' || loaded.source === 'db' || loaded.draftOnly return { ...flowToWorkspaceItem(path, loaded.value), isDraft } } +/** + * The list endpoints return the row from the item table — whose summary is the + * deployed (or, for a `draft_only` anchor, the original) one. The anchor is + * intentionally NOT updated on a re-save, so a row with a live DB draft can show + * a stale summary. For rows flagged as carrying a draft (`has_draft`/`draft_only`) + * we re-resolve summary/metadata through `loadDraft` (precedence-aware, so a live + * editor's unsaved summary wins), keeping the listed item metadata-only + * (`value: undefined`) and `isDraft: true`. + */ +async function hydrateListedDbDraft( + type: 'script' | 'flow', + item: WorkspaceItem, + workspace: string +): Promise { + try { + const loaded = await loadDraft(type, item.path, workspace) + if (!loaded.value) return item + const isDraft = loaded.source === 'live' || loaded.source === 'db' || loaded.draftOnly + return { ...item, summary: loaded.value.summary, value: undefined, isDraft } + } catch { + // A transient read failure for one row must not sink the whole list. + return item + } +} + async function listWorkspaceItems( types: WorkspaceItemType[], workspace: string, @@ -1230,7 +1258,14 @@ async function listWorkspaceItems( includeDraftOnly: true, withoutDescription: true }) - for (const script of scripts) items.push(scriptToItem(script, false)) + for (const script of scripts) { + const item = scriptToItem(script, false) + items.push( + script.has_draft || script.draft_only + ? await hydrateListedDbDraft('script', item, workspace) + : item + ) + } } if (types.includes('flow')) { @@ -1241,7 +1276,14 @@ async function listWorkspaceItems( includeDraftOnly: true, withoutDescription: true }) - for (const flow of flows) items.push(flowToItem(flow, false)) + for (const flow of flows) { + const item = flowToItem(flow, false) + items.push( + flow.has_draft || flow.draft_only + ? await hydrateListedDbDraft('flow', item, workspace) + : item + ) + } } if (types.includes('schedule')) { @@ -1288,6 +1330,10 @@ async function listWorkspaceItems( pathStart: pathPrefix, perPage }) + // The generated `ListableApp` exposes no `has_draft`/`draft_only` flags, so + // we can't bound an app-draft hydration the way scripts/flows do. Accept a + // possibly-stale app summary here for now; client-side regeneration of those + // flags (or a per-app draft read) is the follow-up. for (const app of apps) items.push(appToItem(app, false)) } @@ -3184,22 +3230,26 @@ async function discardDraft( throw new Error(`No draft found for ${type} "${path}".`) } - if (db.hasDbDraft) { - await DraftService.deleteDraft({ workspace, kind: type, path }) - } - - // Only delete the item when it exists purely as a draft. A deployed version - // must survive the discard untouched. + // Order matters: delete the `draft_only` anchor FIRST, then the draft row. + // Deployment rules can reject the anchor delete (it throws); doing it first + // means a failure leaves the draft row intact and recoverable. Only delete + // the item when it exists purely as a draft — a deployed version must + // survive the discard untouched. `keepCaptures` mirrors the editors so a + // later re-create keeps its capture history. if (db.draftOnly) { if (type === 'script') { - await ScriptService.deleteScriptByPath({ workspace, path }) + await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true }) } else if (type === 'flow') { - await FlowService.deleteFlowByPath({ workspace, path }) + await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true }) } else { await AppService.deleteApp({ workspace, path }) } } + if (db.hasDbDraft) { + await DraftService.deleteDraft({ workspace, kind: type, path }) + } + // Full clear of the live-editor mirror — the user is discarding. deleteGlobalDraft(workspace, type, path) @@ -3283,6 +3333,15 @@ async function deployDraft( }) let actions: ToolDisplayAction[] | undefined + // The path the draft is deployed AT. Group-A branches set it to the draft's + // own path (honouring a rename); it differs from the lookup `path` only when + // the draft was renamed. The create/update body, success message, post-deploy + // cleanup and the session-preview reload all key off this. + let deployPath = path + // Whether the Group-A item existed only as a `draft_only` anchor (no deployed + // version). On a rename deploy this decides whether the stale old-path anchor + // needs explicit cleanup. + let oldDraftOnly = false switch (type) { case 'script': { @@ -3293,15 +3352,54 @@ async function deployDraft( if (loaded.source === 'deployed') { throw new Error(`No draft changes to deploy for script "${path}".`) } - const existing = (await ScriptService.existsScriptByPath({ workspace, path })) - ? await ScriptService.getScriptByPath({ workspace, path }) - : undefined - const requestBody = buildScriptDeployRequestBody( - path, - scriptToWorkspaceItem(path, loaded.value), - existing, - deploymentMessage - ) + deployPath = loaded.value.path ?? path + oldDraftOnly = loaded.draftOnly + // Mirror ScriptBuilder's createScript body field-for-field, sourced from + // the in-memory draft (`loaded.value` is the full NewScript). A raw spread + // would leak the DB draft's stray `draft_triggers`/`draft_only`/`hash`, + // so we list the fields explicitly. The deploy parents on the deployed + // hash (`meta.remoteRev`) without an extra fetch. + const draft = loaded.value + const requestBody: NewScript = { + path: deployPath, + summary: draft.summary ?? '', + description: draft.description ?? '', + content: draft.content, + // `meta.remoteRev` is the deployed script hash (a string); fall back to + // any parent already carried on the draft. + parent_hash: (loaded.meta.remoteRev as string | undefined) ?? draft.parent_hash, + schema: draft.schema, + is_template: draft.is_template, + language: draft.language, + kind: draft.kind, + tag: draft.tag, + envs: draft.envs, + dedicated_worker: draft.dedicated_worker, + concurrent_limit: draft.concurrent_limit, + concurrency_time_window_s: draft.concurrency_time_window_s, + debounce_key: draft.debounce_key, + debounce_delay_s: draft.debounce_delay_s, + debounce_args_to_accumulate: draft.debounce_args_to_accumulate, + max_total_debouncing_time: draft.max_total_debouncing_time, + max_total_debounces_amount: draft.max_total_debounces_amount, + cache_ttl: draft.cache_ttl, + cache_ignore_s3_path: draft.cache_ignore_s3_path, + ws_error_handler_muted: draft.ws_error_handler_muted, + priority: draft.priority, + restart_unless_cancelled: draft.restart_unless_cancelled, + timeout: draft.timeout, + delete_after_secs: draft.delete_after_secs, + concurrency_key: draft.concurrency_key, + visible_to_runner_only: draft.visible_to_runner_only, + auto_kind: draft.auto_kind, + has_preprocessor: draft.has_preprocessor, + deployment_message: deploymentMessage, + on_behalf_of_email: draft.on_behalf_of_email, + preserve_on_behalf_of: draft.on_behalf_of_email ? true : undefined, + assets: draft.assets, + modules: draft.modules, + labels: draft.labels + } // Infer the arg schema from the content so it matches the code, like the editor does. try { const schema = emptySchema() @@ -3321,17 +3419,34 @@ async function deployDraft( if (loaded.source === 'deployed') { throw new Error(`No draft changes to deploy for flow "${path}".`) } - const existing = (await FlowService.existsFlowByPath({ workspace, path })) - ? await FlowService.getFlowByPath({ workspace, path }) - : undefined - const requestBody = buildFlowDeployRequestBody( - path, - loaded.value.summary, - flowToFlowDraftValue(loaded.value), - existing, - deploymentMessage - ) - if (existing) { + deployPath = loaded.value.path ?? path + oldDraftOnly = loaded.draftOnly + // Create-vs-update keys off the deployed item at the *original* lookup + // path, not the renamed target. + const deployedExists = await FlowService.existsFlowByPath({ workspace, path }) + // Mirror FlowBuilder's update/create body, sourced from the draft Flow. + // Backend-only fields (edited_by/edited_at/archived/extra_perms) and the + // DB draft's stray fields are excluded by the explicit list. + const draft = loaded.value + const requestBody = { + path: deployPath, + summary: draft.summary ?? '', + description: draft.description ?? '', + value: draft.value, + schema: draft.schema, + tag: draft.tag, + ws_error_handler_muted: draft.ws_error_handler_muted, + priority: draft.priority, + dedicated_worker: draft.dedicated_worker, + timeout: draft.timeout, + visible_to_runner_only: draft.visible_to_runner_only, + on_behalf_of_email: draft.on_behalf_of_email, + preserve_on_behalf_of: draft.on_behalf_of_email ? true : undefined, + labels: (draft as { labels?: string[] }).labels, + deployment_message: deploymentMessage + } + if (deployedExists) { + // Original path in the URL, renamed path in the body — the rename move. await FlowService.updateFlow({ workspace, path, requestBody }) } else { await FlowService.createFlow({ workspace, requestBody }) @@ -3393,6 +3508,10 @@ async function deployDraft( if (loaded.source === 'deployed') { throw new Error(`No draft changes to deploy for app "${path}".`) } + // `AppDraftValue` has no `path`; `loadDraft` surfaces the draft's stored + // path separately so we can honour an app rename. + deployPath = loaded.draftPath ?? path + oldDraftOnly = loaded.draftOnly const appDraft = loaded.value const appValue: AppDraftValue = { ...appDraft, @@ -3435,16 +3554,18 @@ async function deployDraft( data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA } } const summary = appValue.summary ?? '' + // Create-vs-update keys off the deployed app at the *original* lookup path. 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. + // Original path in the URL, renamed `deployPath` in the body — the rename move. await AppService.updateAppRaw({ workspace, path, formData: { app: { - path, + path: deployPath, value: rawAppValue, summary, policy, @@ -3459,7 +3580,7 @@ async function deployDraft( workspace, formData: { app: { - path, + path: deployPath, value: rawAppValue, summary, policy, @@ -3475,7 +3596,27 @@ async function deployDraft( } } - deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) + // On a rename deploy the backend deletes the draft row at the *new* target + // path only; the stale old-lookup-path draft (and its draft_only anchor, if + // the item never had a deployed version) would otherwise linger as a phantom. + // Delete them explicitly. For a non-rename deploy the backend already removes + // the draft on a non-draft create/update — adding a deleteDraft there would be + // redundant. (capture/config moves on rename are a follow-up.) + const renamed = (type === 'script' || type === 'flow' || type === 'app') && deployPath !== path + if (renamed) { + await DraftService.deleteDraft({ workspace, kind: type, path }) + if (oldDraftOnly) { + if (type === 'script') { + await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true }) + } else if (type === 'flow') { + await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true }) + } else { + await AppService.deleteApp({ workspace, path }) + } + } + } + + deleteGlobalDraft(workspace, type, deployPath, triggerKind, { preserveLiveDraft: true }) // Reload the session preview if it's open on the deployed item. Map the // deploy type to the preview kind — a raw app deploys under 'app' but the @@ -3486,19 +3627,19 @@ async function deployDraft( app: 'raw_app' } const kind = previewKindByType[type] - if (kind) deployedInSessionHandler?.({ sessionId, kind, path }) + if (kind) deployedInSessionHandler?.({ sessionId, kind, path: deployPath }) toolCallbacks.setToolStatus(toolId, { - content: `Deployed ${type} "${path}"`, + content: `Deployed ${type} "${deployPath}"`, result: 'Deployed', actions }) return JSON.stringify( { success: true, - message: `Deployed draft ${type} "${path}" to the workspace. Draft removed.`, + message: `Deployed draft ${type} "${deployPath}" to the workspace. Draft removed.`, type, - path, + path: deployPath, triggerKind }, null, diff --git a/frontend/src/lib/components/copilot/chat/global/dbDraft.ts b/frontend/src/lib/components/copilot/chat/global/dbDraft.ts index 42b3b950c1..77ede35b31 100644 --- a/frontend/src/lib/components/copilot/chat/global/dbDraft.ts +++ b/frontend/src/lib/components/copilot/chat/global/dbDraft.ts @@ -178,6 +178,13 @@ export type DbDraftRead = { hasDbDraft: boolean value: V | undefined meta: UserDraftMeta + /** + * The path stored ON the resolved draft/deployed source, when it differs from + * the lookup path (a draft rename). `AppDraftValue` has no `path` field, so the + * app read surfaces it here for the deploy path; scripts/flows carry `path` on + * `value` directly and leave this undefined. + */ + draftPath?: string } const NOT_FOUND: DbDraftRead = { @@ -316,13 +323,17 @@ export async function readAppDbDraft( // `app.draft` nests the raw app under `.value`; deployed apps expose it the // same way. `appSourceToDraftValue` reads `.value`/`.summary`/`.policy`/ // `.custom_path`, falling back to the deployed app for policy/custom_path. - const value = appSourceToDraftValue(hasDbDraft ? app.draft : app, app) + const source = hasDbDraft ? app.draft : app + const value = appSourceToDraftValue(source, app) return { itemExists: true, deployedExists: !draftOnly, draftOnly, hasDbDraft, value, - meta: appDraftMeta(app) + meta: appDraftMeta(app), + // `AppDraftValue` drops `path`; surface the source's stored path so the + // deploy path can honour an app draft rename. + draftPath: (source as { path?: string }).path } } 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 88cbafe205..0000000000 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts +++ /dev/null @@ -1,165 +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('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 9e779779f6..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: 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/components/copilot/chat/global/draftStore.ts b/frontend/src/lib/components/copilot/chat/global/draftStore.ts index 7fb319e252..901091f363 100644 --- a/frontend/src/lib/components/copilot/chat/global/draftStore.ts +++ b/frontend/src/lib/components/copilot/chat/global/draftStore.ts @@ -76,6 +76,12 @@ export type LoadedDraft = { deployedExists: boolean draftOnly: boolean hasDbDraft: boolean + /** + * The path stored on the resolved source when it differs from the lookup path + * (a draft rename). Only the app read surfaces it (its value has no `path`); + * scripts/flows carry `path` on `value`. Undefined on the live branch. + */ + draftPath?: string } function readDbDraft( @@ -174,7 +180,8 @@ export async function loadDraft( itemExists: db.itemExists, deployedExists: db.deployedExists, draftOnly: db.draftOnly, - hasDbDraft: db.hasDbDraft + hasDbDraft: db.hasDbDraft, + draftPath: db.draftPath } }