fix: normalize ai draft data

This commit is contained in:
centdix
2026-06-03 15:00:55 +02:00
parent 59806bc678
commit 217e805cc0
7 changed files with 199 additions and 69 deletions
@@ -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', {
@@ -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<WorkspaceItemType, 'script' | 'flow' | 'app'>
async function hydrateListedDbDraft(
workspace: string,
type: ListedDbDraftType,
item: DraftFlagged & { path: string },
fallback: WorkspaceItem
): Promise<WorkspaceItem> {
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<T extends DraftFlagged & { path: string }>(
workspace: string,
type: ListedDbDraftType,
rows: T[],
toItem: (row: T) => WorkspaceItem
): Promise<WorkspaceItem[]> {
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.<moduleId>` 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
@@ -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<string, any>): 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<string, any>
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 {
@@ -0,0 +1,43 @@
import { DEFAULT_DATA, type RawAppData } from './dataTableRefUtils'
// The raw-app draft shape stored under `UserDraft<RawAppDraft>`.
export type RawAppDraft = {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
summary: string
policy?: any
custom_path?: string
}
function normalizeRawAppData(value: Record<string, any>): 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<string, any>
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
}
}
@@ -5,6 +5,7 @@ import {
type RuntimeRawApp,
type RawAppDraft
} from './appDraftCodec'
import { appSourceToRawAppDraft } from '$lib/components/raw_apps/rawAppDraftCodec'
function runtime(over: Partial<RuntimeRawApp> = {}): 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'
})
})
})
@@ -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<RawAppDraft>` — 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<string, string>
runnables: Record<string, any>
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
@@ -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<string, string>,
runnables: (sourceValue?.runnables ?? {}) as Record<string, any>,
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