fix: preserve metadata, renames, and discard order for ai chat drafts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-03 20:19:31 +02:00
parent fb9714c649
commit 855bfbf2b2
6 changed files with 695 additions and 312 deletions
@@ -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)
@@ -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<WorkspaceItem> {
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,
@@ -178,6 +178,13 @@ export type DbDraftRead<V> = {
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<never> = {
@@ -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
}
}
@@ -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<NewScript>
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)
})
})
@@ -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<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
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
}
}
@@ -76,6 +76,12 @@ export type LoadedDraft<T extends DraftType = DraftType> = {
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<T extends DraftType>(
@@ -174,7 +180,8 @@ export async function loadDraft<T extends DraftType>(
itemExists: db.itemExists,
deployedExists: db.deployedExists,
draftOnly: db.draftOnly,
hasDbDraft: db.hasDbDraft
hasDbDraft: db.hasDbDraft,
draftPath: db.draftPath
}
}