From 47f1d410005a3c9fbf115fb92151c9293a8a02f5 Mon Sep 17 00:00:00 2001 From: centdix Date: Tue, 2 Jun 2026 18:38:02 +0200 Subject: [PATCH] fix: deploy ai chat drafts with metadata --- .../copilot/chat/global/core.test.ts | 155 ++++++++++++++++++ .../components/copilot/chat/global/core.ts | 96 +++++++++-- .../chat/global/deployRequests.test.ts | 155 +++++++++++++++++- .../copilot/chat/global/deployRequests.ts | 154 +++++++++++------ 4 files changed, 493 insertions(+), 67 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 6b5d159931..99467a4ebd 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -794,6 +794,161 @@ describe('global AI tools', () => { }) }) + it('deploys DB script drafts with draft metadata when no local draft exists', async () => { + vi.mocked(ScriptService.existsScriptByPath) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ + path: 'f/scripts/existing', + hash: 'deployed-hash', + summary: 'deployed summary', + description: 'deployed description', + content: 'deployed content', + schema: {}, + is_template: false, + 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: 'db draft content', + language: 'bun', + kind: 'script', + tag: 'draft-tag', + envs: [], + timeout: 0, + visible_to_runner_only: false, + labels: [] + } + } as any) + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/existing', + hash: 'deployed-hash', + summary: 'deployed summary', + description: 'deployed description', + content: 'deployed content', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script', + tag: 'deployed-tag', + envs: ['DEPLOYED_ENV'], + timeout: 60, + visible_to_runner_only: true, + labels: ['deployed'] + } as any) + + 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: 'db draft content', + language: 'bun', + tag: 'draft-tag', + envs: [], + timeout: 0, + visible_to_runner_only: false, + labels: [], + deployment_message: 'ship db draft' + }) + }) + }) + + it('deploys DB flow drafts with draft metadata when no local draft exists', async () => { + vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true).mockResolvedValueOnce(true) + vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ + path: 'f/flows/existing', + summary: 'deployed summary', + description: 'deployed description', + value: { + modules: [{ id: 'deployed_step', value: { type: 'identity' } }] + }, + schema: { type: 'object', properties: { deployed: { type: 'boolean' } } }, + edited_by: 'admin', + edited_at: '2026-05-22T09:00:00Z', + archived: false, + extra_perms: {}, + 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' } } }, + edited_by: 'admin', + edited_at: '2026-05-22T10:00:00Z', + archived: false, + extra_perms: {}, + tag: 'draft-tag', + timeout: 0, + visible_to_runner_only: false, + labels: [] + } + } as any) + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + path: 'f/flows/existing', + summary: 'deployed summary', + description: 'deployed description', + value: { + modules: [{ id: 'deployed_step', value: { type: 'identity' } }] + }, + schema: { type: 'object', properties: { deployed: { type: 'boolean' } } }, + edited_by: 'admin', + edited_at: '2026-05-22T09:00:00Z', + archived: false, + extra_perms: {}, + tag: 'deployed-tag', + timeout: 60, + visible_to_runner_only: true, + labels: ['deployed'] + } as any) + + 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' } } + ]) + }) + it('applies path_prefix to local drafts before enforcing the result limit', async () => { await callGlobalTool('write_script', { path: 'f/other/outside', diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index bf6c5c5889..d56a17ea04 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -95,7 +95,12 @@ import { type WorkspaceItem, type WorkspaceItemType } from './workspaceItems' -import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' +import { + buildFlowDeployRequestBody, + buildScriptDeployRequestBody, + type FlowDeployMetadata, + type ScriptDeployMetadata +} from './deployRequests' import { userStore } from '$lib/stores' import { get } from 'svelte/store' import { bundleRawAppDraft } from './rawAppBundlerBridge' @@ -2165,6 +2170,11 @@ function buildVariableDeployRequestBody( } type DbDraftWorkspaceItemType = Extract +type DeployableDraft = { + item: WorkspaceItem + scriptMetadata?: ScriptDeployMetadata + flowMetadata?: FlowDeployMetadata +} function isDbDraftWorkspaceItemType(type: WorkspaceItemType): type is DbDraftWorkspaceItemType { return type === 'script' || type === 'flow' || type === 'app' @@ -2233,27 +2243,31 @@ async function deleteDbDraftAndDraftOnlyAnchor( } } -async function loadDbDraftItem( +async function loadDbDraftForDeploy( workspace: string, type: DbDraftWorkspaceItemType, path: string -): Promise { +): Promise { switch (type) { case 'script': { if (!(await ScriptService.existsScriptByPath({ workspace, path }))) return undefined const script = await ScriptService.getScriptByPathWithDraft({ workspace, path }) if (!script.draft && !script.draft_only) return undefined - return scriptToItem( - script.draft ? { ...script, ...script.draft, path: script.path } : script, - true, - true - ) + const draftScript = script.draft ? { ...script, ...script.draft, path: script.path } : script + return { + item: scriptToItem(draftScript, true, true), + scriptMetadata: draftScript + } } case 'flow': { if (!(await FlowService.existsFlowByPath({ workspace, path }))) return undefined const flow = await FlowService.getFlowByPathWithDraft({ workspace, path }) if (!flow.draft && !flow.draft_only) return undefined - return flowToItem(flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow, true, true) + const draftFlow = flow.draft ? { ...flow, ...flow.draft, path: flow.path } : flow + return { + item: flowToItem(draftFlow, true, true), + flowMetadata: draftFlow + } } case 'app': { if (!(await AppService.existsApp({ workspace, path }))) return undefined @@ -2261,16 +2275,26 @@ async function loadDbDraftItem( if (!app.draft && !app.draft_only) return undefined const value = appSourceToDraftValue(app.draft ?? app, app) return { - type: 'app', - path: app.path, - summary: value.summary, - value, - isDraft: true + item: { + type: 'app', + path: app.path, + summary: value.summary, + value, + isDraft: true + } } } } } +async function loadDbDraftItem( + workspace: string, + type: DbDraftWorkspaceItemType, + path: string +): Promise { + return (await loadDbDraftForDeploy(workspace, type, path))?.item +} + async function getGlobalOrDbDraft( workspace: string, type: WorkspaceItemType, @@ -2283,6 +2307,34 @@ async function getGlobalOrDbDraft( return loadDbDraftItem(workspace, type, path) } +async function getGlobalOrDbDraftForDeploy( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): Promise { + const item = getGlobalDraft(workspace, type, path, triggerKind) + if (item) { + const storagePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind) + switch (type) { + case 'script': + return { + item, + scriptMetadata: UserDraft.get('script', storagePath, { workspace }) + } + case 'flow': + return { + item, + flowMetadata: UserDraft.get('flow', storagePath, { workspace }) + } + default: + return { item } + } + } + if (!isDbDraftWorkspaceItemType(type)) return undefined + return loadDbDraftForDeploy(workspace, type, path) +} + function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void { ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: `Saving ${type} "${path}" as a draft...` @@ -3403,10 +3455,11 @@ async function deployDraft( throw new Error('trigger_kind is required when deploying a trigger.') } - const draft = await getGlobalOrDbDraft(workspace, type, path, triggerKind) - if (!draft) { + const deployableDraft = await getGlobalOrDbDraftForDeploy(workspace, type, path, triggerKind) + if (!deployableDraft) { throw new Error(`No draft found for ${type} "${path}".`) } + const { item: draft } = deployableDraft if (draft.value === undefined) { throw new Error(`Draft ${type} "${path}" has no value to deploy.`) } @@ -3422,7 +3475,13 @@ async function deployDraft( const existing = (await ScriptService.existsScriptByPath({ workspace, path })) ? await ScriptService.getScriptByPath({ workspace, path }) : undefined - const requestBody = buildScriptDeployRequestBody(path, draft, existing, deploymentMessage) + const requestBody = buildScriptDeployRequestBody( + path, + draft, + existing, + deploymentMessage, + deployableDraft.scriptMetadata + ) // Infer the arg schema from the content so it matches the code, like the editor does. try { const schema = emptySchema() @@ -3444,7 +3503,8 @@ async function deployDraft( draft.summary, flowDraft, existing, - deploymentMessage + deploymentMessage, + deployableDraft.flowMetadata ) if (existing) { await FlowService.updateFlow({ workspace, path, requestBody }) diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts index 88cbafe205..70d57db9e4 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import type { Flow, NewScript, Script } from '$lib/gen/types.gen' -import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' +import { + buildFlowDeployRequestBody, + buildScriptDeployRequestBody, + type FlowDeployMetadata, + type ScriptDeployMetadata +} from './deployRequests' import type { WorkspaceItem } from './workspaceItems' describe('global AI deploy request builders', () => { @@ -93,6 +98,93 @@ describe('global AI deploy request builders', () => { expect(requestBody.lock).toBeUndefined() }) + it('uses script draft metadata over existing deployed metadata', () => { + const existing = { + hash: 'parent-hash', + path: 'f/demo/script', + summary: 'existing summary', + description: 'existing description', + content: 'old content', + schema: { properties: { existing: { type: 'string' } } }, + is_template: true, + language: 'bun', + kind: 'script', + tag: 'old-tag', + envs: ['OLD_ENV'], + concurrent_limit: 5, + cache_ttl: 300, + cache_ignore_s3_path: false, + dedicated_worker: true, + ws_error_handler_muted: true, + priority: 10, + timeout: 120, + visible_to_runner_only: true, + on_behalf_of_email: 'existing@example.com', + assets: [{ path: 's3://old/key', kind: 's3object' }], + modules: { 'old.ts': { content: 'export const old = 1', language: 'bun' } }, + labels: ['old'] + } 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 draftMetadata: ScriptDeployMetadata = { + description: 'draft description', + schema: { properties: { draft: { type: 'boolean' } } }, + tag: 'draft-tag', + envs: [], + concurrent_limit: 0, + cache_ttl: 0, + cache_ignore_s3_path: true, + dedicated_worker: false, + ws_error_handler_muted: false, + priority: 0, + timeout: 0, + visible_to_runner_only: false, + on_behalf_of_email: 'draft@example.com', + assets: [{ path: 's3://draft/key', kind: 's3object' }], + modules: null, + labels: [] + } + + const requestBody = buildScriptDeployRequestBody( + 'f/demo/script', + draft, + existing, + 'ai deploy', + draftMetadata + ) + + expect(requestBody).toMatchObject({ + path: 'f/demo/script', + parent_hash: 'parent-hash', + summary: 'draft summary', + description: 'draft description', + content: 'new content', + schema: draftMetadata.schema, + tag: 'draft-tag', + envs: [], + concurrent_limit: 0, + cache_ttl: 0, + cache_ignore_s3_path: true, + dedicated_worker: false, + ws_error_handler_muted: false, + priority: 0, + timeout: 0, + visible_to_runner_only: false, + on_behalf_of_email: 'draft@example.com', + preserve_on_behalf_of: true, + assets: draftMetadata.assets, + modules: null, + labels: [], + deployment_message: 'ai deploy' + }) + }) + it('preserves existing flow metadata and uses draft value/schema overrides', () => { const existing = { path: 'f/demo/flow', @@ -143,6 +235,67 @@ describe('global AI deploy request builders', () => { expect(requestBody.value.groups).toEqual(draftValue.groups) }) + it('uses flow draft metadata over existing deployed metadata', () => { + const existing = { + path: 'f/demo/flow', + summary: 'existing summary', + description: 'existing description', + value: { modules: [] }, + schema: { required: ['existing'] }, + tag: 'old-tag', + ws_error_handler_muted: true, + priority: 7, + dedicated_worker: true, + timeout: 60, + visible_to_runner_only: true, + on_behalf_of_email: 'existing@example.com', + labels: ['old'] + } as unknown as Flow + const draftValue = { + value: { modules: [{ id: 'step', value: { type: 'identity' } }] }, + schema: { properties: { draft: { type: 'string' } } }, + groups: null + } + const draftMetadata: FlowDeployMetadata = { + description: 'draft description', + tag: 'draft-tag', + ws_error_handler_muted: false, + priority: 0, + dedicated_worker: false, + timeout: 0, + visible_to_runner_only: false, + on_behalf_of_email: undefined, + labels: [] + } + + const requestBody = buildFlowDeployRequestBody( + 'f/demo/flow', + 'draft summary', + draftValue as any, + existing, + 'ai deploy', + draftMetadata + ) + + expect(requestBody).toMatchObject({ + path: 'f/demo/flow', + summary: 'draft summary', + description: 'draft description', + schema: draftValue.schema, + tag: 'draft-tag', + ws_error_handler_muted: false, + priority: 0, + dedicated_worker: false, + timeout: 0, + visible_to_runner_only: false, + labels: [], + deployment_message: 'ai deploy' + }) + expect(requestBody.on_behalf_of_email).toBeUndefined() + expect(requestBody.preserve_on_behalf_of).toBeUndefined() + expect(requestBody.value.groups).toBeUndefined() + }) + it('falls back to existing flow schema when the draft has no schema', () => { const existing = { path: 'f/demo/flow', diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts index 9e779779f6..0c0f37058b 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts @@ -1,7 +1,8 @@ import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen' import type { FlowDraftValue, WorkspaceItem } from './workspaceItems' -type ScriptWithDeployMetadata = Script & Partial> +export type ScriptDeployMetadata = Partial