From 217e805cc0550873f6e5eafe96a992b73d43e31e Mon Sep 17 00:00:00 2001 From: centdix Date: Wed, 3 Jun 2026 15:00:55 +0200 Subject: [PATCH] fix: normalize ai draft data --- .../copilot/chat/global/core.test.ts | 59 +++++++++++++++++++ .../components/copilot/chat/global/core.ts | 38 +++++++++++- .../copilot/chat/global/workspaceDrafts.ts | 37 ++---------- .../components/raw_apps/rawAppDraftCodec.ts | 43 ++++++++++++++ .../components/sessions/appDraftCodec.test.ts | 47 +++++++++++++++ .../lib/components/sessions/appDraftCodec.ts | 14 +---- .../sessions/sessionRuntime.svelte.ts | 30 +++------- 7 files changed, 199 insertions(+), 69 deletions(-) create mode 100644 frontend/src/lib/components/raw_apps/rawAppDraftCodec.ts 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 7574c95bbf..39523375b4 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -1080,6 +1080,39 @@ describe('global AI tools', () => { ]) }) + it('filters list results against DB script draft summaries after write_script', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/stale-list-summary', + summary: 'Fresh DB draft summary', + language: 'bun', + content: 'export async function main() { return "fresh" }' + }) + + 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', @@ -1242,11 +1275,37 @@ describe('global AI tools', () => { path: 'f/scripts/discard-me', keepCaptures: true }) + expect(vi.mocked(ScriptService.deleteScriptByPath).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(DraftService.deleteDraft).mock.invocationCallOrder[0] + ) expect( UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE }) ).toBeUndefined() }) + it('keeps a DB draft when draft-only anchor deletion fails', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/blocked-discard', + summary: 'Temporary draft', + language: 'bun', + content: 'export async function main() { return 1 }' + }) + + vi.mocked(ScriptService.deleteScriptByPath).mockRejectedValueOnce( + new Error('deployment rules blocked deletion') + ) + + await expect( + callGlobalTool('discard_local_draft', { + type: 'script', + path: 'f/scripts/blocked-discard' + }) + ).rejects.toThrow('deployment rules blocked deletion') + + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + expect(scriptDrafts.has('f/scripts/blocked-discard')).toBe(true) + }) + it('requires trigger_kind when discarding a trigger draft', async () => { await expect( callGlobalTool('discard_local_draft', { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 6d8f315296..c1e8f48e3a 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -94,10 +94,12 @@ import { deployAppDraft, deployFlowDraft, deployScriptDraft, + type DraftFlagged, flowToItem, isDbDraftWorkspaceItemType, loadAppDraftValue, loadAppValueForRead, + loadDbDraftItem, loadFlowWithDbDraft, loadScriptWithDbDraft, loadWorkspaceDraft, @@ -656,6 +658,28 @@ function itemMatches( ) } +type ListedDbDraftType = Extract + +async function hydrateListedDbDraft( + workspace: string, + type: ListedDbDraftType, + item: DraftFlagged & { path: string }, + fallback: WorkspaceItem +): Promise { + if (!item.has_draft && !item.draft_only) return fallback + const draft = await loadDbDraftItem(workspace, type, item.path) + return draft ? { ...draft, value: undefined } : fallback +} + +async function hydrateListedDbDrafts( + workspace: string, + type: ListedDbDraftType, + rows: T[], + toItem: (row: T) => WorkspaceItem +): Promise { + return Promise.all(rows.map((row) => hydrateListedDbDraft(workspace, type, row, toItem(row)))) +} + /** * Turn a flow workspace item into the compact response we send to the model: * rawscript content is replaced with `inline_script.` placeholders. @@ -1112,7 +1136,11 @@ async function listWorkspaceItems( includeDraftOnly: true, withoutDescription: true }) - for (const script of scripts) items.push(scriptToItem(script, false)) + items.push( + ...(await hydrateListedDbDrafts(workspace, 'script', scripts, (script) => + scriptToItem(script, false) + )) + ) } if (types.includes('flow')) { @@ -1123,7 +1151,9 @@ async function listWorkspaceItems( includeDraftOnly: true, withoutDescription: true }) - for (const flow of flows) items.push(flowToItem(flow, false)) + items.push( + ...(await hydrateListedDbDrafts(workspace, 'flow', flows, (flow) => flowToItem(flow, false))) + ) } if (types.includes('schedule')) { @@ -1171,7 +1201,9 @@ async function listWorkspaceItems( perPage, includeDraftOnly: true }) - for (const app of apps) items.push(appToItem(app, false)) + items.push( + ...(await hydrateListedDbDrafts(workspace, 'app', apps, (app) => appToItem(app, false))) + ) } return items diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts b/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts index 3662a2076a..4ca27f4ede 100644 --- a/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts +++ b/frontend/src/lib/components/copilot/chat/global/workspaceDrafts.ts @@ -1,5 +1,6 @@ import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' +import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { inferArgs } from '$lib/infer' import { emptySchema } from '$lib/utils' @@ -148,30 +149,30 @@ export async function deleteDbDraftAndDraftOnlyAnchor( const existing = (await ScriptService.existsScriptByPath({ workspace, path })) ? await ScriptService.getScriptByPathWithDraft({ workspace, path }) : undefined - await deleteDbDraft(workspace, typ, path) if (existing?.draft_only) { await ScriptService.deleteScriptByPath({ workspace, path, keepCaptures: true }) } + await deleteDbDraft(workspace, typ, path) break } case 'flow': { const existing = (await FlowService.existsFlowByPath({ workspace, path })) ? await FlowService.getFlowByPathWithDraft({ workspace, path }) : undefined - await deleteDbDraft(workspace, typ, path) if (existing?.draft_only) { await FlowService.deleteFlowByPath({ workspace, path, keepCaptures: true }) } + await deleteDbDraft(workspace, typ, path) break } case 'app': { const existing = (await AppService.existsApp({ workspace, path })) ? await AppService.getAppByPathWithDraft({ workspace, path }) : undefined - await deleteDbDraft(workspace, typ, path) if (existing?.draft_only) { await AppService.deleteApp({ workspace, path }) } + await deleteDbDraft(workspace, typ, path) break } } @@ -528,36 +529,8 @@ async function saveAppDraftToDb( await createDbDraft(workspace, 'app', path, appDraftToDbValue(path, value)) } -function normalizeRawAppData(value: Record): AppDraftValue['data'] { - if (value.data?.creation) { - return { - tables: value.data.tables ?? [], - datatable: value.data.creation.datatable, - schema: value.data.creation.schema - } - } - if (value.data) { - return value.data - } - if (value.datatables) { - return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables } - } - if (value.dataTableRefs) { - return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs } - } - return { ...DEFAULT_RAW_APP_DATA } -} - function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { - const value = (app.value ?? {}) as Record - return { - summary: app.summary ?? '', - files: { ...(value.files ?? {}) }, - runnables: { ...(value.runnables ?? {}) }, - data: normalizeRawAppData(value), - policy: app.policy ?? fallback?.policy, - custom_path: app.custom_path ?? fallback?.custom_path - } + return appSourceToRawAppDraft(app, fallback) } function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta { diff --git a/frontend/src/lib/components/raw_apps/rawAppDraftCodec.ts b/frontend/src/lib/components/raw_apps/rawAppDraftCodec.ts new file mode 100644 index 0000000000..36239f5f19 --- /dev/null +++ b/frontend/src/lib/components/raw_apps/rawAppDraftCodec.ts @@ -0,0 +1,43 @@ +import { DEFAULT_DATA, type RawAppData } from './dataTableRefUtils' + +// The raw-app draft shape stored under `UserDraft`. +export type RawAppDraft = { + files: Record + runnables: Record + data: RawAppData + summary: string + policy?: any + custom_path?: string +} + +function normalizeRawAppData(value: Record): RawAppData { + if (value.data) { + if (value.data.creation) { + return { + tables: value.data.tables ?? [], + datatable: value.data.creation.datatable, + schema: value.data.creation.schema + } + } + return value.data + } + if (value.datatables) { + return { ...DEFAULT_DATA, tables: value.datatables } + } + if (value.dataTableRefs) { + return { ...DEFAULT_DATA, tables: value.dataTableRefs } + } + return { ...DEFAULT_DATA } +} + +export function appSourceToRawAppDraft(app: any, fallback?: any): RawAppDraft { + const value = (app.value ?? {}) as Record + return { + summary: app.summary ?? '', + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: normalizeRawAppData(value), + policy: app.policy ?? fallback?.policy, + custom_path: app.custom_path ?? fallback?.custom_path + } +} diff --git a/frontend/src/lib/components/sessions/appDraftCodec.test.ts b/frontend/src/lib/components/sessions/appDraftCodec.test.ts index f83e1e590b..cef5fc660f 100644 --- a/frontend/src/lib/components/sessions/appDraftCodec.test.ts +++ b/frontend/src/lib/components/sessions/appDraftCodec.test.ts @@ -5,6 +5,7 @@ import { type RuntimeRawApp, type RawAppDraft } from './appDraftCodec' +import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec' function runtime(over: Partial = {}): RuntimeRawApp { return { @@ -59,3 +60,49 @@ describe('appDraftCodec — custom_path round-trip', () => { expect(applyDraftToRuntimeRawApp(base, dv).custom_path).toBe('existing') }) }) + +describe('appSourceToRawAppDraft', () => { + it('unwraps DB draft wrappers instead of treating the wrapper as app files', () => { + const draft = appSourceToRawAppDraft( + { + summary: 'draft app', + value: { + files: { '/src/App.tsx': 'export default function App() { return "draft" }' }, + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' } + }, + policy: { execution_mode: 'anonymous' }, + custom_path: 'draft-url' + }, + { + summary: 'deployed app', + value: { + files: { '/src/App.tsx': 'deployed' }, + runnables: {}, + data: { tables: [] } + }, + policy: { execution_mode: 'publisher' }, + custom_path: 'deployed-url' + } + ) + + expect(draft).toEqual({ + summary: 'draft app', + files: { '/src/App.tsx': 'export default function App() { return "draft" }' }, + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' }, + policy: { execution_mode: 'anonymous' }, + custom_path: 'draft-url' + }) + }) +}) diff --git a/frontend/src/lib/components/sessions/appDraftCodec.ts b/frontend/src/lib/components/sessions/appDraftCodec.ts index 47493da3f2..526ac2ebaf 100644 --- a/frontend/src/lib/components/sessions/appDraftCodec.ts +++ b/frontend/src/lib/components/sessions/appDraftCodec.ts @@ -1,17 +1,7 @@ import type { RawAppData } from '$lib/components/raw_apps/dataTableRefUtils' +import type { RawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec' -// The raw-app draft shape stored under `UserDraft` — matches the -// regular `/apps_raw/edit` route's UserDraft handle exactly. The chat's -// `userDraftAdapter.saveGlobalAppDraft` writes through the same shape, so -// session previews and the chat round-trip identically. -export type RawAppDraft = { - files: Record - runnables: Record - data: RawAppData - summary: string - policy?: any - custom_path?: string -} +export type { RawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec' // The shape `runtime.rawApp.val` actually holds (see SessionRuntime in // sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 15912a28e9..f345f9e208 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -14,6 +14,7 @@ import { } from '$lib/gen' import type { HiddenRunnable } from '$lib/components/apps/types' import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' +import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec' import { workspaceStore } from '$lib/stores' import { emptySchema, type StateStore } from '$lib/utils' import { @@ -460,30 +461,15 @@ function createRuntime(session: Session): SessionRuntime { draft: result.draft, custom_path: result.custom_path } - const sourceValue: any = result.draft ?? result.value - let data: RawAppData = { ...DEFAULT_DATA } - if (sourceValue?.data) { - const d = sourceValue.data - if (d.creation) { - data = { - tables: d.tables ?? [], - datatable: d.creation.datatable, - schema: d.creation.schema - } - } else { - data = d - } - } else if (sourceValue?.datatables) { - data = { ...DEFAULT_DATA, tables: sourceValue.datatables } - } + const sourceDraft = appSourceToRawAppDraft(result.draft ?? result, result) const runtimeValue = { - files: (sourceValue?.files ?? {}) as Record, - runnables: (sourceValue?.runnables ?? {}) as Record, - data, - policy: result.policy, - summary: result.summary ?? '', + files: sourceDraft.files, + runnables: sourceDraft.runnables, + data: sourceDraft.data, + policy: sourceDraft.policy, + summary: sourceDraft.summary, path: result.path, - custom_path: result.custom_path + custom_path: sourceDraft.custom_path } UserDraft.save('raw_app', path, runtimeRawAppToDraft(runtimeValue), { workspace }) rawApp.val = runtimeValue