From 36012f7425bdb42f86a54c4f10d6b8ae6cbda509 Mon Sep 17 00:00:00 2001 From: centdix Date: Wed, 3 Jun 2026 15:48:47 +0200 Subject: [PATCH] fix: preserve db draft rename deploys --- .../copilot/chat/global/core.test.ts | 134 ++++++++++++++++++ .../components/copilot/chat/global/core.ts | 16 ++- .../copilot/chat/global/workspaceDrafts.ts | 39 +++-- 3 files changed, 171 insertions(+), 18 deletions(-) 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 39523375b4..044c696e08 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -987,6 +987,50 @@ describe('global AI tools', () => { expect(ScriptService.getScriptByPath).not.toHaveBeenCalled() }) + it('deploys saved DB script rename drafts to the draft target path', async () => { + vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) + vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ + path: 'f/scripts/old-name', + hash: 'old-script-hash', + summary: 'deployed summary', + description: 'deployed description', + content: 'deployed content', + schema: {}, + is_template: false, + 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' + } + } as any) + + 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' + }) + }) + expect(JSON.parse(raw)).toMatchObject({ + success: true, + type: 'script', + path: 'f/scripts/new-name' + }) + }) + it('deploys DB flow drafts with draft metadata when no local draft exists', async () => { vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ @@ -1051,6 +1095,52 @@ describe('global AI tools', () => { expect(FlowService.getFlowByPath).not.toHaveBeenCalled() }) + it('deploys saved DB flow rename drafts through the original route path', async () => { + vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) + vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ + path: 'f/flows/old-name', + 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' } } } + } + } as any) + + const raw = await callGlobalTool('deploy_workspace_item', { + type: 'flow', + path: 'f/flows/old-name' + }) + + 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' } } + ]) + expect(JSON.parse(raw)).toMatchObject({ + success: true, + type: 'flow', + path: 'f/flows/new-name' + }) + }) + it('applies path_prefix to local drafts before enforcing the result limit', async () => { await callGlobalTool('write_script', { path: 'f/other/outside', @@ -1913,6 +2003,50 @@ describe('global AI tools', () => { expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) + it('deploys saved DB app rename drafts through the original route path', async () => { + appDrafts.set('f/apps/old-report', { + 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' + }) + + 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() + 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 c1e8f48e3a..cefac3c916 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -3072,9 +3072,11 @@ async function deployDraft( if (draft.value === undefined) { throw new Error(`Draft ${type} "${path}" has no value to deploy.`) } + const deployPath = + type === 'script' || type === 'flow' || type === 'app' ? (draft.path ?? path) : path toolCallbacks.setToolStatus(toolId, { - content: `Deploying ${type} "${path}"...` + content: `Deploying ${type} "${deployPath}"...` }) let actions: ToolDisplayAction[] | undefined @@ -3083,7 +3085,7 @@ async function deployDraft( case 'script': { await deployScriptDraft({ workspace, - path, + path: deployPath, draft, existing: deployableDraft.existingScript, deploymentMessage, @@ -3095,6 +3097,7 @@ async function deployDraft( await deployFlowDraft({ workspace, path, + targetPath: deployPath, draft, existing: deployableDraft.existingFlow, deploymentMessage, @@ -3153,6 +3156,7 @@ async function deployDraft( await deployAppDraft({ workspace, path, + targetPath: deployPath, draft, appExists: deployableDraft.appExists, deploymentMessage, @@ -3175,19 +3179,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 from the draft system.`, + message: `Deployed draft ${type} "${deployPath}" to the workspace. Draft removed from the draft system.`, type, - path, + path: deployPath, triggerKind }, null, diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts b/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts index 4ca27f4ede..b89a74b8bb 100644 --- a/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts +++ b/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts @@ -188,7 +188,9 @@ export async function loadDbDraftForDeploy( 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 + const draftScript = script.draft + ? { ...script, ...script.draft, path: script.draft.path ?? script.path } + : script return { item: scriptToItem(draftScript, true, true), existingScript: script, @@ -199,7 +201,9 @@ export async function loadDbDraftForDeploy( 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 + const draftFlow = flow.draft + ? { ...flow, ...flow.draft, path: flow.draft.path ?? flow.path } + : flow return { item: flowToItem(draftFlow, true, true), existingFlow: flow, @@ -210,11 +214,12 @@ export async function loadDbDraftForDeploy( if (!(await AppService.existsApp({ workspace, path }))) return undefined const app = await AppService.getAppByPathWithDraft({ workspace, path }) if (!app.draft && !app.draft_only) return undefined + const draftPath = (app.draft as { path?: string } | undefined)?.path ?? app.path const value = appSourceToDraftValue(app.draft ?? app, app) return { item: { type: 'app', - path: app.path, + path: draftPath, summary: value.summary, value, isDraft: true @@ -310,15 +315,24 @@ export async function deployScriptDraft(args: { export async function deployFlowDraft(args: { workspace: string path: string + targetPath?: string draft: WorkspaceItem existing?: FlowDeployMetadata deploymentMessage?: string draftMetadata?: FlowDeployMetadata }): Promise { - const { workspace, path, draft, existing, deploymentMessage, draftMetadata } = args + const { + workspace, + path, + targetPath = path, + draft, + existing, + deploymentMessage, + draftMetadata + } = args const flowDraft = draft.value as FlowDraftValue const requestBody = buildFlowDeployRequestBody( - path, + targetPath, draft.summary, flowDraft, existing, @@ -335,12 +349,13 @@ export async function deployFlowDraft(args: { export async function deployAppDraft(args: { workspace: string path: string + targetPath?: string draft: WorkspaceItem appExists?: boolean deploymentMessage?: string onStatus?: DeployStatusHandler }): Promise { - const { workspace, path, draft, appExists, deploymentMessage, onStatus } = args + const { workspace, path, targetPath = path, draft, appExists, deploymentMessage, onStatus } = args const appDraft = draft.value as AppDraftValue const appValue: AppDraftValue = { ...appDraft, @@ -351,10 +366,10 @@ export async function deployAppDraft(args: { await recomputeAppPolicy(appValue) const policy = appValue.policy if (!policy) { - throw new Error(`Draft app "${path}" has no policy to deploy.`) + throw new Error(`Draft app "${targetPath}" has no policy to deploy.`) } - onStatus?.(`Bundling app "${path}"...`) + onStatus?.(`Bundling app "${targetPath}"...`) const bundle = await bundleRawAppDraft({ workspace, files: appValue.files, @@ -365,12 +380,12 @@ export async function deployAppDraft(args: { .filter(Boolean) const latest = lines[lines.length - 1] if (latest) { - onStatus?.(`Bundling app "${path}"... ${latest}`) + onStatus?.(`Bundling app "${targetPath}"... ${latest}`) } } }) - onStatus?.(`Deploying app "${path}"...`) + onStatus?.(`Deploying app "${targetPath}"...`) const rawAppValue = { files: appValue.files, runnables: appValue.runnables, @@ -386,7 +401,7 @@ export async function deployAppDraft(args: { path, formData: { app: { - path, + path: targetPath, value: rawAppValue, summary, policy, @@ -401,7 +416,7 @@ export async function deployAppDraft(args: { workspace, formData: { app: { - path, + path: targetPath, value: rawAppValue, summary, policy,