fix: deploy ai chat drafts with metadata

This commit is contained in:
centdix
2026-06-02 18:38:02 +02:00
parent 2a40102bc0
commit 47f1d41000
4 changed files with 493 additions and 67 deletions
@@ -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',
@@ -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<WorkspaceItemType, 'script' | 'flow' | 'app'>
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<WorkspaceItem | undefined> {
): Promise<DeployableDraft | undefined> {
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<WorkspaceItem | undefined> {
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<DeployableDraft | undefined> {
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<NewScript>('script', storagePath, { workspace })
}
case 'flow':
return {
item,
flowMetadata: UserDraft.get<Flow>('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 })
@@ -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<NewScript>
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',
@@ -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<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
export type ScriptDeployMetadata = Partial<Script> & Partial<NewScript>
export type FlowDeployMetadata = Partial<Flow>
export type FlowDeployRequestBody = OpenFlowWPath & {
deployment_message?: string
@@ -11,56 +12,110 @@ function preserveOnBehalfOf(email: string | undefined): true | undefined {
return email ? true : undefined
}
function deployMetadataField<T extends object, K extends keyof T>(
draftMetadata: T | undefined,
existing: T | undefined,
key: K
): T[K] | undefined {
if (draftMetadata && Object.prototype.hasOwnProperty.call(draftMetadata, key)) {
return draftMetadata[key]
}
return existing?.[key]
}
export function buildScriptDeployRequestBody(
path: string,
draft: WorkspaceItem,
existing: Script | undefined,
deploymentMessage: string | undefined
deploymentMessage: string | undefined,
draftMetadata?: ScriptDeployMetadata
): 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
const existingWithMetadata = existing as ScriptDeployMetadata | undefined
const onBehalfOfEmail = deployMetadataField(
draftMetadata,
existingWithMetadata,
'on_behalf_of_email'
)
return {
path,
summary: draft.summary ?? existing?.summary ?? '',
description: existing?.description ?? '',
summary:
draft.summary ?? deployMetadataField(draftMetadata, existingWithMetadata, 'summary') ?? '',
description: deployMetadataField(draftMetadata, existingWithMetadata, 'description') ?? '',
content: draft.value,
parent_hash: existing?.hash,
schema: existing?.schema,
is_template: existing?.is_template,
schema: deployMetadataField(draftMetadata, existingWithMetadata, 'schema'),
is_template: deployMetadataField(draftMetadata, existingWithMetadata, '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,
kind: deployMetadataField(draftMetadata, existingWithMetadata, 'kind'),
tag: deployMetadataField(draftMetadata, existingWithMetadata, 'tag'),
envs: deployMetadataField(draftMetadata, existingWithMetadata, 'envs'),
concurrent_limit: deployMetadataField(draftMetadata, existingWithMetadata, 'concurrent_limit'),
concurrency_time_window_s: deployMetadataField(
draftMetadata,
existingWithMetadata,
'concurrency_time_window_s'
),
debounce_key: deployMetadataField(draftMetadata, existingWithMetadata, 'debounce_key'),
debounce_delay_s: deployMetadataField(draftMetadata, existingWithMetadata, 'debounce_delay_s'),
debounce_args_to_accumulate: deployMetadataField(
draftMetadata,
existingWithMetadata,
'debounce_args_to_accumulate'
),
max_total_debouncing_time: deployMetadataField(
draftMetadata,
existingWithMetadata,
'max_total_debouncing_time'
),
max_total_debounces_amount: deployMetadataField(
draftMetadata,
existingWithMetadata,
'max_total_debounces_amount'
),
cache_ttl: deployMetadataField(draftMetadata, existingWithMetadata, 'cache_ttl'),
cache_ignore_s3_path: deployMetadataField(
draftMetadata,
existingWithMetadata,
'cache_ignore_s3_path'
),
dedicated_worker: deployMetadataField(draftMetadata, existingWithMetadata, 'dedicated_worker'),
ws_error_handler_muted: deployMetadataField(
draftMetadata,
existingWithMetadata,
'ws_error_handler_muted'
),
priority: deployMetadataField(draftMetadata, existingWithMetadata, 'priority'),
restart_unless_cancelled: deployMetadataField(
draftMetadata,
existingWithMetadata,
'restart_unless_cancelled'
),
timeout: deployMetadataField(draftMetadata, existingWithMetadata, 'timeout'),
delete_after_secs: deployMetadataField(
draftMetadata,
existingWithMetadata,
'delete_after_secs'
),
deployment_message: deploymentMessage,
concurrency_key: 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
concurrency_key: deployMetadataField(draftMetadata, existingWithMetadata, 'concurrency_key'),
visible_to_runner_only: deployMetadataField(
draftMetadata,
existingWithMetadata,
'visible_to_runner_only'
),
auto_kind: deployMetadataField(draftMetadata, existingWithMetadata, 'auto_kind'),
codebase: deployMetadataField(draftMetadata, existingWithMetadata, 'codebase'),
has_preprocessor: deployMetadataField(draftMetadata, existingWithMetadata, 'has_preprocessor'),
on_behalf_of_email: onBehalfOfEmail,
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail),
assets: deployMetadataField(draftMetadata, existingWithMetadata, 'assets'),
modules: deployMetadataField(draftMetadata, existingWithMetadata, 'modules'),
labels: deployMetadataField(draftMetadata, existingWithMetadata, 'labels')
}
}
@@ -79,23 +134,26 @@ export function buildFlowDeployRequestBody(
draftSummary: string | undefined,
flowDraft: FlowDraftValue,
existing: Flow | undefined,
deploymentMessage: string | undefined
deploymentMessage: string | undefined,
draftMetadata?: FlowDeployMetadata
): FlowDeployRequestBody {
const onBehalfOfEmail = deployMetadataField(draftMetadata, existing, 'on_behalf_of_email')
return {
path,
summary: draftSummary ?? existing?.summary ?? '',
description: existing?.description ?? '',
summary: draftSummary ?? deployMetadataField(draftMetadata, existing, 'summary') ?? '',
description: deployMetadataField(draftMetadata, 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,
schema: flowDraft.schema ?? deployMetadataField(draftMetadata, existing, 'schema') ?? {},
tag: deployMetadataField(draftMetadata, existing, 'tag'),
ws_error_handler_muted: deployMetadataField(draftMetadata, existing, 'ws_error_handler_muted'),
priority: deployMetadataField(draftMetadata, existing, 'priority'),
dedicated_worker: deployMetadataField(draftMetadata, existing, 'dedicated_worker'),
timeout: deployMetadataField(draftMetadata, existing, 'timeout'),
visible_to_runner_only: deployMetadataField(draftMetadata, existing, 'visible_to_runner_only'),
on_behalf_of_email: onBehalfOfEmail,
preserve_on_behalf_of: preserveOnBehalfOf(onBehalfOfEmail),
labels: deployMetadataField(draftMetadata, existing, 'labels'),
deployment_message: deploymentMessage
}
}