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 044c696e08..14d4e76b79 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -40,25 +40,86 @@ vi.mock('$lib/gen', async () => { }) } + // In-memory stand-in for the backend `draft`/item tables so script/flow/app + // "write then read" round-trips work the way the real DB does: write_script / + // write_flow / write_app_* now persist via DraftService.createDraft (+ a + // draft_only anchor), and reads resolve through get*ByPathWithDraft. The store + // mirrors that: createDraft / createScript / createFlow / createAppRaw register + // a readable row, and the *WithDraft reads return it (404 when absent). + // `row`, when present, is the full object a get*ByPathWithDraft read returns + // (used to model a *deployed* item that also carries a DB draft). When absent, + // the read synthesizes a draft_only row from `value`. + type StoredDraft = { typ: 'script' | 'flow' | 'app'; value: any; anchored: boolean; row?: any } + const dbDraftStore = new Map() + const dbKey = (typ: string, path: string) => `${typ}:${path}` + + function notFound404() { + return Object.assign(new Error('not found'), { status: 404 }) + } + return { ...actual, + // Lets the test reset the in-memory DB-draft store between cases. + __resetDbDraftStore: () => dbDraftStore.clear(), + // Seeds a full get*ByPathWithDraft row (e.g. a deployed item + DB draft) so + // every read in a test sees the same fixture without leaking mock impls. + __seedDbRow: (typ: 'script' | 'flow' | 'app', path: string, row: any) => + dbDraftStore.set(dbKey(typ, path), { typ, value: row?.draft ?? row, anchored: true, row }), + // A 404 from the generated client surfaces as a thrown ApiError-shaped + // object with `.status === 404`. `readScriptDbDraft`/`readFlowDbDraft`/ + // `readAppDbDraft` catch *only* this case and treat it as "no item exists". ScriptService: wrapService(actual.ScriptService, { existsScriptByPath: vi.fn(async () => false), - createScript: vi.fn(async () => 'created'), - deleteScriptByPath: vi.fn(async () => 'deleted'), + createScript: vi.fn(async ({ path, requestBody }: any) => { + const p = path ?? requestBody?.path + const existing = dbDraftStore.get(dbKey('script', p)) + dbDraftStore.set(dbKey('script', p), { + typ: 'script', + value: existing?.value, + anchored: true + }) + return 'created' + }), getScriptByPath: vi.fn(async () => { throw new Error('getScriptByPath mock not configured') }), getScriptByHash: vi.fn(async () => { throw new Error('getScriptByHash mock not configured') }), - getScriptByPathWithDraft: vi.fn(async () => { - throw new Error('getScriptByPathWithDraft mock not configured') + getScriptByPathWithDraft: vi.fn(async ({ path }: any) => { + const stored = dbDraftStore.get(dbKey('script', path)) + if (!stored) throw notFound404() + if (stored.row) return stored.row + return { + path, + hash: undefined, + draft_only: true, + draft: stored.value ?? null, + ...(stored.value ?? {}) + } }), queryHubScripts: vi.fn(async () => []), getHubScriptContentByPath: vi.fn(async () => ''), + deleteScriptByPath: vi.fn(async () => 'deleted'), listScripts: vi.fn(async () => []) }), + DraftService: wrapService(actual.DraftService, { + createDraft: vi.fn(async ({ requestBody }: any) => { + const { path, typ, value } = requestBody + if (typ === 'script' || typ === 'flow' || typ === 'app') { + const existing = dbDraftStore.get(dbKey(typ, path)) + dbDraftStore.set(dbKey(typ, path), { + typ, + value, + anchored: existing?.anchored ?? false + }) + } + return 'created' + }), + // Deploy must NOT call this — the backend deletes the DB draft row on a + // non-draft create/update. Mocked so deploy tests can assert it stays unused. + deleteDraft: vi.fn(async () => undefined) + }), JobService: wrapService(actual.JobService, { runScriptPreview: vi.fn(async () => 'job-script-preview'), runFlowPreview: vi.fn(async () => 'job-flow-preview'), @@ -72,16 +133,33 @@ vi.mock('$lib/gen', async () => { }), FlowService: wrapService(actual.FlowService, { existsFlowByPath: vi.fn(async () => false), - createFlow: vi.fn(async () => 'created'), - deleteFlowByPath: vi.fn(async () => 'deleted'), + createFlow: vi.fn(async ({ path, requestBody }: any) => { + const p = path ?? requestBody?.path + const existing = dbDraftStore.get(dbKey('flow', p)) + dbDraftStore.set(dbKey('flow', p), { + typ: 'flow', + value: existing?.value, + anchored: true + }) + return 'created' + }), updateFlow: vi.fn(async () => 'updated'), getFlowByPath: vi.fn(async () => { throw new Error('getFlowByPath mock not configured') }), - getFlowByPathWithDraft: vi.fn(async () => { - throw new Error('getFlowByPathWithDraft mock not configured') + getFlowByPathWithDraft: vi.fn(async ({ path }: any) => { + const stored = dbDraftStore.get(dbKey('flow', path)) + if (!stored) throw notFound404() + if (stored.row) return stored.row + return { + path, + draft_only: true, + draft: stored.value ?? null, + ...(stored.value ?? {}) + } }), getFlowLatestVersion: vi.fn(async () => ({ id: 1 })), + deleteFlowByPath: vi.fn(async () => 'deleted'), listFlows: vi.fn(async () => []) }), ScheduleService: wrapService(actual.ScheduleService, { @@ -98,12 +176,35 @@ vi.mock('$lib/gen', async () => { }), AppService: wrapService(actual.AppService, { existsApp: vi.fn(async () => false), - createAppRaw: vi.fn(async () => 'created'), - deleteApp: vi.fn(async () => 'deleted'), - updateAppRaw: vi.fn(async () => 'updated'), - getAppByPathWithDraft: vi.fn(async () => { - throw new Error('getAppByPathWithDraft mock not configured') + createAppRaw: vi.fn(async ({ path, formData }: any) => { + // Mirrors the backend: a raw-app create registers a readable row (the + // `draft_only` anchor when `formData.app.draft_only`). Preserve any + // draft already upserted at this path so a later read resolves it. + const p = path ?? formData?.app?.path + const existing = dbDraftStore.get(dbKey('app', p)) + dbDraftStore.set(dbKey('app', p), { + typ: 'app', + value: existing?.value, + anchored: true + }) + return 'created' }), + updateAppRaw: vi.fn(async () => 'updated'), + getAppByPathWithDraft: vi.fn(async ({ path }: any) => { + const stored = dbDraftStore.get(dbKey('app', path)) + if (!stored) throw notFound404() + if (stored.row) return stored.row + // Synthesize a draft_only row from the upserted draft value. The draft + // `value` is `{ value: rawApp, path, summary, policy, custom_path }`, + // exactly what `appSourceToDraftValue` reads back via `app.draft`. + return { + path, + draft_only: true, + draft: stored.value ?? null, + ...(stored.value ?? {}) + } + }), + deleteApp: vi.fn(async () => 'deleted'), listApps: vi.fn(async () => []) }), ResourceService: wrapService(actual.ResourceService, { @@ -119,10 +220,6 @@ vi.mock('$lib/gen', async () => { }), createVariable: vi.fn(async () => 'created'), updateVariable: vi.fn(async () => 'updated') - }), - DraftService: wrapService(actual.DraftService, { - createDraft: vi.fn(async () => 'draft created'), - deleteDraft: vi.fn(async () => 'draft deleted') }) } }) @@ -134,6 +231,21 @@ vi.mock('./rawAppBundlerBridge', () => ({ })) })) +// The real `inferArgs` loads a wasm parser that isn't available under vitest; stub +// it to populate the passed schema in place (mirroring the real mutation) so the +// deploy schema-inference step is exercised deterministically. +vi.mock('$lib/infer', () => ({ + inferArgs: vi.fn(async (_language: unknown, code: string, schema: any) => { + const match = /main\(([^)]*)\)/.exec(code) + const name = match?.[1]?.split(':')[0]?.trim() + if (name) { + schema.properties = { [name]: { type: 'number' } } + schema.required = [name] + } + return { auto_kind: null, has_preprocessor: null } + }) +})) + import { globalTools, globalToolsFor, @@ -157,8 +269,16 @@ import { ScriptService, VariableService } from '$lib/gen' +import * as genModule from '$lib/gen' import type { Tool, ToolCallbacks } from '../shared' +// Reset/seed helpers for the in-memory DB-draft store backing the script/flow +// $lib/gen mocks. +const { __resetDbDraftStore, __seedDbRow } = genModule as unknown as { + __resetDbDraftStore: () => void + __seedDbRow: (typ: 'script' | 'flow' | 'app', path: string, row: any) => void +} + const WORKSPACE = 'global-core-test' const toolCallbacks: ToolCallbacks = { @@ -166,141 +286,6 @@ const toolCallbacks: ToolCallbacks = { removeToolStatus: vi.fn() } -const scriptDrafts = new Map() -const flowDrafts = new Map() -const appDrafts = new Map() - -function serviceMock(fn: unknown): any { - return vi.mocked(fn as any) as any -} - -function resetDbDraftMocks(): void { - scriptDrafts.clear() - flowDrafts.clear() - appDrafts.clear() - - serviceMock(DraftService.createDraft).mockImplementation(async ({ requestBody }: any) => { - const { path, typ, value } = requestBody - if (typ === 'script') scriptDrafts.set(path, structuredClone(value)) - if (typ === 'flow') flowDrafts.set(path, structuredClone(value)) - if (typ === 'app') appDrafts.set(path, structuredClone(value)) - return 'draft created' - }) - serviceMock(DraftService.deleteDraft).mockImplementation(async ({ kind, path }: any) => { - if (kind === 'script') scriptDrafts.delete(path) - if (kind === 'flow') flowDrafts.delete(path) - if (kind === 'app') appDrafts.delete(path) - return 'draft deleted' - }) - - serviceMock(ScriptService.existsScriptByPath).mockImplementation(async ({ path }: any) => - scriptDrafts.has(path) - ) - serviceMock(ScriptService.getScriptByPathWithDraft).mockImplementation(async ({ path }: any) => { - const draft = scriptDrafts.get(path) - if (!draft) throw new Error('getScriptByPathWithDraft mock not configured') - return { - ...draft, - hash: 'draft-anchor-hash', - draft_only: true, - draft: structuredClone(draft) - } as any - }) - serviceMock(ScriptService.getScriptByPath).mockImplementation(async ({ path }: any) => { - const draft = scriptDrafts.get(path) - if (!draft) throw new Error('getScriptByPath mock not configured') - return { ...structuredClone(draft), hash: 'draft-anchor-hash', draft_only: true } as any - }) - serviceMock(ScriptService.listScripts).mockImplementation(async ({ pathStart, perPage }: any) => - Array.from(scriptDrafts.values()) - .filter((draft) => !pathStart || draft.path.startsWith(pathStart)) - .slice(0, perPage) - .map((draft) => ({ - path: draft.path, - summary: draft.summary, - language: draft.language, - has_draft: true, - draft_only: true - })) - ) - serviceMock(ScriptService.createScript).mockImplementation(async ({ requestBody }: any) => { - if (!requestBody.draft_only) scriptDrafts.delete(requestBody.path) - return 'created' - }) - - serviceMock(FlowService.existsFlowByPath).mockImplementation(async ({ path }: any) => - flowDrafts.has(path) - ) - serviceMock(FlowService.getFlowByPathWithDraft).mockImplementation(async ({ path }: any) => { - const draft = flowDrafts.get(path) - if (!draft) throw new Error('getFlowByPathWithDraft mock not configured') - return { - ...draft, - draft_only: true, - draft: structuredClone(draft) - } as any - }) - serviceMock(FlowService.getFlowByPath).mockImplementation(async ({ path }: any) => { - const draft = flowDrafts.get(path) - if (!draft) throw new Error('getFlowByPath mock not configured') - return { ...structuredClone(draft), draft_only: true } as any - }) - serviceMock(FlowService.listFlows).mockImplementation(async ({ pathStart, perPage }: any) => - Array.from(flowDrafts.values()) - .filter((draft) => !pathStart || draft.path.startsWith(pathStart)) - .slice(0, perPage) - .map((draft) => ({ - path: draft.path, - summary: draft.summary, - has_draft: true, - draft_only: true - })) - ) - serviceMock(FlowService.createFlow).mockImplementation(async ({ requestBody }: any) => { - if (!requestBody.draft_only) flowDrafts.delete(requestBody.path) - return 'created' - }) - serviceMock(FlowService.updateFlow).mockImplementation(async ({ path }: any) => { - flowDrafts.delete(path) - return 'updated' - }) - - serviceMock(AppService.existsApp).mockImplementation(async ({ path }: any) => appDrafts.has(path)) - serviceMock(AppService.getAppByPathWithDraft).mockImplementation(async ({ path }: any) => { - const draft = appDrafts.get(path) - if (!draft) throw new Error('getAppByPathWithDraft mock not configured') - const value = draft.value ?? {} - return { - path, - summary: draft.summary ?? '', - value, - policy: draft.policy, - custom_path: draft.custom_path, - draft_only: true, - draft: structuredClone(draft) - } as any - }) - serviceMock(AppService.listApps).mockImplementation(async ({ pathStart, perPage }: any) => - Array.from(appDrafts.values()) - .filter((draft) => !pathStart || draft.path.startsWith(pathStart)) - .slice(0, perPage) - .map((draft) => ({ - path: draft.path, - summary: draft.summary, - has_draft: true, - draft_only: true - })) - ) - serviceMock(AppService.createAppRaw).mockImplementation(async ({ formData }: any) => { - if (!formData.app.draft_only) appDrafts.delete(formData.app.path) - return 'created' - }) - serviceMock(AppService.updateAppRaw).mockImplementation(async ({ path }: any) => { - appDrafts.delete(path) - return 'updated' - }) -} - function getGlobalTool(name: string): Tool<{}> { const tool = globalTools.find((candidate) => candidate.def.function.name === name) if (!tool) { @@ -349,8 +334,8 @@ describe('global AI tools', () => { __resetUserDraftForTesting() localStorage.clear() clearGlobalDrafts(WORKSPACE) + __resetDbDraftStore() vi.clearAllMocks() - resetDbDraftMocks() }) it('defaults the datatable instruction subject to the TypeScript SQL SDK', async () => { @@ -573,7 +558,7 @@ describe('global AI tools', () => { expect(VariableService.updateVariable).not.toHaveBeenCalled() }) - it('writes script drafts into DB drafts without a non-live UserDraft mirror', async () => { + it('writes a brand-new script as a DB draft with a draft_only anchor', async () => { const content = 'export async function main() {\n\treturn "hello"\n}' const raw = await callGlobalTool('write_script', { @@ -583,577 +568,185 @@ describe('global AI tools', () => { content }) - expect(UserDraft.get('script', 'f/scripts/hello', { workspace: WORKSPACE })).toBeUndefined() - expect(JSON.parse(raw).item).toMatchObject({ + // Brand-new item (getScriptByPathWithDraft 404s) -> a draft_only anchor is + // created so the draft row is readable, then the draft row is upserted. + expect(ScriptService.createScript).toHaveBeenCalledTimes(1) + const anchor = vi.mocked(ScriptService.createScript).mock.calls[0][0] + expect(anchor.workspace).toBe(WORKSPACE) + expect(anchor.requestBody.draft_only).toBe(true) + expect(anchor.requestBody.path).toBe('f/scripts/hello') + + expect(DraftService.createDraft).toHaveBeenCalledTimes(1) + const draftCall = vi.mocked(DraftService.createDraft).mock.calls[0][0] + expect(draftCall.workspace).toBe(WORKSPACE) + expect(draftCall.requestBody).toMatchObject({ + path: 'f/scripts/hello', + typ: 'script' + }) + expect(draftCall.requestBody.value).toMatchObject({ path: 'f/scripts/hello', summary: 'Hello script', language: 'bun', - value: content - }) - expect(ScriptService.createScript).toHaveBeenCalledWith({ - workspace: WORKSPACE, - requestBody: expect.objectContaining({ - path: 'f/scripts/hello', - summary: 'Hello script', - content, - language: 'bun', - draft_only: true - }) - }) - expect(DraftService.createDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - requestBody: { - path: 'f/scripts/hello', - typ: 'script', - value: expect.objectContaining({ - path: 'f/scripts/hello', - summary: 'Hello script', - content, - language: 'bun' - }) - } - }) - }) - - it('updates draft-only script DB drafts without deleting the existing anchor', async () => { - const content = 'export async function main() {\n\treturn "updated"\n}' - vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ - path: 'f/scripts/new-draft', - hash: 'draft-hash', - summary: 'Existing draft', - description: '', - content: 'export async function main() {\n\treturn "old"\n}', - schema: {}, - is_template: false, - language: 'bun', - kind: 'script', - draft_only: true - } as any) - - await callGlobalTool('write_script', { - path: 'f/scripts/new-draft', - summary: 'Updated draft', - language: 'bun', content }) - expect(ScriptService.deleteScriptByPath).not.toHaveBeenCalled() - expect(ScriptService.createScript).not.toHaveBeenCalled() - expect(DraftService.createDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - requestBody: { - path: 'f/scripts/new-draft', - typ: 'script', - value: expect.objectContaining({ - path: 'f/scripts/new-draft', - summary: 'Updated draft', - content, - language: 'bun' - }) - } - }) - }) + // No live editor open -> nothing mirrored to localStorage. + expect(UserDraft.get('script', 'f/scripts/hello', { workspace: WORKSPACE })).toBeUndefined() - it('updates draft-only flow DB drafts without deleting the existing anchor', async () => { - vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) - vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ - path: 'f/flows/new-draft', - summary: 'Existing draft', - value: { modules: [{ id: 'old', value: { type: 'identity' } }] }, - schema: {}, - edited_by: 'admin', - edited_at: '2026-05-22T09:00:00Z', - archived: false, - extra_perms: {}, - draft_only: true - } as any) - - await callGlobalTool('write_flow', { - path: 'f/flows/new-draft', - summary: 'Updated draft', - modules: JSON.stringify([{ id: 'updated', value: { type: 'identity' } }]) - }) - - expect(FlowService.deleteFlowByPath).not.toHaveBeenCalled() - expect(FlowService.createFlow).not.toHaveBeenCalled() - expect(DraftService.createDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - requestBody: { - path: 'f/flows/new-draft', - typ: 'flow', - value: expect.objectContaining({ - path: 'f/flows/new-draft', - summary: 'Updated draft', - value: { - modules: [{ id: 'updated', value: { type: 'identity' } }] - } - }) - } - }) - }) - - it('updates draft-only raw app DB drafts without deleting or rebundling the existing anchor', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ - path: 'f/apps/draft-only', - summary: 'Existing app draft', - draft_only: true, - value: { - files: { '/src/App.tsx': 'export default function App() { return null }' }, - runnables: {}, - data: { tables: [] } - }, - policy: { execution_mode: 'anonymous' } - } as any) - - await callGlobalTool('write_app_file', { - path: 'f/apps/draft-only', - file_path: '/src/App.tsx', - content: 'export default function App() { return "updated" }' - }) - - expect(AppService.deleteApp).not.toHaveBeenCalled() - expect(AppService.createAppRaw).not.toHaveBeenCalled() - expect(bundleRawAppDraft).not.toHaveBeenCalled() - expect(DraftService.createDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - requestBody: { - path: 'f/apps/draft-only', - typ: 'app', - value: expect.objectContaining({ - path: 'f/apps/draft-only', - summary: 'Existing app draft', - value: expect.objectContaining({ - files: { '/src/App.tsx': 'export default function App() { return "updated" }' }, - runnables: {}, - data: { tables: [] } - }), - policy: { execution_mode: 'anonymous' } - }) - } - }) - }) - - it('marks backend script, flow, and app drafts as drafts in list output', async () => { - vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([ - { - path: 'f/scripts/with-db-draft', - summary: 'Script with DB draft', - language: 'bun', - has_draft: true - } - ] as any) - vi.mocked(FlowService.listFlows).mockResolvedValueOnce([ - { - path: 'f/flows/draft-only', - summary: 'Draft-only flow', - value: { modules: [] }, - schema: {}, - draft_only: true - } - ] as any) - vi.mocked(AppService.listApps).mockResolvedValueOnce([ - { - path: 'f/apps/with-db-draft', - summary: 'App with DB draft', - has_draft: true - } - ] as any) - - const raw = await callGlobalTool('list_workspace_items', { - types: ['script', 'flow', 'app'] - }) - const items = JSON.parse(raw) - - expect(items).toEqual([ - expect.objectContaining({ - type: 'script', - path: 'f/scripts/with-db-draft', - isDraft: true - }), - expect.objectContaining({ - type: 'flow', - path: 'f/flows/draft-only', - isDraft: true - }), - expect.objectContaining({ - type: 'app', - path: 'f/apps/with-db-draft', - isDraft: true - }) - ]) - expect(AppService.listApps).toHaveBeenCalledWith( - expect.objectContaining({ includeDraftOnly: true }) - ) - }) - - it('reads saved DB script drafts when no local draft exists', async () => { - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ - path: 'f/scripts/existing', - hash: 'deployed-hash', - summary: 'deployed summary', - description: 'deployed description', - content: 'deployed content', - language: 'bun', - kind: 'script', - draft: { - path: 'f/scripts/existing', - summary: 'db draft summary', - description: 'db draft description', - content: 'db draft content', - language: 'bun', - kind: 'script' - } - } as any) - - const raw = await callGlobalTool('read_workspace_item', { + // The tool result is built from the persisted value (not localStorage). + const result = JSON.parse(raw) + expect(result.success).toBe(true) + expect(result.message).toContain('as a workspace draft') + expect(result.message).toContain('not deployed') + expect(result.item).toMatchObject({ type: 'script', - path: 'f/scripts/existing' - }) - const item = JSON.parse(raw) - - expect(item).toEqual({ - type: 'script', - path: 'f/scripts/existing', - summary: 'db draft summary', + path: 'f/scripts/hello', + summary: 'Hello script', language: 'bun', - value: 'db draft content', - isDraft: false - }) - expect(raw).not.toContain('deployed content') - }) - - it('reads saved DB flow drafts when no local draft exists', async () => { - vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ - path: 'f/flows/existing', - summary: 'deployed summary', - 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: {}, - draft: { - path: 'f/flows/existing', - summary: 'db draft summary', - 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: {} - } - } as any) - - const raw = await callGlobalTool('read_workspace_item', { - type: 'flow', - path: 'f/flows/existing' - }) - const item = JSON.parse(raw) - - expect(item).toMatchObject({ - type: 'flow', - path: 'f/flows/existing', - summary: 'db draft summary', - isDraft: false, - value: { - modules: [{ id: 'draft_step', value: { type: 'identity' } }], - schema: { type: 'object', properties: { draft: { type: 'string' } } } - } - }) - expect(raw).not.toContain('deployed_step') - }) - - it('reads draft-only DB script anchors as drafts when no local draft exists', async () => { - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ - path: 'f/scripts/draft-only', - hash: 'draft-anchor-hash', - summary: 'draft-only summary', - description: 'draft-only description', - content: 'draft-only content', - language: 'bun', - kind: 'script', - draft_only: true - } as any) - - const raw = await callGlobalTool('read_workspace_item', { - type: 'script', - path: 'f/scripts/draft-only' - }) - const item = JSON.parse(raw) - - expect(item).toEqual({ - type: 'script', - path: 'f/scripts/draft-only', - summary: 'draft-only summary', - language: 'bun', - value: 'draft-only content', + value: content, isDraft: true }) }) - it('reads draft-only DB flow anchors as drafts when no local draft exists', async () => { - vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ - path: 'f/flows/draft-only', - summary: 'draft-only flow summary', - value: { - modules: [{ id: 'draft_only_step', value: { type: 'identity' } }] - }, - schema: { type: 'object', properties: { draftOnly: { type: 'boolean' } } }, - edited_by: 'admin', - edited_at: '2026-05-22T10:00:00Z', - archived: false, - extra_perms: {}, - draft_only: true - } as any) - - const raw = await callGlobalTool('read_workspace_item', { - type: 'flow', - path: 'f/flows/draft-only' - }) - const item = JSON.parse(raw) - - expect(item).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('deploys DB script drafts with draft metadata when no local draft exists', async () => { - vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ - path: 'f/scripts/existing', + it('skips the draft_only anchor when the script item already exists', async () => { + __seedDbRow('script', 'f/scripts/existing-anchor', { + path: 'f/scripts/existing-anchor', hash: 'deployed-hash', - summary: 'deployed summary', - description: 'deployed description', - content: 'deployed content', - schema: {}, - is_template: false, + summary: 'deployed', + description: '', + content: 'old', 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) - - await callGlobalTool('deploy_workspace_item', { - type: 'script', - path: 'f/scripts/existing', - deployment_message: 'ship db draft' + kind: 'script' }) - 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' - }) + await callGlobalTool('write_script', { + path: 'f/scripts/existing-anchor', + summary: 'Updated', + language: 'bun', + content: 'export async function main() { return 1 }' }) - expect(ScriptService.getScriptByPath).not.toHaveBeenCalled() + + expect(ScriptService.createScript).not.toHaveBeenCalled() + expect(DraftService.createDraft).toHaveBeenCalledTimes(1) }) - it('deploys saved DB script rename drafts to the draft target path', async () => { - vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ - path: 'f/scripts/old-name', - hash: 'old-script-hash', - summary: 'deployed summary', - description: 'deployed description', - content: 'deployed content', - schema: {}, - is_template: false, - language: 'bun', - kind: 'script', - draft: { - path: 'f/scripts/new-name', - summary: 'renamed draft summary', - description: 'renamed draft description', - content: 'renamed draft content', + it('mirrors a script write to localStorage when a live editor is open', async () => { + // Open a live script editor whose effective path matches the write target. + UserDraft.save( + 'script', + '', + { + path: 'u/admin/live_mirror', + summary: 'Live script', + description: '', + content: 'export async function main() { return 1 }', + schema: {}, + is_template: false, language: 'bun', kind: 'script' - } - } as any) - - 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' - }) - }) - expect(JSON.parse(raw)).toMatchObject({ - success: true, - type: 'script', - path: 'f/scripts/new-name' - }) - }) - - it('deploys DB flow drafts with draft metadata when no local draft exists', async () => { - vi.mocked(FlowService.existsFlowByPath).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) - - await callGlobalTool('deploy_workspace_item', { - type: 'flow', - path: 'f/flows/existing', - deployment_message: 'ship db draft' - }) - - expect(FlowService.updateFlow).toHaveBeenCalledWith({ + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ 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' - }) + itemKind: 'script', + storagePath: '', + effectivePath: 'u/admin/live_mirror' + }) + + const content = 'export async function main() { return 2 }' + await callGlobalTool('write_script', { + path: 'u/admin/live_mirror', + summary: 'Live script', + language: 'bun', + content + }) + + // DB draft is always written... + expect(DraftService.createDraft).toHaveBeenCalledTimes(1) + // ...and a live editor open -> the value is mirrored to localStorage at the + // live editor's storage path ('') so the open editor updates immediately. + expect(UserDraft.get('script', '', { workspace: WORKSPACE })).toMatchObject({ + path: 'u/admin/live_mirror', + content }) - expect(vi.mocked(FlowService.updateFlow).mock.calls[0]?.[0].requestBody.value.modules).toEqual([ - { id: 'draft_step', value: { type: 'identity' } } - ]) - expect(FlowService.getFlowByPath).not.toHaveBeenCalled() }) - it('deploys saved DB flow rename drafts through the original route path', async () => { - vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) - vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ - path: 'f/flows/old-name', - summary: 'deployed summary', - description: 'deployed description', - value: { - modules: [{ id: 'deployed_step', value: { type: 'identity' } }] + it('reads a deployed script with isDraft false and a DB draft with isDraft true', async () => { + __seedDbRow('script', 'f/scripts/deployed-read', { + path: 'f/scripts/deployed-read', + hash: 'deployed-hash', + draft_only: false, + summary: 'Deployed', + description: '', + content: 'export async function main() { return 1 }', + language: 'bun', + kind: 'script' + }) + + const deployedRaw = await callGlobalTool('read_workspace_item', { + type: 'script', + path: 'f/scripts/deployed-read' + }) + expect(JSON.parse(deployedRaw)).toMatchObject({ + type: 'script', + path: 'f/scripts/deployed-read', + isDraft: false + }) + + // Now write a draft over it; the read should flag isDraft true. + await callGlobalTool('write_script', { + path: 'f/scripts/deployed-read', + summary: 'Deployed', + language: 'bun', + content: 'export async function main() { return 2 }' + }) + const draftRaw = await callGlobalTool('read_workspace_item', { + type: 'script', + path: 'f/scripts/deployed-read' + }) + const draftItem = JSON.parse(draftRaw) + expect(draftItem).toMatchObject({ + type: 'script', + path: 'f/scripts/deployed-read', + value: 'export async function main() { return 2 }', + 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. + UserDraft.save( + 'script', + 'f/other/outside', + { + path: 'f/other/outside', + summary: 'Outside draft', + description: '', + content: 'export async function main() { return "outside" }', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script' }, - 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' } } } - } - } as any) - - const raw = await callGlobalTool('deploy_workspace_item', { - type: 'flow', - path: 'f/flows/old-name' - }) - - 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' } } - ]) - expect(JSON.parse(raw)).toMatchObject({ - success: true, - type: 'flow', - path: 'f/flows/new-name' - }) - }) - - it('applies path_prefix to local drafts before enforcing the result limit', async () => { - await callGlobalTool('write_script', { - path: 'f/other/outside', - summary: 'Outside draft', - language: 'bun', - content: 'export async function main() { return "outside" }' - }) - await callGlobalTool('write_script', { - path: 'f/matching/inside', - summary: 'Inside draft', - language: 'bun', - content: 'export async function main() { return "inside" }' - }) + { workspace: WORKSPACE } + ) + UserDraft.save( + 'script', + 'f/matching/inside', + { + path: 'f/matching/inside', + summary: 'Inside draft', + description: '', + content: 'export async function main() { return "inside" }', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script' + }, + { workspace: WORKSPACE } + ) const raw = await callGlobalTool('list_workspace_items', { types: ['script'], @@ -1170,37 +763,69 @@ 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" }' - }) - + it('flags backend draft_only items as drafts and never double-lists them with localStorage', async () => { + // The backend list (`includeDraftOnly: true`) already returns Group-A DB + // drafts, including draft-only items that were never deployed. Those must be + // flagged `isDraft: true`, deployed items `isDraft: false`, and a path that + // ALSO has a localStorage live-editor mirror must appear exactly once. vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([ { - path: 'f/scripts/stale-list-summary', - summary: 'Old deployed summary', + path: 'f/list/deployed', + summary: 'Deployed script', language: 'bun', - has_draft: true + content: '', + draft_only: false + }, + { + path: 'f/list/db-draft-only', + summary: 'Draft-only script', + language: 'bun', + content: '', + draft_only: true + }, + { + path: 'f/list/live-mirrored', + summary: 'Backend copy', + language: 'bun', + content: '', + draft_only: true } ] as any) - const raw = await callGlobalTool('list_workspace_items', { - types: ['script'], - query: 'Fresh DB draft' - }) + // A live-editor localStorage draft for a path the backend list already + // returns — the merge must dedupe by key, not list it twice. + UserDraft.save( + 'script', + 'f/list/live-mirrored', + { + path: 'f/list/live-mirrored', + summary: 'Live mirror', + description: '', + content: 'export async function main() { return 1 }', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script' + }, + { workspace: WORKSPACE } + ) - expect(JSON.parse(raw)).toEqual([ - expect.objectContaining({ - type: 'script', - path: 'f/scripts/stale-list-summary', - summary: 'Fresh DB draft summary', - isDraft: true - }) + const raw = await callGlobalTool('list_workspace_items', { types: ['script'] }) + const items = JSON.parse(raw) as Array<{ path: string; isDraft: boolean }> + + const byPath = (p: string) => items.filter((i) => i.path === p) + expect(byPath('f/list/deployed')).toEqual([ + expect.objectContaining({ path: 'f/list/deployed', isDraft: false }) ]) - expect(raw).not.toContain('Old deployed summary') + expect(byPath('f/list/db-draft-only')).toEqual([ + expect.objectContaining({ path: 'f/list/db-draft-only', isDraft: true }) + ]) + // Exactly one entry despite appearing in BOTH the backend list and localStorage. + expect(byPath('f/list/live-mirrored')).toHaveLength(1) + expect(byPath('f/list/live-mirrored')[0]).toMatchObject({ + path: 'f/list/live-mirrored', + isDraft: true + }) }) it('lists and edits the live script editor draft through its effective path', async () => { @@ -1332,19 +957,28 @@ describe('global AI tools', () => { expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined() }) - it('discards a DB draft and its draft-only anchor without a local mirror', async () => { - await callGlobalTool('write_script', { - path: 'f/scripts/discard-me', - summary: 'Temporary draft', - language: 'bun', - content: 'export async function main() { return 1 }' - }) + it('discards a local draft without deleting the workspace item', async () => { + // For Group B (and the localStorage live-editor mirror), discard_draft + // operates on the localStorage layer, so seed a localStorage draft directly. + UserDraft.save( + 'script', + 'f/scripts/discard-me', + { + path: 'f/scripts/discard-me', + summary: 'Temporary draft', + description: '', + content: 'export async function main() { return 1 }', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script' + }, + { workspace: WORKSPACE } + ) - expect( - UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE }) - ).toBeUndefined() + expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined() - const raw = await callGlobalTool('discard_local_draft', { + const raw = await callGlobalTool('discard_draft', { type: 'script', path: 'f/scripts/discard-me' }) @@ -1355,61 +989,245 @@ describe('global AI tools', () => { path: 'f/scripts/discard-me' }) expect(raw).toContain('The deployed workspace item was not changed') - expect(DraftService.deleteDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - kind: 'script', - path: 'f/scripts/discard-me' - }) - expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({ - workspace: WORKSPACE, - 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', { + callGlobalTool('discard_draft', { type: 'trigger', path: 'f/routes/missing-kind' }) ).rejects.toThrow('trigger_kind is required') }) - it('preserves existing script metadata and seeds freshness on first script write', async () => { - vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ + it('discards a DB draft over a deployed script without deleting the deployed item', async () => { + // A deployed script that also carries a DB draft. Discard must remove only + // the draft row and leave the deployed item intact. + __seedDbRow('script', 'f/scripts/with-deployed', { + path: 'f/scripts/with-deployed', + hash: 'deployed-hash', + draft_only: false, + summary: 'deployed summary', + content: 'old deployed content', + language: 'bun', + kind: 'script', + draft: { + path: 'f/scripts/with-deployed', + summary: 'draft summary', + content: 'export async function main() { return 1 }', + language: 'bun', + kind: 'script' + } + }) + + const raw = await callGlobalTool('discard_draft', { + type: 'script', + path: 'f/scripts/with-deployed' + }) + + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'script', + path: 'f/scripts/with-deployed' + }) + // Deployed version exists -> the anchor/item must NOT be deleted. + expect(ScriptService.deleteScriptByPath).not.toHaveBeenCalled() + expect( + UserDraft.get('script', 'f/scripts/with-deployed', { workspace: WORKSPACE }) + ).toBeUndefined() + expect(JSON.parse(raw)).toMatchObject({ + success: true, + type: 'script', + path: 'f/scripts/with-deployed' + }) + expect(raw).toContain('deployed') + }) + + it('discards a draft_only script and deletes the anchor item', async () => { + // Only a DB draft exists (no deployed version) -> delete the draft AND the + // draft_only anchor so no empty shell remains. + __seedDbRow('script', 'f/scripts/db-only', { + path: 'f/scripts/db-only', + draft_only: true, + draft: { + path: 'f/scripts/db-only', + summary: 'DB draft script', + content: 'export async function main() { return 1 }', + language: 'bun', + kind: 'script' + } + }) + + await callGlobalTool('discard_draft', { + type: 'script', + path: 'f/scripts/db-only' + }) + + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'script', + path: 'f/scripts/db-only' + }) + expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/scripts/db-only' + }) + }) + + it('discards a DB draft over a deployed flow without deleting the deployed item', async () => { + __seedDbRow('flow', 'f/flows/with-deployed', { + path: 'f/flows/with-deployed', + draft_only: false, + summary: 'deployed summary', + value: { modules: [] }, + draft: { + path: 'f/flows/with-deployed', + summary: 'draft summary', + value: { modules: [] } + } + }) + + await callGlobalTool('discard_draft', { + type: 'flow', + path: 'f/flows/with-deployed' + }) + + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'flow', + path: 'f/flows/with-deployed' + }) + expect(FlowService.deleteFlowByPath).not.toHaveBeenCalled() + }) + + it('discards a draft_only flow and deletes the anchor item', async () => { + __seedDbRow('flow', 'f/flows/db-only', { + path: 'f/flows/db-only', + draft_only: true, + draft: { + path: 'f/flows/db-only', + summary: 'DB draft flow', + value: { modules: [] } + } + }) + + await callGlobalTool('discard_draft', { + type: 'flow', + path: 'f/flows/db-only' + }) + + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'flow', + path: 'f/flows/db-only' + }) + expect(FlowService.deleteFlowByPath).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/flows/db-only' + }) + }) + + it('discards a DB draft over a deployed app without deleting the deployed item', async () => { + __seedDbRow('app', 'f/apps/with-deployed', { + path: 'f/apps/with-deployed', + draft_only: false, + summary: 'deployed report', + versions: [1], + value: { files: {}, runnables: {}, data: { tables: [] } }, + policy: { execution_mode: 'publisher' }, + draft: { + summary: 'Updated report', + value: { files: { '/index.tsx': 'x' }, runnables: {}, data: { tables: [] } } + } + }) + + await callGlobalTool('discard_draft', { + type: 'app', + path: 'f/apps/with-deployed' + }) + + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'app', + path: 'f/apps/with-deployed' + }) + expect(AppService.deleteApp).not.toHaveBeenCalled() + }) + + it('discards a draft_only app and deletes the anchor item', async () => { + __seedDbRow('app', 'f/apps/db-only', { + path: 'f/apps/db-only', + draft_only: true, + draft: { + summary: 'AI report', + value: { files: { '/index.tsx': 'x' }, runnables: {}, data: { tables: [] } } + } + }) + + await callGlobalTool('discard_draft', { + type: 'app', + path: 'f/apps/db-only' + }) + + expect(DraftService.deleteDraft).toHaveBeenCalledWith({ + workspace: WORKSPACE, + kind: 'app', + path: 'f/apps/db-only' + }) + expect(AppService.deleteApp).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/apps/db-only' + }) + }) + + it('errors when discarding a Group A draft that does not exist', async () => { + await expect( + callGlobalTool('discard_draft', { + type: 'script', + path: 'f/scripts/missing' + }) + ).rejects.toThrow('No draft found') + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + expect(ScriptService.deleteScriptByPath).not.toHaveBeenCalled() + }) + + it('discards a Group B (resource) draft via localStorage only', async () => { + UserDraft.save( + 'resource', + 'f/resources/discard-me', + { + path: 'f/resources/discard-me', + value: { token: 'secret' }, + resource_type: 'c_custom' + }, + { workspace: WORKSPACE } + ) + expect( + UserDraft.get('resource', 'f/resources/discard-me', { workspace: WORKSPACE }) + ).toBeDefined() + + await callGlobalTool('discard_draft', { + type: 'resource', + path: 'f/resources/discard-me' + }) + + // Group B never touches the DB-draft / item services. + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + expect( + UserDraft.get('resource', 'f/resources/discard-me', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + + it('merges the existing DB draft and anchors to the deployed hash on a script write', async () => { + // A deployed script that already carries a DB draft. `loadDraft` resolves + // to the DB draft as the merge base; the deployed hash becomes parent_hash. + __seedDbRow('script', 'f/scripts/existing', { path: 'f/scripts/existing', hash: 'deployed-hash', + draft_only: false, draft_created_at: '2026-05-22T10:00:00Z', summary: 'deployed summary', description: 'deployed description', @@ -1424,7 +1242,7 @@ describe('global AI tools', () => { language: 'bun', kind: 'script' } - } as any) + }) await callGlobalTool('write_script', { path: 'f/scripts/existing', @@ -1433,29 +1251,27 @@ describe('global AI tools', () => { content: 'new content' }) - expect(UserDraft.get('script', 'f/scripts/existing', { workspace: WORKSPACE })).toBeUndefined() - expect(DraftService.createDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - requestBody: { - path: 'f/scripts/existing', - typ: 'script', - value: expect.objectContaining({ - path: 'f/scripts/existing', - parent_hash: 'deployed-hash', - summary: 'new summary', - description: 'db draft description', - content: 'new content', - language: 'bun' - }) - } + // Item already exists -> no anchor; only the draft row is upserted. + expect(ScriptService.createScript).not.toHaveBeenCalled() + expect(DraftService.createDraft).toHaveBeenCalledTimes(1) + const value = vi.mocked(DraftService.createDraft).mock.calls[0][0].requestBody.value as any + expect(value).toMatchObject({ + path: 'f/scripts/existing', + parent_hash: 'deployed-hash', + summary: 'new summary', + description: 'db draft description', + content: 'new content', + language: 'bun' }) + // No live editor -> nothing mirrored to localStorage. + expect(UserDraft.get('script', 'f/scripts/existing', { workspace: WORKSPACE })).toBeUndefined() }) - it('preserves existing flow metadata and seeds freshness on first flow write', async () => { - vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) + it('merges the existing DB draft on a flow write', async () => { vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any) - vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ + __seedDbRow('flow', 'f/flows/existing', { path: 'f/flows/existing', + draft_only: false, summary: 'deployed summary', description: 'deployed description', value: { modules: [] }, @@ -1476,7 +1292,7 @@ describe('global AI tools', () => { archived: false, extra_perms: {} } - } as any) + }) await callGlobalTool('write_flow', { path: 'f/flows/existing', @@ -1484,20 +1300,16 @@ describe('global AI tools', () => { modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) }) - expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toBeUndefined() - expect(DraftService.createDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - requestBody: { - path: 'f/flows/existing', - typ: 'flow', - value: expect.objectContaining({ - path: 'f/flows/existing', - summary: 'new summary', - description: 'db draft description', - value: { modules: [{ id: 'step', value: { type: 'identity' } }] } - }) - } + expect(FlowService.createFlow).not.toHaveBeenCalled() + expect(DraftService.createDraft).toHaveBeenCalledTimes(1) + const value = vi.mocked(DraftService.createDraft).mock.calls[0][0].requestBody.value as any + expect(value).toMatchObject({ + path: 'f/flows/existing', + summary: 'new summary', + description: 'db draft description', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] } }) + expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toBeUndefined() }) it('preserves editor schedule fields when writing over an existing schedule', async () => { @@ -1608,8 +1420,11 @@ describe('global AI tools', () => { }) }) - it('seeds raw app draft metadata on first app write', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + it('upserts the DB draft (no anchor, no localStorage) when an app already has a draft', async () => { + // A deployed app that already carries a DB draft. `loadDraft` resolves the + // existing DB draft as the merge base; `write_app_file` adds a file and + // re-upserts the draft row. No live editor -> nothing mirrored to localStorage. + __seedDbRow('app', 'f/apps/report', { path: 'f/apps/report', summary: 'deployed app', versions: [3, 4], @@ -1635,7 +1450,7 @@ describe('global AI tools', () => { }, policy: { execution_mode: 'anonymous' } } - } as any) + }) await callGlobalTool('write_app_file', { path: 'f/apps/report', @@ -1643,38 +1458,147 @@ describe('global AI tools', () => { content: 'export default function New() { return null }' }) - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() - expect(DraftService.createDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - requestBody: { - path: 'f/apps/report', - typ: 'app', - value: expect.objectContaining({ - summary: 'saved app draft', - value: expect.objectContaining({ - files: { - '/src/App.tsx': 'draft content', - '/src/New.tsx': 'export default function New() { return null }' - }, - 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: 'report' - }) - } + // Existing item -> no anchor created; only the draft row is upserted. + expect(AppService.createAppRaw).not.toHaveBeenCalled() + expect(DraftService.createDraft).toHaveBeenCalledTimes(1) + const draftCall = vi.mocked(DraftService.createDraft).mock.calls[0][0] + expect(draftCall.workspace).toBe(WORKSPACE) + expect(draftCall.requestBody).toMatchObject({ path: 'f/apps/report', typ: 'app' }) + // The DB draft value nests the raw app under `.value`, like the raw-app editor. + const dbValue = draftCall.requestBody.value as any + expect(dbValue.value).toMatchObject({ + files: { + '/src/App.tsx': 'draft content', + '/src/New.tsx': 'export default function New() { return null }' + }, + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' } }) + expect(dbValue.summary).toBe('saved app draft') + expect(dbValue.policy).toEqual({ execution_mode: 'anonymous' }) + expect(dbValue.custom_path).toBe('report') + + // No live editor open -> nothing mirrored to localStorage. + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) - it('summarizes local raw app drafts in read_workspace_item', async () => { + it('creates a draft_only raw-app anchor (bundling) + a DB draft for a brand-new app', async () => { + // `init_app` is the only entry that creates a brand-new app: no row exists yet + // (existsApp false, no draft), so the DB-draft write must bundle and create a + // `draft_only` anchor so the draft row is readable, then upsert the draft row. + const raw = await callGlobalTool('init_app', { + path: 'f/apps/fresh', + summary: 'Fresh app', + framework: 'react19' + }) + + // A brand-new app -> the bundle runs and a draft_only raw-app anchor is created. + expect(bundleRawAppDraft).toHaveBeenCalledTimes(1) + expect(AppService.createAppRaw).toHaveBeenCalledTimes(1) + const anchor = vi.mocked(AppService.createAppRaw).mock.calls[0][0] as any + expect(anchor.workspace).toBe(WORKSPACE) + expect(anchor.formData.app.draft_only).toBe(true) + expect(anchor.formData.app.path).toBe('f/apps/fresh') + // A policy is required to create the anchor; `init_app` computes it first. + expect(anchor.formData.app.policy).toBeTruthy() + expect(anchor.formData.js).toBe('bundled js') + expect(anchor.formData.css).toBe('bundled css') + + // ...then the draft row is upserted under typ 'app'. + expect(DraftService.createDraft).toHaveBeenCalledTimes(1) + const draftCall = vi.mocked(DraftService.createDraft).mock.calls[0][0] + expect(draftCall.requestBody).toMatchObject({ path: 'f/apps/fresh', typ: 'app' }) + + // No live editor open -> nothing mirrored to localStorage. + expect(UserDraft.get('raw_app', 'f/apps/fresh', { workspace: WORKSPACE })).toBeUndefined() + + const result = JSON.parse(raw) + expect(result.success).toBe(true) + expect(result.item).toMatchObject({ type: 'app', path: 'f/apps/fresh', isDraft: true }) + }) + + it('prefers the DB draft over the deployed value when reading an app file', async () => { + // Deployed app that also carries a DB draft. `loadDraft` resolves the DB draft, + // so `read_app_file` returns the draft content, not the deployed content. + __seedDbRow('app', 'f/apps/prefers-draft', { + path: 'f/apps/prefers-draft', + summary: 'deployed app', + versions: [7], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + }, + draft: { + summary: 'saved app draft', + value: { + files: { '/src/App.tsx': 'draft content' }, + runnables: {}, + data: { tables: [] } + } + } + }) + + await expect( + callGlobalTool('read_app_file', { + path: 'f/apps/prefers-draft', + file_path: '/src/App.tsx' + }) + ).resolves.toBe('draft content') + }) + + it('mirrors an app write to localStorage when a live editor is open', async () => { + // Open a live raw-app editor whose effective path matches the write target. + UserDraft.save( + 'raw_app', + '', + { + summary: 'Live app', + files: { '/src/App.tsx': 'export default function App() { return null }' }, + runnables: {}, + data: { tables: [] } + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'raw_app', + storagePath: '', + effectivePath: 'u/admin/mirror_app' + }) + + await callGlobalTool('write_app_file', { + path: 'u/admin/mirror_app', + file_path: '/src/New.tsx', + content: 'export default function New() { return null }' + }) + + // DB draft is always written... + expect(DraftService.createDraft).toHaveBeenCalledTimes(1) + expect(vi.mocked(DraftService.createDraft).mock.calls[0][0].requestBody.typ).toBe('app') + // ...and a live editor open -> the value is mirrored to localStorage at the + // live editor's storage path ('') so the open editor updates immediately. + expect(UserDraft.get('raw_app', '', { workspace: WORKSPACE })).toMatchObject({ + files: { + '/src/App.tsx': 'export default function App() { return null }', + '/src/New.tsx': 'export default function New() { return null }' + } + }) + // Nothing written at the path-keyed slot (the non-live mirror location). + expect( + UserDraft.get('raw_app', 'u/admin/mirror_app', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + + it('summarizes a live-editor raw app draft in read_workspace_item', async () => { + // App reads route through `loadDraft`, which only reads the localStorage + // mirror when a live editor is open (source 'live'); otherwise it reads the + // DB. Seed the mirror AND open a live editor so this exercises the live path. UserDraft.save( 'raw_app', 'f/apps/local', @@ -1694,6 +1618,12 @@ describe('global AI tools', () => { }, { workspace: WORKSPACE } ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'raw_app', + storagePath: 'f/apps/local', + effectivePath: 'f/apps/local' + }) const raw = await callGlobalTool('read_workspace_item', { type: 'app', @@ -1786,7 +1716,9 @@ describe('global AI tools', () => { ], data: { tables: ['draft'] } }, - isDraft: false + // The value resolved from the DB draft (not the deployed version), so the + // item is flagged as a draft. + isDraft: true }) await expect( @@ -1898,22 +1830,244 @@ describe('global AI tools', () => { expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) - it('deploys a new raw app draft by bundling files and creating a raw app', async () => { - UserDraft.save( - 'raw_app', - 'f/apps/report', - { - summary: 'AI report', - files: { - '/index.tsx': 'console.log("app")', - '/package.json': '{"dependencies":{"react":"19.0.0"}}' - }, - runnables: {}, - data: { tables: [] } - }, - { workspace: WORKSPACE } - ) + it('deploys a DB-only script draft via createScript with inferred schema (no deployed version)', async () => { + // A brand-new draft_only script: only a DB draft exists, no deployed version. + __seedDbRow('script', 'f/scripts/db-only', { + path: 'f/scripts/db-only', + draft_only: true, + draft: { + path: 'f/scripts/db-only', + summary: 'DB draft script', + description: '', + content: 'export async function main(x: number) { return x }', + language: 'bun', + kind: 'script' + } + }) + await callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/db-only', + deployment_message: 'ship it' + }) + + // Deploys the DB draft content (no deployed version -> no parent_hash). The + // schema is inferred from the content like the editor does. + expect(ScriptService.createScript).toHaveBeenCalledTimes(1) + const call = vi.mocked(ScriptService.createScript).mock.calls[0][0] as any + expect(call.workspace).toBe(WORKSPACE) + expect(call.requestBody).toMatchObject({ + path: 'f/scripts/db-only', + summary: 'DB draft script', + content: 'export async function main(x: number) { return x }', + language: 'bun', + deployment_message: 'ship it' + }) + expect(call.requestBody.parent_hash).toBeUndefined() + expect(call.requestBody.schema?.properties).toHaveProperty('x') + // draft_only on a create would re-create an anchor; deploy must NOT set it. + expect(call.requestBody.draft_only).toBeUndefined() + + // The backend deletes the DB draft row on deploy; the frontend must NOT. + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + // localStorage stays clean (no live editor was open). + expect(UserDraft.get('script', 'f/scripts/db-only', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('anchors a deployed script to its parent_hash when deploying a DB draft over it', async () => { + // A deployed script that also carries a DB draft. The deployed hash becomes + // the parent_hash on the new deploy. + __seedDbRow('script', 'f/scripts/with-deployed', { + path: 'f/scripts/with-deployed', + hash: 'deployed-hash', + draft_only: false, + summary: 'deployed summary', + description: 'deployed description', + content: 'old deployed content', + language: 'bun', + kind: 'script', + draft: { + path: 'f/scripts/with-deployed', + summary: 'draft summary', + description: '', + content: 'export async function main() { return 1 }', + language: 'bun', + kind: 'script' + } + }) + vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/with-deployed', + hash: 'deployed-hash', + summary: 'deployed summary', + description: 'deployed description', + content: 'old deployed content', + language: 'bun', + kind: 'script' + } as any) + + await callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/with-deployed' + }) + + expect(ScriptService.createScript).toHaveBeenCalledTimes(1) + const call = vi.mocked(ScriptService.createScript).mock.calls[0][0] as any + expect(call.requestBody).toMatchObject({ + path: 'f/scripts/with-deployed', + content: 'export async function main() { return 1 }', + parent_hash: 'deployed-hash' + }) + expect(DraftService.deleteDraft).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', + hash: 'deployed-hash', + draft_only: false, + summary: 'deployed', + description: '', + content: 'export async function main() { return 1 }', + language: 'bun', + kind: 'script' + // no `draft` -> source resolves to 'deployed' + }) + + await expect( + callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/deployed-only' + }) + ).rejects.toThrow('No draft changes to deploy') + expect(ScriptService.createScript).not.toHaveBeenCalled() + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + }) + + it('errors when deploying a script with no draft at all', async () => { + await expect( + callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/missing' + }) + ).rejects.toThrow('No draft found') + expect(ScriptService.createScript).not.toHaveBeenCalled() + }) + + it('deploys a DB-only flow draft via createFlow (no deployed version)', async () => { + __seedDbRow('flow', 'f/flows/db-only', { + path: 'f/flows/db-only', + draft_only: true, + draft: { + path: 'f/flows/db-only', + summary: 'DB draft flow', + description: '', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] }, + schema: { properties: { a: { type: 'string' } } } + } + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'flow', + path: 'f/flows/db-only', + deployment_message: 'ship flow' + }) + + // No deployed version -> createFlow (not updateFlow). + expect(FlowService.createFlow).toHaveBeenCalledTimes(1) + expect(FlowService.updateFlow).not.toHaveBeenCalled() + const call = vi.mocked(FlowService.createFlow).mock.calls[0][0] as any + expect(call.workspace).toBe(WORKSPACE) + expect(call.requestBody).toMatchObject({ + path: 'f/flows/db-only', + summary: 'DB draft flow', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] }, + schema: { properties: { a: { type: 'string' } } }, + deployment_message: 'ship flow' + }) + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + expect(UserDraft.get('flow', 'f/flows/db-only', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('deploys a DB flow draft over a deployed flow via updateFlow', async () => { + __seedDbRow('flow', 'f/flows/with-deployed', { + path: 'f/flows/with-deployed', + draft_only: false, + summary: 'deployed summary', + description: 'deployed description', + value: { modules: [] }, + schema: { properties: { deployed: { type: 'boolean' } } }, + draft: { + path: 'f/flows/with-deployed', + summary: 'draft summary', + description: '', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] }, + schema: { properties: { a: { type: 'string' } } } + } + }) + vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + path: 'f/flows/with-deployed', + summary: 'deployed summary', + description: 'deployed description', + value: { modules: [] }, + schema: { properties: { deployed: { type: 'boolean' } } } + } as any) + + await callGlobalTool('deploy_workspace_item', { + type: 'flow', + path: 'f/flows/with-deployed' + }) + + // A deployed version exists -> updateFlow. + expect(FlowService.updateFlow).toHaveBeenCalledTimes(1) + expect(FlowService.createFlow).not.toHaveBeenCalled() + const call = vi.mocked(FlowService.updateFlow).mock.calls[0][0] as any + expect(call.workspace).toBe(WORKSPACE) + expect(call.path).toBe('f/flows/with-deployed') + expect(call.requestBody).toMatchObject({ + path: 'f/flows/with-deployed', + summary: 'draft summary', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] } + }) + expect(DraftService.deleteDraft).not.toHaveBeenCalled() + }) + + 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', + draft_only: false, + summary: 'deployed', + value: { modules: [] } + // no `draft` -> source resolves to 'deployed' + }) + + await expect( + callGlobalTool('deploy_workspace_item', { + type: 'flow', + path: 'f/flows/deployed-only' + }) + ).rejects.toThrow('No draft changes to deploy') + expect(FlowService.createFlow).not.toHaveBeenCalled() + expect(FlowService.updateFlow).not.toHaveBeenCalled() + }) + + it('deploys a new raw app draft by bundling files and creating a raw app', async () => { + __seedDbRow('app', 'f/apps/report', { + path: 'f/apps/report', + draft_only: true, + draft: { + summary: 'AI report', + value: { + files: { + '/index.tsx': 'console.log("app")', + '/package.json': '{"dependencies":{"react":"19.0.0"}}' + }, + runnables: {}, + data: { tables: [] } + } + } + }) const raw = await callGlobalTool('deploy_workspace_item', { type: 'app', path: 'f/apps/report', @@ -1961,19 +2115,24 @@ describe('global AI tools', () => { it('deploys an existing raw app draft by bundling files and updating the raw app', async () => { vi.mocked(AppService.existsApp).mockResolvedValueOnce(true) - UserDraft.save( - 'raw_app', - 'f/apps/report', - { + // A deployed app with a DB draft carrying the edited value + policy/custom_path. + __seedDbRow('app', 'f/apps/report', { + path: 'f/apps/report', + summary: 'deployed report', + versions: [1], + value: { files: {}, runnables: {}, data: { tables: [] } }, + policy: { execution_mode: 'publisher' }, + draft: { summary: 'Updated report', - files: { '/index.tsx': 'console.log("updated")' }, - runnables: {}, - data: { tables: ['orders'] }, + value: { + files: { '/index.tsx': 'console.log("updated")' }, + runnables: {}, + data: { tables: ['orders'] } + }, policy: { execution_mode: 'anonymous' }, custom_path: 'kept-by-backend' - }, - { workspace: WORKSPACE } - ) + } + }) await callGlobalTool('deploy_workspace_item', { type: 'app', @@ -2003,65 +2162,22 @@ describe('global AI tools', () => { expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) - it('deploys saved DB app rename drafts through the original route path', async () => { - appDrafts.set('f/apps/old-report', { - 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' - }) - - 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() - 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) try { - UserDraft.save( - 'raw_app', - 'f/apps/report', - { + __seedDbRow('app', 'f/apps/report', { + path: 'f/apps/report', + draft_only: true, + draft: { summary: 'AI report', - files: { '/index.tsx': 'console.log("app")' }, - runnables: {}, - data: { tables: [] } - }, - { workspace: WORKSPACE } - ) + value: { + files: { '/index.tsx': 'console.log("app")' }, + runnables: {}, + data: { tables: [] } + } + } + }) await callGlobalTool( 'deploy_workspace_item', @@ -2205,13 +2321,18 @@ describe('global AI tools', () => { expect(result).toContain('test logs') }) - it('test_run_script previews deployed script content when no local draft exists', async () => { - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ + it('test_run_script previews deployed script content when no draft exists', async () => { + // Deployed-only row (no draft) — `loadDraft` falls through to the deployed + // value, sourced from getScriptByPathWithDraft (not getScriptByPath). + __seedDbRow('script', 'f/scripts/deployed-test', { path: 'f/scripts/deployed-test', + hash: 'deployed-hash', + draft_only: false, summary: 'Deployed test script', content: 'def main(name):\n return name', - language: 'python3' - } as any) + language: 'python3', + kind: 'script' + }) await withCompletedTestJob(() => callGlobalTool('test_run_script', { @@ -2220,10 +2341,6 @@ describe('global AI tools', () => { }) ) - expect(ScriptService.getScriptByPathWithDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - path: 'f/scripts/deployed-test' - }) expect(ScriptService.getScriptByPath).not.toHaveBeenCalled() expect(JobService.runScriptPreview).toHaveBeenCalledWith({ workspace: WORKSPACE, @@ -2262,14 +2379,17 @@ describe('global AI tools', () => { }) }) - it('test_run_flow previews deployed flow content when no local draft exists', async () => { + it('test_run_flow previews deployed flow content when no draft exists', async () => { const modules = [{ id: 'deployed_start', value: { type: 'identity' } }] - vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ + // Deployed-only row (no draft) — `loadDraft` falls through to the deployed + // value, sourced from getFlowByPathWithDraft (not getFlowByPath). + __seedDbRow('flow', 'f/flows/deployed-test', { path: 'f/flows/deployed-test', + draft_only: false, summary: 'Deployed test flow', value: { modules }, schema: {} - } as any) + }) await withCompletedTestJob(() => callGlobalTool('test_run_flow', { @@ -2278,10 +2398,6 @@ describe('global AI tools', () => { }) ) - expect(FlowService.getFlowByPathWithDraft).toHaveBeenCalledWith({ - workspace: WORKSPACE, - path: 'f/flows/deployed-test' - }) expect(FlowService.getFlowByPath).not.toHaveBeenCalled() expect(JobService.runFlowPreview).toHaveBeenCalledWith({ workspace: WORKSPACE, @@ -2419,7 +2535,7 @@ describe('global AI tools', () => { }) }) - it('test_run_step previews DB-backed script drafts for script steps', async () => { + it('test_run_step prefers local script drafts for script steps', async () => { const content = 'export async function main(name: string) {\n\treturn `draft ${name}`\n}' await callGlobalTool('write_script', { path: 'f/scripts/step-script', @@ -2462,7 +2578,7 @@ describe('global AI tools', () => { }) }) - it('test_run_step previews DB-backed draft subflows for flow steps', async () => { + it('test_run_step previews local draft subflows for flow steps', async () => { const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }] await callGlobalTool('write_flow', { path: 'f/flows/nested-draft', @@ -2655,7 +2771,16 @@ describe('prepareGlobalSystemMessage', () => { expect(content).toContain('Draft tools create or update drafts only') expect(content).toContain( - 'Use discard_local_draft to remove an unsaved draft, including the matching open editor draft' + 'Use discard_draft to remove a draft, including the matching open editor draft' + ) + expect(content).toContain( + 'create or update a workspace draft: a persisted draft that is visible to editors' + ) + expect(content).toContain( + 'deploy_workspace_item deploys a draft to the workspace' + ) + expect(content).toContain( + 'they are staged locally in the browser until you deploy them' ) expect(content).toContain( 'After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step' @@ -2668,11 +2793,11 @@ describe('prepareGlobalSystemMessage', () => { }) it('exposes separate tools for discarding drafts and deleting workspace items', () => { - const discard = getGlobalTool('discard_local_draft') + const discard = getGlobalTool('discard_draft') const deleteItem = getGlobalTool('delete_workspace_item') expect(discard.def.function.description).toBe( - 'Discard a draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' + 'Discard a draft only; the deployed item is never changed. For script/flow/app it discards the workspace draft (and removes a draft-only item that was never deployed), leaving any deployed version untouched. For schedule/trigger/resource/variable it discards the locally staged draft. Also clears the matching open editor draft if one is mounted.' ) expect(deleteItem.def.function.description).toBe( 'Delete a deployed workspace item. Mutates the workspace.' diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index cefac3c916..bd335e15ed 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -1,6 +1,7 @@ import { AppService, AzureTriggerService, + DraftService, FlowService, GcpTriggerService, HttpTriggerService, @@ -18,24 +19,29 @@ import { } from '$lib/gen' import { $ScriptLang } from '$lib/gen/schemas.gen' import type { + AppWithLastVersion, CreateResource, CreateVariable, Flow, FlowValue, + ListableApp, ListableResource, ListableVariable, NewSchedule, NewScript, Resource, Schedule, + Script, ScriptLang } from '$lib/gen/types.gen' +import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { FRAMEWORK_TEMPLATES, STARTER_RUNNABLE, STARTER_RUNNABLE_KEY, type FrameworkKey } from '$lib/components/raw_apps/templates' +import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { applyEditableFlowJsonToFlow, buildEditableFlowJson, @@ -68,8 +74,9 @@ import { } from '../shared' import type { ContextElement } from '../context' import { getDatatableTools } from '../datatableTools' -import { UserDraft } from '$lib/userDraft.svelte' +import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte' import { emptySchema } from '$lib/utils' +import { inferArgs } from '$lib/infer' import { resourceRequestSchema, scheduleRequestSchema, @@ -88,30 +95,10 @@ import { type WorkspaceItem, type WorkspaceItemType } from './workspaceItems' -import { - appToItem, - deleteDbDraftAndDraftOnlyAnchor, - deployAppDraft, - deployFlowDraft, - deployScriptDraft, - type DraftFlagged, - flowToItem, - isDbDraftWorkspaceItemType, - loadAppDraftValue, - loadAppValueForRead, - loadDbDraftItem, - loadFlowWithDbDraft, - loadScriptWithDbDraft, - loadWorkspaceDraft, - loadWorkspaceDraftForDeploy, - recomputeAppPolicy, - saveAppDraft, - saveFlowDraftToDb, - saveScriptDraftToDb, - scriptToItem -} from './workspaceDrafts' +import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' import { userStore } from '$lib/stores' import { get } from 'svelte/store' +import { bundleRawAppDraft } from './rawAppBundlerBridge' import { clearEphemeralSecretVariableDraftValue, deleteGlobalDraft, @@ -120,9 +107,10 @@ import { getGlobalDraftStoragePath, listGlobalDrafts, setEphemeralSecretVariableDraftValue, - syncLiveGlobalDraft, triggerKindToUserDraftKind } from './userDraftAdapter' +import { loadDraft, persistDraft } from './draftStore' +import { readAppDbDraft, readFlowDbDraft, readScriptDbDraft } from './dbDraft' const ITEM_TYPES = [ 'script', @@ -142,7 +130,10 @@ const INSTRUCTION_SUBJECTS = [ // `datatable` is not a workspace item type, but the model can request the // datatable SDK reference (the wmill.datatable() runnable API) the same way. const INSTRUCTION_SUBJECTS_EXTRA = ['datatable'] as const -const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const +const ALL_INSTRUCTION_SUBJECTS = [ + ...INSTRUCTION_SUBJECTS, + ...INSTRUCTION_SUBJECTS_EXTRA +] as const const MAX_LIST_LIMIT = 100 type ActiveGlobalEditorType = Extract type LiveEditorDraftKind = Parameters[0] @@ -174,7 +165,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum) const getInstructionsSchema = z.object({ subject: instructionSubjectSchema.describe( - 'What to get authoring instructions for: a workspace item type (script, flow, resource, app) or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' + "What to get authoring instructions for: a workspace item type (script, flow, resource, app) or \"datatable\" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don't need instructions — their tool schemas describe everything." ), language: scriptLangSchema .optional() @@ -603,12 +594,17 @@ Path conventions: - If the request implies shared / team work but doesn't name a specific folder (e.g. "the marketing flow"), ask which folder to use rather than guessing. Call \`list_workspace_items\` with \`type: ['folder']\` (or rely on the user's hint) before assuming a folder exists. - Only use an \`f//\` path when the user explicitly named the folder or you confirmed it exists. +Draft model: +- write_script, write_flow, and the app-writing tools create or update a workspace draft: a persisted draft that is visible to editors and to anyone in the workspace, but is NOT deployed. The deployed version (if any) is left untouched until you deploy. +- deploy_workspace_item deploys a draft to the workspace, replacing the deployed version. discard_draft removes the draft (and, for a draft that was never deployed, the draft-only item itself), leaving any deployed version untouched. +- Schedule, trigger, resource, and variable drafts have no workspace-draft backing; they are staged locally in the browser until you deploy them. + Rules: - Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items. - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. - If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor". -- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. -- Use discard_local_draft to remove an unsaved draft, including the matching open editor draft. Use delete_workspace_item only to delete a deployed workspace item. +- Use deploy_workspace_item only after the user explicitly asks to deploy. It deploys a draft to the workspace. +- Use discard_draft to remove a draft, including the matching open editor draft. Use delete_workspace_item only to delete a deployed workspace item. - Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable". - Use search_resource_types before write_resource. - Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. @@ -658,26 +654,31 @@ 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 +function scriptToItem(script: Script, includeValue: boolean): WorkspaceItem { + return { + type: 'script', + path: script.path, + summary: script.summary, + language: script.language, + value: includeValue ? script.content : undefined, + // A backend `draft_only` script exists solely as a workspace draft (never + // deployed), so it is a draft. A deployed script (`draft_only` false) is not. + isDraft: script.draft_only ?? false + } } -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)))) +function flowToItem(flow: Flow, includeValue: boolean): WorkspaceItem { + return { + type: 'flow', + path: flow.path, + summary: flow.summary, + value: includeValue + ? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null } + : undefined, + // A backend `draft_only` flow exists solely as a workspace draft (never + // deployed), so it is a draft. A deployed flow (`draft_only` false) is not. + isDraft: flow.draft_only ?? false + } } /** @@ -875,6 +876,12 @@ type AppMetadata = { data?: any } +type LoadedAppDraftValue = { + value: AppDraftValue + meta?: UserDraftMeta + itemExists: boolean +} + function summarizeAppValue(value: AppDraftValue): AppMetadata { const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map( ([path, content]) => ({ @@ -908,6 +915,16 @@ function summarizeAppValue(value: AppDraftValue): AppMetadata { } } +function appToItem(app: ListableApp | AppWithLastVersion, includeValue: boolean): WorkspaceItem { + return { + type: 'app', + path: app.path, + summary: app.summary, + value: includeValue ? ((app as AppWithLastVersion).value as AppDraftValue) : undefined, + isDraft: false + } +} + const GENERATED_APP_FILE_PATHS = new Set(['/wmill.d.ts']) function assertNotGeneratedAppFile(filePath: string): void { @@ -971,6 +988,53 @@ function getInlineRunnableContent( return { content: runnable.inlineScript?.content ?? '', runnable } } +async function loadAppValueForRead(path: string, workspace: string): Promise { + const loaded = await loadDraft('app', path, workspace) + if (!loaded.value) throw new Error(`App "${path}" not found.`) + return loaded.value +} + +async function loadAppDraftValue(path: string, workspace: string): Promise { + const loaded = await loadDraft('app', path, workspace) + if (!loaded.value) throw new Error(`App "${path}" not found.`) + return { value: loaded.value, meta: loaded.meta, itemExists: loaded.itemExists } +} + +/** + * Build the `WorkspaceItem` an app-mutating tool reports for `item:` in its + * result, mirroring `scriptToWorkspaceItem` / `flowToWorkspaceItem`. The persisted + * value is the source of truth (we don't read it back from localStorage, since a + * non-live write never touches localStorage). + */ +function appToWorkspaceItem(path: string, value: AppDraftValue): WorkspaceItem { + return { + type: 'app', + path, + summary: value.summary, + value, + isDraft: true + } +} + +/** + * Persist an app draft via the DB-draft source of truth (mirrored to localStorage + * only when a live editor is open). `value.policy` must already be computed by the + * caller for the new-anchor case; the `bundle` callback is invoked only when a + * brand-new `draft_only` anchor has to be created (`itemExists` false). + */ +async function saveAppDraft( + workspace: string, + path: string, + value: AppDraftValue, + opts: { itemExists?: boolean } = {} +): Promise { + await persistDraft('app', path, workspace, value, { + itemExists: opts.itemExists, + bundle: () => bundleRawAppDraft({ workspace, files: value.files }) + }) + return appToWorkspaceItem(path, value) +} + type TriggerLike = { path: string; summary?: string | null } function triggerToItem( @@ -1080,9 +1144,9 @@ async function readWorkspaceItem( ): Promise { switch (type) { case 'script': - return scriptToItem(await loadScriptWithDbDraft(path, workspace), true) + return scriptToItem(await ScriptService.getScriptByPath({ workspace, path }), true) case 'flow': - return flowToItem(await loadFlowWithDbDraft(path, workspace), true) + return flowToItem(await FlowService.getFlowByPath({ workspace, path }), true) case 'schedule': return scheduleToItem(await ScheduleService.getSchedule({ workspace, path }), true) case 'trigger': @@ -1107,19 +1171,49 @@ async function readWorkspaceItem( ) case 'app': { // Returns lightweight metadata only — file/runnable contents come via read_app_file. - const value = await loadAppValueForRead(path, workspace) - const metadata = summarizeAppValue(value) + // Resolves the AI-chat draft precedence (live mirror -> DB draft -> deployed) + // via `loadDraft`; the item is flagged `isDraft` when the value came from a + // draft (live or DB) rather than the deployed version. + const loaded = await loadDraft('app', path, workspace) + if (!loaded.value) throw new Error(`App "${path}" not found.`) + const metadata = summarizeAppValue(loaded.value) return { type: 'app', path, - summary: value.summary, + summary: loaded.value.summary, value: metadata as unknown as AppDraftValue, - isDraft: false + isDraft: loaded.source === 'live' || loaded.source === 'db' } } } } +/** + * Read a script or flow for `read_workspace_item`, resolving the AI-chat draft + * precedence (live editor mirror -> DB draft -> deployed) via `loadDraft`. The + * returned item is flagged `isDraft` when the value came from a draft (live or + * DB) rather than the deployed version. + */ +async function readScriptOrFlowItem( + type: 'script' | 'flow', + path: string, + workspace: string +): Promise { + // 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). + 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' + 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' + return { ...flowToWorkspaceItem(path, loaded.value), isDraft } +} + async function listWorkspaceItems( types: WorkspaceItemType[], workspace: string, @@ -1136,11 +1230,7 @@ async function listWorkspaceItems( includeDraftOnly: true, withoutDescription: true }) - items.push( - ...(await hydrateListedDbDrafts(workspace, 'script', scripts, (script) => - scriptToItem(script, false) - )) - ) + for (const script of scripts) items.push(scriptToItem(script, false)) } if (types.includes('flow')) { @@ -1151,9 +1241,7 @@ async function listWorkspaceItems( includeDraftOnly: true, withoutDescription: true }) - items.push( - ...(await hydrateListedDbDrafts(workspace, 'flow', flows, (flow) => flowToItem(flow, false))) - ) + for (const flow of flows) items.push(flowToItem(flow, false)) } if (types.includes('schedule')) { @@ -1198,12 +1286,9 @@ async function listWorkspaceItems( const apps = await AppService.listApps({ workspace, pathStart: pathPrefix, - perPage, - includeDraftOnly: true + perPage }) - items.push( - ...(await hydrateListedDbDrafts(workspace, 'app', apps, (app) => appToItem(app, false))) - ) + for (const app of apps) items.push(appToItem(app, false)) } return items @@ -1404,6 +1489,14 @@ export const globalTools: Tool<{}>[] = [ const limit = parsed.limit ?? 50 toolCallbacks.setToolStatus(toolId, { content: 'Listing workspace items...' }) + // Dedupe by `getWorkspaceItemKey`. The backend list already includes + // Group-A DB drafts (`includeDraftOnly: true`, flagged `isDraft` via + // `draft_only`). The localStorage merge below only needs to ADD entries + // the backend list doesn't cover — live-editor-unsaved Group-A items and + // Group-B (schedule/trigger/resource/variable) drafts — so writing to the + // same `Map` key guarantees no item is listed twice. When a key collides + // (a live Group-A editor over its backend draft) the localStorage mirror + // wins, since it holds the freshest unsaved value. const byKey = new Map() const workspaceItems = await listWorkspaceItems( types, @@ -1447,10 +1540,41 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: message, error: message }) return JSON.stringify({ success: false, error: message }) } + // Group A (script/flow/app) draft precedence — live editor mirror -> DB + // draft -> deployed — is owned by `loadDraft` (via `readScriptOrFlowItem` + // and `readWorkspaceItem`'s `case 'app'`). These resolve through their + // loadDraft-backed reads, so the `getGlobalDraft` short-circuit below + // applies only to Group B. + if (parsed.type === 'script' || parsed.type === 'flow') { + toolCallbacks.setToolStatus(toolId, { + content: `Reading ${parsed.type} "${parsed.path}"...` + }) + const item = await readScriptOrFlowItem(parsed.type, parsed.path, workspace) + const draftFlag = item.isDraft ? ' draft' : '' + toolCallbacks.setToolStatus(toolId, { + content: `Read${draftFlag} ${parsed.type} "${parsed.path}"` + }) + return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2) + } + + if (parsed.type === 'app') { + toolCallbacks.setToolStatus(toolId, { + content: `Reading app "${parsed.path}"...` + }) + const item = await readWorkspaceItem('app', parsed.path, workspace) + const draftFlag = item.isDraft ? ' draft' : '' + toolCallbacks.setToolStatus(toolId, { + content: `Read${draftFlag} app "${parsed.path}"` + }) + return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2) + } + + // Group B (schedule/trigger/resource/variable) keep the + // localStorage-draft-then-backend read path. const draft = getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) if (draft) { toolCallbacks.setToolStatus(toolId, { - content: `Read draft ${parsed.type} "${parsed.path}"` + content: `Read local draft ${parsed.type} "${parsed.path}"` }) return JSON.stringify(serializeWorkspaceItemForRead(draft), null, 2) } @@ -1464,7 +1588,11 @@ export const globalTools: Tool<{}>[] = [ } }, { - def: createToolDef(writeScriptSchema, 'write_script', 'Create or overwrite a draft script.'), + def: createToolDef( + writeScriptSchema, + 'write_script', + 'Create or update a draft script (saved as a workspace draft — visible in editors, not deployed until deploy_workspace_item).' + ), showDetails: true, streamArguments: true, showFade: true, @@ -1474,7 +1602,11 @@ export const globalTools: Tool<{}>[] = [ } }, { - def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a draft flow.'), + def: createToolDef( + writeFlowSchema, + 'write_flow', + 'Create or update a draft flow (saved as a workspace draft — visible in editors, not deployed until deploy_workspace_item).' + ), showDetails: true, streamArguments: true, showFade: true, @@ -1534,7 +1666,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( editScriptSchema, 'edit_script', - 'Find/replace exact text in a script and save a draft.' + 'Find/replace exact text in a script and save a workspace draft.' ), showDetails: true, streamArguments: true, @@ -1548,7 +1680,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( patchFlowJsonSchema, 'patch_flow_json', - 'Find/replace exact text in compact flow JSON and save a draft.' + 'Find/replace exact text in compact flow JSON and save a workspace draft.' ), showDetails: true, streamArguments: true, @@ -1625,8 +1757,8 @@ export const globalTools: Tool<{}>[] = [ { def: createToolDef( discardDraftSchema, - 'discard_local_draft', - 'Discard a draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' + 'discard_draft', + 'Discard a draft only; the deployed item is never changed. For script/flow/app it discards the workspace draft (and removes a draft-only item that was never deployed), leaving any deployed version untouched. For schedule/trigger/resource/variable it discards the locally staged draft. Also clears the matching open editor draft if one is mounted.' ), showDetails: true, showFade: true, @@ -1711,7 +1843,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( setFlowModuleCodeSchema, 'set_flow_module_code', - 'Overwrite inline script code in one flow module and save a draft.' + 'Overwrite inline script code in one flow module and save a workspace draft.' ), showDetails: true, streamArguments: true, @@ -1725,7 +1857,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( initAppSchema, 'init_app', - 'Initialize a draft raw app from a framework template.', + 'Initialize a draft raw app from a framework template (saved as a workspace draft — visible in editors, not deployed until deploy_workspace_item).', { strict: false } ), showDetails: true, @@ -1750,7 +1882,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeAppFileSchema, 'write_app_file', - 'Create or overwrite a frontend file in an app draft.' + 'Create or overwrite a frontend file in a local app draft.' ), showDetails: true, streamArguments: true, @@ -1764,7 +1896,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deleteAppFileSchema, 'delete_app_file', - 'Remove a frontend file from an app draft.' + 'Remove a frontend file from a local app draft.' ), fn: async (ctx) => { const parsed = deleteAppFileSchema.parse(ctx.args) @@ -1775,7 +1907,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( patchAppFileSchema, 'patch_app_file', - 'Find/replace exact text in a raw app file and save a draft.' + 'Find/replace exact text in a raw app file and save a workspace draft.' ), showDetails: true, streamArguments: true, @@ -1789,7 +1921,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeAppRunnableSchema, 'write_app_runnable', - 'Create or overwrite a backend runnable in an app draft.', + 'Create or overwrite a backend runnable in a local app draft.', { strict: false } ), showDetails: true, @@ -1804,7 +1936,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deleteAppRunnableSchema, 'delete_app_runnable', - 'Remove a backend runnable from an app draft.' + 'Remove a backend runnable from a local app draft.' ), fn: async (ctx) => { const parsed = deleteAppRunnableSchema.parse(ctx.args) @@ -2063,7 +2195,7 @@ function buildVariableDeployRequestBody( function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void { ctx.toolCallbacks.setToolStatus(ctx.toolId, { - content: `Saving ${type} "${path}" as a draft...` + content: `Saving ${type} "${path}" as a draft…` }) } @@ -2087,14 +2219,19 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra ? serializeWorkspaceItemForRead(stored) : stored + const scope = + stored.type === 'script' || stored.type === 'flow' || stored.type === 'app' + ? 'as a workspace draft (visible to editors, not deployed)' + : 'as a local draft (staged locally, not deployed)' + ctx.toolCallbacks.setToolStatus(ctx.toolId, { - content: `${verb} ${stored.type} draft "${stored.path}"`, - result: `Saved draft` + content: `${verb} ${stored.type} "${stored.path}" as a draft`, + result: `Saved as a draft` }) return JSON.stringify( { success: true, - message: `${verb} ${stored.type} draft "${stored.path}". It was not deployed.`, + message: `${verb} ${stored.type} "${stored.path}" ${scope}.`, item: serializedItem }, null, @@ -2102,56 +2239,74 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra ) } +function scriptToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem { + return { + type: 'script', + path, + summary: draft.summary, + language: draft.language, + value: draft.content, + isDraft: true + } +} + +function flowToWorkspaceItem(path: string, draft: Flow): WorkspaceItem { + return { + type: 'flow', + path, + summary: draft.summary, + value: { + value: draft.value, + schema: draft.schema ?? null, + groups: draft.value.groups ?? null + }, + isDraft: true + } +} + async function writeScriptDraft( args: { path: string; summary?: string; language: ScriptLang; content: string }, ctx: WriteDraftCtx ): Promise { const { workspace } = ctx startDraftWrite(ctx, 'script', args.path) - const storagePath = getGlobalDraftStoragePath(workspace, 'script', args.path) - const existingDraft = UserDraft.get('script', storagePath, { workspace }) - const existing = (await ScriptService.existsScriptByPath({ workspace, path: args.path })) - ? await ScriptService.getScriptByPathWithDraft({ - workspace, - path: args.path - }) - : undefined - const base = existingDraft ?? ((existing?.draft ?? existing) as NewScript | undefined) - const draft: NewScript = base - ? { - ...structuredClone(base), - parent_hash: existing && !existing.draft_only ? existing.hash : base.parent_hash, - path: args.path, - summary: args.summary ?? base.summary, - content: args.content, - language: args.language - } - : { - path: args.path, - summary: args.summary ?? '', - description: '', - content: args.content, - schema: emptySchema(), - is_template: false, - language: args.language, - kind: 'script' - } + // Source of truth is the DB draft (live editor mirror takes precedence for + // the base value); `loadDraft` resolves live -> DB draft -> deployed. + const loaded = await loadDraft('script', args.path, workspace) + const base = loaded.value - await saveScriptDraftToDb(workspace, args.path, draft, existing) + let draft: NewScript + if (base) { + draft = { + ...structuredClone(base), + path: args.path, + summary: args.summary ?? base.summary, + content: args.content, + language: args.language + } + // First draft over a deployed script: anchor it to the deployed hash so a + // later deploy parents correctly. `meta.remoteRev` is the deployed hash; + // it's empty for the live branch (leave any existing parent_hash intact). + if (loaded.deployedExists && !draft.parent_hash && loaded.meta.remoteRev !== undefined) { + draft.parent_hash = loaded.meta.remoteRev as string + } + } else { + draft = { + path: args.path, + summary: args.summary ?? '', + description: '', + content: args.content, + schema: emptySchema(), + is_template: false, + language: args.language, + kind: 'script' + } + } - const mirrored = syncLiveGlobalDraft( - workspace, - 'script', - args.path, - draft, - existing && !existing.draft_only - ? { remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at } - : undefined - ) - const stored = mirrored ?? scriptToItem(draft, true, true) + await persistDraft('script', args.path, workspace, draft) - return finishDraftWrite(stored, existingDraft !== undefined || existing !== undefined, ctx) + return finishDraftWrite(scriptToWorkspaceItem(args.path, draft), loaded.itemExists, ctx) } async function writeFlowDraft( @@ -2160,7 +2315,6 @@ async function writeFlowDraft( ): Promise { const { workspace } = ctx startDraftWrite(ctx, 'flow', args.path) - const storagePath = getGlobalDraftStoragePath(workspace, 'flow', args.path) const draftValue = args.flow const value = structuredClone(draftValue.value) @@ -2168,48 +2322,34 @@ async function writeFlowDraft( value.groups = structuredClone(draftValue.groups) } - const existingDraft = UserDraft.get('flow', storagePath, { workspace }) - const existing = (await FlowService.existsFlowByPath({ workspace, path: args.path })) - ? await FlowService.getFlowByPathWithDraft({ workspace, path: args.path }) - : undefined - const base = existingDraft ?? ((existing?.draft ?? existing) as Flow | undefined) - const draft: Flow = base - ? { - ...structuredClone(base), - path: args.path, - summary: args.summary ?? base.summary, - value, - schema: draftValue.schema ?? base.schema - } - : { - path: args.path, - summary: args.summary ?? '', - value, - schema: draftValue.schema ?? emptySchema(), - edited_by: '', - edited_at: '', - archived: false, - extra_perms: {} - } + const loaded = await loadDraft('flow', args.path, workspace) + const base = loaded.value - await saveFlowDraftToDb(workspace, args.path, draft, existing) + let draft: Flow + if (base) { + draft = { + ...structuredClone(base), + path: args.path, + summary: args.summary ?? base.summary, + value, + schema: draftValue.schema ?? base.schema + } + } else { + draft = { + path: args.path, + summary: args.summary ?? '', + value, + schema: draftValue.schema ?? emptySchema(), + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + } + } - const latestVersion = - existing && !existing.draft_only - ? await FlowService.getFlowLatestVersion({ workspace, path: args.path }) - : undefined - const mirrored = syncLiveGlobalDraft( - workspace, - 'flow', - args.path, - draft, - latestVersion - ? { remoteRev: latestVersion.id, remoteDraftRev: existing?.draft_created_at } - : undefined - ) - const stored = mirrored ?? flowToItem(draft, true, true) + await persistDraft('flow', args.path, workspace, draft) - return finishDraftWrite(stored, existingDraft !== undefined || existing !== undefined, ctx) + return finishDraftWrite(flowToWorkspaceItem(args.path, draft), loaded.itemExists, ctx) } async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promise { @@ -2348,15 +2488,15 @@ async function loadScriptForEdit( path: string, workspace: string ): Promise<{ content: string; language: ScriptLang; summary?: string }> { - const draft = getGlobalDraft(workspace, 'script', path) - if (draft) { - if (typeof draft.value !== 'string' || !draft.language) { - throw new Error(`Draft script "${path}" is missing content or language.`) - } - return { content: draft.value, language: draft.language, summary: draft.summary } + const loaded = await loadDraft('script', path, workspace) + if (!loaded.value) { + throw new Error(`Script "${path}" not found.`) } - const script = await loadScriptWithDbDraft(path, workspace) - return { content: script.content, language: script.language, summary: script.summary } + const draft = loaded.value + if (typeof draft.content !== 'string' || !draft.language) { + throw new Error(`Script "${path}" is missing content or language.`) + } + return { content: draft.content, language: draft.language, summary: draft.summary } } async function editScript( @@ -2379,22 +2519,19 @@ async function editScript( ) } +function flowToFlowDraftValue(flow: Flow): FlowDraftValue { + return { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null } +} + async function loadFlowDraftValue( path: string, workspace: string ): Promise<{ flow: FlowDraftValue; summary?: string }> { - const draft = getGlobalDraft(workspace, 'flow', path) - if (draft) { - if (draft.value === undefined || typeof draft.value === 'string') { - throw new Error(`Draft flow "${path}" has no value.`) - } - return { flow: draft.value as FlowDraftValue, summary: draft.summary } - } - const flow = await loadFlowWithDbDraft(path, workspace) - return { - flow: { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null }, - summary: flow.summary + const loaded = await loadDraft('flow', path, workspace) + if (!loaded.value) { + throw new Error(`Flow "${path}" not found.`) } + return { flow: flowToFlowDraftValue(loaded.value), summary: loaded.value.summary } } async function patchFlowJson( @@ -2508,28 +2645,21 @@ async function loadScriptForFlowStep( moduleValue: { path: string; hash?: string }, workspace: string ): Promise<{ content: string; language: ScriptLang }> { - const localDraft = getGlobalDraft(workspace, 'script', moduleValue.path) - if (localDraft) { - if (typeof localDraft.value !== 'string' || !localDraft.language) { + // Prefer a draft (live editor mirror or DB draft) over the deployed version. + // A draft is path-keyed, so it overrides a hash-pinned module reference. + const loaded = await loadDraft('script', moduleValue.path, workspace) + if (loaded.source === 'live' || loaded.source === 'db') { + const draft = loaded.value + if (!draft || typeof draft.content !== 'string' || !draft.language) { throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`) } - return { content: localDraft.value, language: localDraft.language } + return { content: draft.content, language: draft.language } } - if (!moduleValue.hash) { - const script = await loadScriptWithDbDraft(moduleValue.path, workspace) - return { content: script.content, language: script.language } - } - - const draft = (await loadWorkspaceDraftForDeploy(workspace, 'script', moduleValue.path))?.item - if (draft) { - if (typeof draft.value !== 'string' || !draft.language) { - throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`) - } - return { content: draft.value, language: draft.language } - } - - const script = await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash }) + // No draft: honor the module's pinned hash when present, else read by path. + const script = moduleValue.hash + ? await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash }) + : await ScriptService.getScriptByPath({ workspace, path: moduleValue.path }) return { content: script.content, language: script.language } } @@ -2537,15 +2667,15 @@ async function loadDraftFlowPreviewValue( path: string, workspace: string ): Promise { - const draft = await loadWorkspaceDraftForDeploy(workspace, 'flow', path) - if (!draft) { + // Only return a preview value when a draft (live editor mirror or DB draft) + // exists; for a deployed-only subflow the step runner falls back to + // run_flow_by_path against the deployed version. + const loaded = await loadDraft('flow', path, workspace) + if (loaded.source !== 'live' && loaded.source !== 'db') { return undefined } - const flowDraft = draft.item.value - if (flowDraft === undefined || typeof flowDraft === 'string') { - throw new Error(`Draft flow "${path}" has no value.`) - } - return flowDraftValueForPreview(flowDraft as FlowDraftValue) + if (!loaded.value) return undefined + return flowDraftValueForPreview(flowToFlowDraftValue(loaded.value)) } async function testRunScriptByPath( @@ -2673,7 +2803,7 @@ async function initApp( } toolCallbacks.setToolStatus(toolId, { - content: `Saving app "${path}" as a draft (${framework} template)...` + content: `Saving app "${path}" as a draft (${framework} template)…` }) const template = FRAMEWORK_TEMPLATES[framework] @@ -2682,17 +2812,21 @@ async function initApp( files: { ...template }, runnables: { [STARTER_RUNNABLE_KEY]: { ...STARTER_RUNNABLE } } } + // Compute the policy before persisting — a brand-new app draft needs a + // `draft_only` anchor, and `createAppRaw` requires a policy. await recomputeAppPolicy(value) - const stored = await saveAppDraft(workspace, path, value) + // `existsApp` is false here (guarded above) and there is no draft_only row yet, + // so this is a new item: `persistDraft` creates the anchor + bundles. + const stored = await saveAppDraft(workspace, path, value, { itemExists: false }) toolCallbacks.setToolStatus(toolId, { - content: `Saved app draft "${path}" (${framework})`, + content: `Saved app "${path}" as a draft (${framework})`, result: 'Saved draft' }) return JSON.stringify( { success: true, - message: `Initialized app draft "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}". Use write_app_file / write_app_runnable to evolve it.`, + message: `Initialized app "${path}" as a workspace draft from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (visible in editors, not deployed until deploy_workspace_item). Use write_app_file / write_app_runnable to evolve it.`, item: stored }, null, @@ -2743,9 +2877,9 @@ async function writeAppFile( content: `Writing ${target.filePath} to app "${args.path}"...` }) - const { value, meta, existing } = await loadAppDraftValue(args.path, workspace) + const { value, itemExists } = await loadAppDraftValue(args.path, workspace) value.files = { ...value.files, [target.filePath]: args.content } - const stored = await saveAppDraft(workspace, args.path, value, meta, existing) + const stored = await saveAppDraft(workspace, args.path, value, { itemExists }) toolCallbacks.setToolStatus(toolId, { content: `Updated ${target.filePath} in app "${args.path}"`, @@ -2754,7 +2888,7 @@ async function writeAppFile( return JSON.stringify( { success: true, - message: `Updated app draft "${args.path}" with frontend file "${target.filePath}".`, + message: `Updated the draft for app "${args.path}" with frontend file "${target.filePath}".`, item: stored }, null, @@ -2779,13 +2913,13 @@ async function deleteAppFile( content: `Deleting ${target.filePath} from app "${args.path}"...` }) - const { value, meta, existing } = await loadAppDraftValue(args.path, workspace) + const { value, itemExists } = await loadAppDraftValue(args.path, workspace) if (!(target.filePath in value.files)) { throw new Error(`Frontend file "${target.filePath}" not found in app "${args.path}".`) } const { [target.filePath]: _removed, ...remaining } = value.files value.files = remaining - const stored = await saveAppDraft(workspace, args.path, value, meta, existing) + const stored = await saveAppDraft(workspace, args.path, value, { itemExists }) toolCallbacks.setToolStatus(toolId, { content: `Removed ${target.filePath} from app "${args.path}"`, @@ -2794,7 +2928,7 @@ async function deleteAppFile( return JSON.stringify( { success: true, - message: `Removed "${target.filePath}" from app draft "${args.path}".`, + message: `Removed "${target.filePath}" from the draft for app "${args.path}".`, item: stored }, null, @@ -2829,7 +2963,7 @@ async function patchAppFile( content: `Patching ${target.filePath} in app "${path}"...` }) - const { value, meta, existing } = await loadAppDraftValue(path, workspace) + const { value, itemExists } = await loadAppDraftValue(path, workspace) let currentContent: string let runnable: PersistedRunnable | undefined @@ -2869,7 +3003,7 @@ async function patchAppFile( } } - const stored = await saveAppDraft(workspace, path, value, meta, existing) + const stored = await saveAppDraft(workspace, path, value, { itemExists }) toolCallbacks.setToolStatus(toolId, { content: `Patched ${target.filePath} in app "${path}"`, result: 'Saved draft' @@ -2877,7 +3011,7 @@ async function patchAppFile( return JSON.stringify( { success: true, - message: `Patched "${target.filePath}" in app draft "${path}".`, + message: `Patched "${target.filePath}" in the draft for app "${path}".`, item: stored }, null, @@ -2885,6 +3019,17 @@ async function patchAppFile( ) } +async function recomputeAppPolicy(value: AppDraftValue): Promise { + const policy = (await updateRawAppPolicy( + value.runnables as any, + value.policy as any + )) as NonNullable + if (!policy.execution_mode) { + policy.execution_mode = 'publisher' + } + value.policy = policy +} + async function writeAppRunnable( args: { path: string; key: string; runnable: BackendRunnableInput }, ctx: WriteDraftCtx @@ -2895,12 +3040,12 @@ async function writeAppRunnable( content: `Writing runnable "${key}" to app "${path}"...` }) - const { value, meta, existing } = await loadAppDraftValue(path, workspace) - const existingRunnable = value.runnables[key] as PersistedRunnable | undefined - const persisted = buildPersistedRunnable(input, existingRunnable) + const { value, itemExists } = await loadAppDraftValue(path, workspace) + const existing = value.runnables[key] as PersistedRunnable | undefined + const persisted = buildPersistedRunnable(input, existing) value.runnables = { ...value.runnables, [key]: persisted } await recomputeAppPolicy(value) - const stored = await saveAppDraft(workspace, path, value, meta, existing) + const stored = await saveAppDraft(workspace, path, value, { itemExists }) toolCallbacks.setToolStatus(toolId, { content: `Updated runnable "${key}" in app "${path}"`, @@ -2909,7 +3054,7 @@ async function writeAppRunnable( return JSON.stringify( { success: true, - message: `Updated app draft "${path}" with runnable "${key}".`, + message: `Updated the draft for app "${path}" with runnable "${key}".`, item: stored }, null, @@ -2927,14 +3072,14 @@ async function deleteAppRunnable( content: `Removing runnable "${key}" from app "${path}"...` }) - const { value, meta, existing } = await loadAppDraftValue(path, workspace) + const { value, itemExists } = await loadAppDraftValue(path, workspace) if (!(key in value.runnables)) { throw new Error(`Backend runnable "${key}" not found in app "${path}".`) } const { [key]: _removed, ...remaining } = value.runnables value.runnables = remaining await recomputeAppPolicy(value) - const stored = await saveAppDraft(workspace, path, value, meta, existing) + const stored = await saveAppDraft(workspace, path, value, { itemExists }) toolCallbacks.setToolStatus(toolId, { content: `Removed runnable "${key}" from app "${path}"`, @@ -2943,7 +3088,7 @@ async function deleteAppRunnable( return JSON.stringify( { success: true, - message: `Removed runnable "${key}" from app draft "${path}".`, + message: `Removed runnable "${key}" from the draft for app "${path}".`, item: stored }, null, @@ -3021,24 +3166,77 @@ async function discardDraft( throw new Error('trigger_kind is required when discarding a trigger draft.') } - const draft = await loadWorkspaceDraft(workspace, type, path, triggerKind) - if (!draft) { - throw new Error(`No draft found for ${type} "${path}".`) + // Group A (script/flow/app) drafts live in the DB `draft` table; localStorage is + // only a live-editor mirror. Discard deletes the DB draft row, deletes the + // `draft_only` anchor when the item exists *only* as a draft (so no empty shell + // remains), and never touches a deployed version. Group B + // (schedule/trigger/resource/variable) drafts are localStorage-only. + if (type === 'script' || type === 'flow' || type === 'app') { + const db = + type === 'script' + ? await readScriptDbDraft(workspace, path) + : type === 'flow' + ? await readFlowDbDraft(workspace, path) + : await readAppDbDraft(workspace, path) + const local = getGlobalDraft(workspace, type, path) + + if (!db.hasDbDraft && !local) { + 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. + if (db.draftOnly) { + if (type === 'script') { + await ScriptService.deleteScriptByPath({ workspace, path }) + } else if (type === 'flow') { + await FlowService.deleteFlowByPath({ workspace, path }) + } else { + await AppService.deleteApp({ workspace, path }) + } + } + + // Full clear of the live-editor mirror — the user is discarding. + deleteGlobalDraft(workspace, type, path) + + toolCallbacks.setToolStatus(toolId, { + content: `Discarded the draft for ${type} "${path}"`, + result: 'Discarded draft' + }) + return JSON.stringify( + { + success: true, + message: db.draftOnly + ? `Discarded the workspace draft for ${type} "${path}". The draft-only item was removed.` + : `Discarded the workspace draft for ${type} "${path}". The deployed workspace item was not changed.`, + type, + path, + triggerKind + }, + null, + 2 + ) } - if (isDbDraftWorkspaceItemType(type)) { - await deleteDbDraftAndDraftOnlyAnchor(workspace, type, path) + const draft = getGlobalDraft(workspace, type, path, triggerKind) + if (!draft) { + throw new Error(`No local draft found for ${type} "${path}".`) } + deleteGlobalDraft(workspace, type, path, triggerKind) toolCallbacks.setToolStatus(toolId, { - content: `Discarded ${type} draft "${path}"`, - result: 'Discarded draft' + content: `Discarded the local draft for ${type} "${path}"`, + result: 'Discarded local draft' }) return JSON.stringify( { success: true, - message: `Discarded the draft for ${type} "${path}". The deployed workspace item was not changed.`, + message: `Discarded the local draft for ${type} "${path}". The deployed workspace item was not changed.`, type, path, triggerKind @@ -3064,49 +3262,84 @@ async function deployDraft( throw new Error('trigger_kind is required when deploying a trigger.') } - const deployableDraft = await loadWorkspaceDraftForDeploy(workspace, type, path, triggerKind) - if (!deployableDraft) { - throw new Error(`No draft found for ${type} "${path}".`) + // Group A (script/flow/app) drafts live in the DB (or in localStorage only + // while a live editor is open); resolve them live-aware via `loadDraft`. + // Group B (schedule/trigger/resource/variable) drafts are localStorage-only, + // so they keep reading through `getGlobalDraft` in their branches. + const isGroupA = type === 'script' || type === 'flow' || type === 'app' + let draft: WorkspaceItem | undefined + if (!isGroupA) { + draft = getGlobalDraft(workspace, type, path, triggerKind) + if (!draft) { + throw new Error(`No local draft found for ${type} "${path}".`) + } + if (draft.value === undefined) { + throw new Error(`Draft ${type} "${path}" has no value to deploy.`) + } } - const { item: draft } = deployableDraft - if (draft.value === undefined) { - throw new Error(`Draft ${type} "${path}" has no value to deploy.`) - } - const deployPath = - type === 'script' || type === 'flow' || type === 'app' ? (draft.path ?? path) : path toolCallbacks.setToolStatus(toolId, { - content: `Deploying ${type} "${deployPath}"...` + content: `Deploying ${type} "${path}"...` }) let actions: ToolDisplayAction[] | undefined switch (type) { case 'script': { - await deployScriptDraft({ - workspace, - path: deployPath, - draft, - existing: deployableDraft.existingScript, - deploymentMessage, - draftMetadata: deployableDraft.scriptMetadata - }) + const loaded = await loadDraft('script', path, workspace) + if (loaded.source === 'none' || loaded.value === undefined) { + throw new Error(`No draft found for script "${path}".`) + } + 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 + ) + // Infer the arg schema from the content so it matches the code, like the editor does. + try { + const schema = emptySchema() + await inferArgs(requestBody.language, requestBody.content, schema) + requestBody.schema = schema + } catch (e) { + console.error('Failed to infer script schema before deploy', e) + } + await ScriptService.createScript({ workspace, requestBody }) break } case 'flow': { - await deployFlowDraft({ - workspace, + const loaded = await loadDraft('flow', path, workspace) + if (loaded.source === 'none' || loaded.value === undefined) { + throw new Error(`No draft found for flow "${path}".`) + } + 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, - targetPath: deployPath, - draft, - existing: deployableDraft.existingFlow, - deploymentMessage, - draftMetadata: deployableDraft.flowMetadata - }) + loaded.value.summary, + flowToFlowDraftValue(loaded.value), + existing, + deploymentMessage + ) + if (existing) { + await FlowService.updateFlow({ workspace, path, requestBody }) + } else { + await FlowService.createFlow({ workspace, requestBody }) + } break } case 'schedule': { - const requestBody = draft.value as any + const requestBody = draft!.value as any if (await ScheduleService.existsSchedule({ workspace, path })) { await ScheduleService.updateSchedule({ workspace, path, requestBody }) } else { @@ -3117,7 +3350,7 @@ async function deployDraft( } case 'trigger': { const service = triggerServices[triggerKind!] - const requestBody = draft.value as { is_flow?: boolean } + const requestBody = draft!.value as { is_flow?: boolean } if (await service.exists({ workspace, path })) { await service.update({ workspace, path, requestBody }) } else { @@ -3129,7 +3362,7 @@ async function deployDraft( break } case 'resource': { - const requestBody = draft.value as any + const requestBody = draft!.value as any if (await ResourceService.existsResource({ workspace, path })) { await ResourceService.updateResource({ workspace, path, requestBody }) } else { @@ -3142,7 +3375,7 @@ async function deployDraft( const requestBody = buildVariableDeployRequestBody( workspace, path, - draft.value as CreateVariable + draft!.value as CreateVariable ) if (await VariableService.existsVariable({ workspace, path })) { await VariableService.updateVariable({ workspace, path, requestBody }) @@ -3153,17 +3386,91 @@ async function deployDraft( break } case 'app': { - await deployAppDraft({ + const loaded = await loadDraft('app', path, workspace) + if (loaded.source === 'none' || loaded.value === undefined) { + throw new Error(`No draft found for app "${path}".`) + } + if (loaded.source === 'deployed') { + throw new Error(`No draft changes to deploy for app "${path}".`) + } + const appDraft = loaded.value + const appValue: AppDraftValue = { + ...appDraft, + files: { ...(appDraft.files ?? {}) }, + runnables: { ...(appDraft.runnables ?? {}) }, + data: appDraft.data ?? { ...DEFAULT_RAW_APP_DATA } + } + await recomputeAppPolicy(appValue) + const policy = appValue.policy + if (!policy) { + throw new Error(`Draft app "${path}" has no policy to deploy.`) + } + + toolCallbacks.setToolStatus(toolId, { + content: `Bundling app "${path}"...` + }) + const bundle = await bundleRawAppDraft({ workspace, - path, - targetPath: deployPath, - draft, - appExists: deployableDraft.appExists, - deploymentMessage, - onStatus: (content) => { - toolCallbacks.setToolStatus(toolId, { content }) + files: appValue.files, + onLog: (delta) => { + const lines = delta + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const latest = lines[lines.length - 1] + if (latest) { + toolCallbacks.setToolStatus(toolId, { + content: `Bundling app "${path}"... ${latest}` + }) + } } }) + + toolCallbacks.setToolStatus(toolId, { + content: `Deploying app "${path}"...` + }) + const rawAppValue = { + files: appValue.files, + runnables: appValue.runnables, + data: appValue.data ?? { ...DEFAULT_RAW_APP_DATA } + } + const summary = appValue.summary ?? '' + 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. + await AppService.updateAppRaw({ + workspace, + path, + formData: { + app: { + path, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage + }, + js: bundle.js, + css: bundle.css + } + }) + } else { + await AppService.createAppRaw({ + workspace, + formData: { + app: { + path, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage, + custom_path: appValue.custom_path + }, + js: bundle.js, + css: bundle.css + } + }) + } break } } @@ -3179,19 +3486,19 @@ async function deployDraft( app: 'raw_app' } const kind = previewKindByType[type] - if (kind) deployedInSessionHandler?.({ sessionId, kind, path: deployPath }) + if (kind) deployedInSessionHandler?.({ sessionId, kind, path }) toolCallbacks.setToolStatus(toolId, { - content: `Deployed ${type} "${deployPath}"`, + content: `Deployed ${type} "${path}"`, result: 'Deployed', actions }) return JSON.stringify( { success: true, - message: `Deployed draft ${type} "${deployPath}" to the workspace. Draft removed from the draft system.`, + message: `Deployed draft ${type} "${path}" to the workspace. Draft removed.`, type, - path: deployPath, + path, triggerKind }, null, diff --git a/frontend/src/lib/components/copilot/chat/global/dbDraft.test.ts b/frontend/src/lib/components/copilot/chat/global/dbDraft.test.ts new file mode 100644 index 0000000000..710b3e4916 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/dbDraft.test.ts @@ -0,0 +1,506 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// dbDraft.ts only touches these services. Mock them as plain spies so the +// unit is exercised in isolation (no transitive $lib/gen imports). +vi.mock('$lib/gen', () => ({ + ScriptService: { + createScript: vi.fn(async () => 'new-hash'), + getScriptByPathWithDraft: vi.fn(async () => { + throw new Error('getScriptByPathWithDraft mock not configured') + }) + }, + FlowService: { + createFlow: vi.fn(async () => 'created'), + getFlowByPathWithDraft: vi.fn(async () => { + throw new Error('getFlowByPathWithDraft mock not configured') + }), + getFlowLatestVersion: vi.fn(async () => { + throw new Error('getFlowLatestVersion mock not configured') + }) + }, + AppService: { + createAppRaw: vi.fn(async () => 'created'), + getAppByPathWithDraft: vi.fn(async () => { + throw new Error('getAppByPathWithDraft mock not configured') + }) + }, + DraftService: { createDraft: vi.fn(async () => 'created') } +})) + +import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen' +import { + saveScriptDbDraft, + saveFlowDbDraft, + saveAppDbDraft, + readScriptDbDraft, + readFlowDbDraft, + readAppDbDraft +} from './dbDraft' + +const ws = 'test-ws' + +const scriptCreate = ScriptService.createScript as unknown as ReturnType +const flowCreate = FlowService.createFlow as unknown as ReturnType +const appCreate = AppService.createAppRaw as unknown as ReturnType +const draftCreate = DraftService.createDraft as unknown as ReturnType +const scriptWithDraft = ScriptService.getScriptByPathWithDraft as unknown as ReturnType +const flowWithDraft = FlowService.getFlowByPathWithDraft as unknown as ReturnType +const flowLatestVersion = FlowService.getFlowLatestVersion as unknown as ReturnType +const appWithDraft = AppService.getAppByPathWithDraft as unknown as ReturnType + +// A 404 from the generated client surfaces as a thrown ApiError-shaped object +// with `.status === 404`. The read path catches *only* this case. +function notFound() { + return Object.assign(new Error('not found'), { status: 404 }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('saveScriptDbDraft', () => { + const baseScript = { + path: 'u/test/foo', + summary: 'Foo', + description: '', + content: 'def main(): pass', + language: 'python3', + schema: { type: 'object', properties: {} }, + kind: 'script', + is_template: false + } as any + + it('creates a draft_only anchor for a brand-new item, then upserts the draft row', async () => { + await saveScriptDbDraft(ws, baseScript.path, baseScript, { itemExists: false }) + + expect(scriptCreate).toHaveBeenCalledTimes(1) + const anchor = scriptCreate.mock.calls[0][0] + expect(anchor.workspace).toBe(ws) + expect(anchor.requestBody.draft_only).toBe(true) + expect(anchor.requestBody.path).toBe(baseScript.path) + expect(anchor.requestBody.content).toBe(baseScript.content) + expect(anchor.requestBody.language).toBe(baseScript.language) + + expect(draftCreate).toHaveBeenCalledTimes(1) + const draftCall = draftCreate.mock.calls[0][0] + expect(draftCall.workspace).toBe(ws) + expect(draftCall.requestBody.typ).toBe('script') + expect(draftCall.requestBody.path).toBe(baseScript.path) + expect(draftCall.requestBody.value.content).toBe(baseScript.content) + expect(draftCall.requestBody.value.draft_triggers).toEqual([]) + }) + + it('skips the anchor when the item already exists (deployed or draft_only)', async () => { + await saveScriptDbDraft(ws, baseScript.path, baseScript, { itemExists: true }) + expect(scriptCreate).not.toHaveBeenCalled() + expect(draftCreate).toHaveBeenCalledTimes(1) + }) + + it('keeps draft_only/no-triggers/no-parent_hash on the anchor while preserving triggers in the draft row', async () => { + await saveScriptDbDraft( + ws, + baseScript.path, + { ...baseScript, parent_hash: 'abc', draft_triggers: [{ x: 1 }] }, + { itemExists: false } + ) + const anchor = scriptCreate.mock.calls[0][0].requestBody + expect(anchor.draft_only).toBe(true) + expect(anchor.parent_hash).toBeUndefined() + expect('draft_triggers' in anchor).toBe(false) + + const draftValue = draftCreate.mock.calls[0][0].requestBody.value + expect(draftValue.draft_triggers).toEqual([{ x: 1 }]) + }) +}) + +describe('saveFlowDbDraft', () => { + const baseFlow = { + path: 'u/test/flow', + summary: 'My flow', + description: 'desc', + value: { modules: [] }, + schema: { type: 'object', properties: {} } + } as any + + it('creates a draft_only anchor for a new flow, then upserts the draft row with path + empty triggers', async () => { + await saveFlowDbDraft(ws, baseFlow.path, baseFlow, { itemExists: false }) + + expect(flowCreate).toHaveBeenCalledTimes(1) + const anchor = flowCreate.mock.calls[0][0].requestBody + expect(anchor.draft_only).toBe(true) + expect(anchor.path).toBe(baseFlow.path) + expect(anchor.summary).toBe(baseFlow.summary) + expect(anchor.value).toEqual(baseFlow.value) + + const draftCall = draftCreate.mock.calls[0][0].requestBody + expect(draftCall.typ).toBe('flow') + expect(draftCall.path).toBe(baseFlow.path) + expect(draftCall.value.path).toBe(baseFlow.path) + expect(draftCall.value.value).toEqual(baseFlow.value) + expect(draftCall.value.draft_triggers).toEqual([]) + }) + + it('skips the anchor when the flow already exists', async () => { + await saveFlowDbDraft(ws, baseFlow.path, baseFlow, { itemExists: true }) + expect(flowCreate).not.toHaveBeenCalled() + expect(draftCreate).toHaveBeenCalledTimes(1) + }) +}) + +describe('saveAppDbDraft', () => { + const baseApp = { + summary: 'My app', + files: { '/main.tsx': 'export default 1' }, + runnables: { a: { type: 'inline' } }, + data: { tables: [] }, + policy: { execution_mode: 'publisher' }, + custom_path: 'my-app' + } as any + const path = 'u/test/app' + + it('bundles + creates a draft_only raw-app anchor for a new app, then upserts the nested draft value', async () => { + const bundle = vi.fn(async () => ({ js: 'JS', css: 'CSS' })) + await saveAppDbDraft(ws, path, baseApp, { itemExists: false, bundle }) + + expect(bundle).toHaveBeenCalledTimes(1) + expect(appCreate).toHaveBeenCalledTimes(1) + const fd = appCreate.mock.calls[0][0].formData + expect(fd.js).toBe('JS') + expect(fd.css).toBe('CSS') + expect(fd.app.draft_only).toBe(true) + expect(fd.app.path).toBe(path) + expect(fd.app.policy).toEqual(baseApp.policy) + // anchor app value is the raw { files, runnables, data } — not the flattened draft value + expect(fd.app.value).toEqual({ + files: baseApp.files, + runnables: baseApp.runnables, + data: baseApp.data + }) + + const draftCall = draftCreate.mock.calls[0][0].requestBody + expect(draftCall.typ).toBe('app') + expect(draftCall.path).toBe(path) + // DB draft value nests the raw app under `value`, like the raw-app editor stores it + expect(draftCall.value.value).toEqual({ + files: baseApp.files, + runnables: baseApp.runnables, + data: baseApp.data + }) + expect(draftCall.value.path).toBe(path) + expect(draftCall.value.summary).toBe(baseApp.summary) + expect(draftCall.value.policy).toEqual(baseApp.policy) + expect(draftCall.value.custom_path).toBe(baseApp.custom_path) + }) + + it('skips the anchor (and the bundle) when the app already exists', async () => { + const bundle = vi.fn(async () => ({ js: 'JS', css: 'CSS' })) + await saveAppDbDraft(ws, path, baseApp, { itemExists: true, bundle }) + expect(bundle).not.toHaveBeenCalled() + expect(appCreate).not.toHaveBeenCalled() + expect(draftCreate).toHaveBeenCalledTimes(1) + }) + + it('requires a bundle to create a new anchor', async () => { + await expect(saveAppDbDraft(ws, path, baseApp, { itemExists: false })).rejects.toThrow( + /bundle/i + ) + }) + + it('requires a policy to create a new anchor', async () => { + const bundle = vi.fn(async () => ({ js: 'JS', css: 'CSS' })) + await expect( + saveAppDbDraft(ws, path, { ...baseApp, policy: undefined }, { itemExists: false, bundle }) + ).rejects.toThrow(/policy/i) + }) +}) + +describe('readScriptDbDraft', () => { + const path = 'f/scripts/existing' + + function deployed(extra: Record = {}) { + return { + path, + hash: 'deployed-hash', + summary: 'deployed summary', + description: 'deployed description', + content: 'deployed content', + language: 'bun', + kind: 'script', + ...extra + } + } + + it('returns the not-found result when no row exists at the path', async () => { + scriptWithDraft.mockRejectedValueOnce(notFound()) + const res = await readScriptDbDraft(ws, path) + expect(res).toEqual({ + itemExists: false, + deployedExists: false, + draftOnly: false, + hasDbDraft: false, + value: undefined, + meta: {} + }) + }) + + it('rethrows non-404 errors instead of swallowing them', async () => { + scriptWithDraft.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) + await expect(readScriptDbDraft(ws, path)).rejects.toThrow('boom') + }) + + it('returns the deployed value when the row has no draft', async () => { + scriptWithDraft.mockResolvedValueOnce(deployed()) + const res = await readScriptDbDraft(ws, path) + expect(res.itemExists).toBe(true) + expect(res.deployedExists).toBe(true) + expect(res.draftOnly).toBe(false) + expect(res.hasDbDraft).toBe(false) + expect(res.value?.content).toBe('deployed content') + expect(res.meta).toEqual({ remoteRev: 'deployed-hash', remoteDraftRev: undefined }) + }) + + it('returns the draft value (not the deployed one) when a draft row exists', async () => { + scriptWithDraft.mockResolvedValueOnce( + deployed({ + draft_created_at: '2026-05-22T10:00:00Z', + draft: { + path, + summary: 'db draft summary', + description: 'db draft description', + content: 'draft content', + language: 'bun', + kind: 'script' + } + }) + ) + const res = await readScriptDbDraft(ws, path) + expect(res.itemExists).toBe(true) + expect(res.deployedExists).toBe(true) + expect(res.draftOnly).toBe(false) + expect(res.hasDbDraft).toBe(true) + expect(res.value?.content).toBe('draft content') + expect(res.value?.summary).toBe('db draft summary') + expect(res.meta).toEqual({ + remoteRev: 'deployed-hash', + remoteDraftRev: '2026-05-22T10:00:00Z' + }) + }) + + it('marks a draft_only row as not-deployed', async () => { + scriptWithDraft.mockResolvedValueOnce( + deployed({ + draft_only: true, + draft_created_at: '2026-05-22T10:00:00Z', + draft: { path, summary: 's', content: 'draft content', language: 'bun', kind: 'script' } + }) + ) + const res = await readScriptDbDraft(ws, path) + expect(res.itemExists).toBe(true) + expect(res.deployedExists).toBe(false) + expect(res.draftOnly).toBe(true) + expect(res.hasDbDraft).toBe(true) + expect(res.value?.content).toBe('draft content') + }) +}) + +describe('readFlowDbDraft', () => { + const path = 'f/flows/existing' + + function deployed(extra: Record = {}) { + return { + path, + summary: 'deployed summary', + description: 'deployed description', + value: { modules: [] }, + schema: { properties: { deployed: { type: 'boolean' } } }, + edited_by: 'admin', + edited_at: '2026-05-22T09:00:00Z', + archived: false, + extra_perms: {}, + ...extra + } + } + + it('returns the not-found result when no row exists at the path', async () => { + flowWithDraft.mockRejectedValueOnce(notFound()) + const res = await readFlowDbDraft(ws, path) + expect(res).toEqual({ + itemExists: false, + deployedExists: false, + draftOnly: false, + hasDbDraft: false, + value: undefined, + meta: {} + }) + // No remoteRev lookup when the item doesn't exist. + expect(flowLatestVersion).not.toHaveBeenCalled() + }) + + it('rethrows non-404 errors instead of swallowing them', async () => { + flowWithDraft.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) + await expect(readFlowDbDraft(ws, path)).rejects.toThrow('boom') + }) + + it('returns the deployed value with remoteRev from getFlowLatestVersion', async () => { + flowWithDraft.mockResolvedValueOnce(deployed()) + flowLatestVersion.mockResolvedValueOnce({ id: 42 }) + const res = await readFlowDbDraft(ws, path) + expect(res.itemExists).toBe(true) + expect(res.deployedExists).toBe(true) + expect(res.draftOnly).toBe(false) + expect(res.hasDbDraft).toBe(false) + expect(res.value?.value).toEqual({ modules: [] }) + expect(res.meta).toEqual({ remoteRev: 42, remoteDraftRev: undefined }) + }) + + it('returns the draft value when a draft row exists', async () => { + flowWithDraft.mockResolvedValueOnce( + deployed({ + draft_created_at: '2026-05-22T10:00:00Z', + draft: { + path, + summary: 'db draft summary', + value: { modules: [{ id: 'a' }] }, + schema: { properties: { draft: { type: 'string' } } }, + edited_by: 'admin', + edited_at: '2026-05-22T09:30:00Z', + archived: false, + extra_perms: {} + } + }) + ) + flowLatestVersion.mockResolvedValueOnce({ id: 42 }) + const res = await readFlowDbDraft(ws, path) + expect(res.hasDbDraft).toBe(true) + expect(res.value?.value).toEqual({ modules: [{ id: 'a' }] }) + expect(res.value?.summary).toBe('db draft summary') + expect(res.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-05-22T10:00:00Z' }) + }) + + it('leaves remoteRev undefined for a draft_only flow with no deployed version', async () => { + flowWithDraft.mockResolvedValueOnce( + deployed({ + draft_only: true, + draft_created_at: '2026-05-22T10:00:00Z', + draft: { + path, + summary: 's', + value: { modules: [] }, + edited_by: 'admin', + edited_at: '2026-05-22T09:30:00Z', + archived: false, + extra_perms: {} + } + }) + ) + const res = await readFlowDbDraft(ws, path) + expect(res.draftOnly).toBe(true) + expect(res.deployedExists).toBe(false) + expect(res.hasDbDraft).toBe(true) + expect(res.meta.remoteRev).toBeUndefined() + expect(res.meta.remoteDraftRev).toBe('2026-05-22T10:00:00Z') + // A draft_only flow has no deployed version to look up. + expect(flowLatestVersion).not.toHaveBeenCalled() + }) + + it('tolerates a 404 from getFlowLatestVersion (remoteRev undefined)', async () => { + flowWithDraft.mockResolvedValueOnce(deployed()) + flowLatestVersion.mockRejectedValueOnce(notFound()) + const res = await readFlowDbDraft(ws, path) + expect(res.deployedExists).toBe(true) + expect(res.meta.remoteRev).toBeUndefined() + }) +}) + +describe('readAppDbDraft', () => { + const path = 'f/apps/report' + + function deployed(extra: Record = {}) { + return { + path, + summary: 'deployed app', + versions: [3, 4], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + }, + policy: { execution_mode: 'publisher' }, + custom_path: 'report', + ...extra + } + } + + it('returns the not-found result when no row exists at the path', async () => { + appWithDraft.mockRejectedValueOnce(notFound()) + const res = await readAppDbDraft(ws, path) + expect(res).toEqual({ + itemExists: false, + deployedExists: false, + draftOnly: false, + hasDbDraft: false, + value: undefined, + meta: {} + }) + }) + + it('rethrows non-404 errors instead of swallowing them', async () => { + appWithDraft.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) + await expect(readAppDbDraft(ws, path)).rejects.toThrow('boom') + }) + + it('returns the deployed value (shaped via appSourceToDraftValue) when no draft', async () => { + appWithDraft.mockResolvedValueOnce(deployed()) + const res = await readAppDbDraft(ws, path) + expect(res.itemExists).toBe(true) + expect(res.deployedExists).toBe(true) + expect(res.hasDbDraft).toBe(false) + expect(res.value?.files).toEqual({ '/src/App.tsx': 'deployed content' }) + expect(res.value?.policy).toEqual({ execution_mode: 'publisher' }) + expect(res.value?.custom_path).toBe('report') + expect(res.meta).toEqual({ remoteRev: 4, remoteDraftRev: undefined }) + }) + + it('returns the draft value when a draft row exists', async () => { + appWithDraft.mockResolvedValueOnce( + deployed({ + draft_created_at: '2026-05-22T10:30:00Z', + draft: { + summary: 'saved app draft', + value: { + files: { '/src/App.tsx': 'draft content' }, + runnables: { main: { type: 'inline' } }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' } + }, + policy: { execution_mode: 'anonymous' }, + custom_path: 'report' + } + }) + ) + const res = await readAppDbDraft(ws, path) + expect(res.hasDbDraft).toBe(true) + expect(res.value?.files).toEqual({ '/src/App.tsx': 'draft content' }) + expect(res.value?.summary).toBe('saved app draft') + expect(res.value?.policy).toEqual({ execution_mode: 'anonymous' }) + expect(res.value?.data).toEqual({ tables: ['orders'], datatable: 'db', schema: 'public' }) + expect(res.meta).toEqual({ remoteRev: 4, remoteDraftRev: '2026-05-22T10:30:00Z' }) + }) + + it('marks a draft_only app as not-deployed', async () => { + appWithDraft.mockResolvedValueOnce( + deployed({ + draft_only: true, + draft_created_at: '2026-05-22T10:30:00Z', + draft: { + summary: 's', + value: { files: {}, runnables: {}, data: { tables: [] } }, + policy: { execution_mode: 'publisher' } + } + }) + ) + const res = await readAppDbDraft(ws, path) + expect(res.draftOnly).toBe(true) + expect(res.deployedExists).toBe(false) + expect(res.hasDbDraft).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/dbDraft.ts b/frontend/src/lib/components/copilot/chat/global/dbDraft.ts new file mode 100644 index 0000000000..42b3b950c1 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/dbDraft.ts @@ -0,0 +1,328 @@ +import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen' +import type { Flow, NewScript } from '$lib/gen' +import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' +import type { UserDraftMeta } from '$lib/userDraft.svelte' +import type { AppDraftValue } from './workspaceItems' + +/** + * Persists AI-chat drafts of `script` / `flow` / `app` items exactly the way the + * editors' "Save draft" buttons do, against the backend `draft` table. + * + * The DB draft is the source of truth for AI-chat drafts. localStorage mirroring + * for an open live editor is layered on top in `draftStore.ts` — this module + * never touches localStorage. + * + * Two pieces per save: + * 1. The draft row itself, via `DraftService.createDraft` — an upsert keyed by + * (workspace, path, typ). Its `value` mirrors the shape the editors store. + * 2. For a brand-new item with no row yet in the script/flow/app table, a + * `draft_only` anchor row. This is required because `get*ByPathWithDraft` + * is a `LEFT JOIN` *from* the item table — without the anchor the draft is + * unreadable. Existing items (deployed or already draft_only) skip this. + * + * `opts.itemExists` is "a row already exists at this path in the item table + * (deployed or draft_only)". The caller knows this from the read it already did, + * so we never fetch it again here (keeps this module pure and avoids the + * redundant-fetch problem). + */ + +// The editors store the full item plus a `draft_triggers` array in the draft +// `value`. AI chat manages triggers as separate (localStorage-only) drafts, so +// we preserve any triggers already carried on the draft and otherwise default +// to none — never dropping the field, so the round-trip matches the editors'. +type ScriptDbDraft = NewScript & { draft_triggers?: unknown[] } +type FlowDbDraft = Flow & { draft_triggers?: unknown[] } + +function scriptDraftValue(draft: ScriptDbDraft): ScriptDbDraft { + return { ...draft, draft_triggers: draft.draft_triggers ?? [] } +} + +// The anchor carries every NewScript field so that `listScripts({includeDraftOnly})` +// and a first-time deploy observe the same metadata the draft was written with. +// `draft_triggers` isn't a NewScript field, and a fresh draft_only row has no +// parent — strip both. +function scriptAnchorBody(draft: ScriptDbDraft): NewScript { + const { draft_triggers: _triggers, parent_hash: _parent, ...rest } = draft + return { ...rest, draft_only: true } +} + +export async function saveScriptDbDraft( + workspace: string, + path: string, + draft: ScriptDbDraft, + opts: { itemExists: boolean } +): Promise { + if (!opts.itemExists) { + await ScriptService.createScript({ workspace, requestBody: scriptAnchorBody(draft) }) + } + await DraftService.createDraft({ + workspace, + requestBody: { path, typ: 'script', value: scriptDraftValue(draft) } + }) +} + +function flowDraftValue(draft: FlowDbDraft, path: string): FlowDbDraft { + return { ...draft, path, draft_triggers: draft.draft_triggers ?? [] } +} + +function flowAnchorBody(draft: Flow, path: string) { + return { + path, + summary: draft.summary ?? '', + description: draft.description ?? '', + value: draft.value, + schema: draft.schema, + draft_only: true + } +} + +export async function saveFlowDbDraft( + workspace: string, + path: string, + draft: FlowDbDraft, + opts: { itemExists: boolean } +): Promise { + if (!opts.itemExists) { + await FlowService.createFlow({ workspace, requestBody: flowAnchorBody(draft, path) }) + } + await DraftService.createDraft({ + workspace, + requestBody: { path, typ: 'flow', value: flowDraftValue(draft, path) } + }) +} + +// AI chat works with raw apps. The raw-app editor stores the runtime +// `{ files, runnables, data }` nested under `value`, alongside path/summary/ +// policy/custom_path — mirror that exactly so a draft round-trips between chat +// and an open raw-app editor. +function rawAppValue(value: AppDraftValue) { + return { files: value.files, runnables: value.runnables, data: value.data } +} + +function appDraftValue(value: AppDraftValue, path: string) { + return { + value: rawAppValue(value), + path, + summary: value.summary ?? '', + policy: value.policy, + custom_path: value.custom_path + } +} + +/** + * Unlike scripts/flows, a raw-app anchor row carries the compiled bundle, so the + * caller threads a `bundle` callback (used only when an anchor must be created). + * `value.policy` must already be computed by the caller — `createAppRaw` requires it. + */ +export async function saveAppDbDraft( + workspace: string, + path: string, + value: AppDraftValue, + opts: { itemExists: boolean; bundle?: () => Promise<{ js: string; css: string }> } +): Promise { + if (!opts.itemExists) { + if (!opts.bundle) { + throw new Error('A bundle is required to create a new app draft anchor.') + } + const policy = value.policy + if (!policy) { + throw new Error('A policy is required to create a new app draft anchor.') + } + const { js, css } = await opts.bundle() + await AppService.createAppRaw({ + workspace, + formData: { + app: { + value: rawAppValue(value), + path, + summary: value.summary ?? '', + policy, + draft_only: true, + custom_path: value.custom_path + }, + js, + css + } + }) + } + await DraftService.createDraft({ + workspace, + requestBody: { path, typ: 'app', value: appDraftValue(value, path) } + }) +} + +/** + * The read counterpart of the save functions above. Fetches the *latest DB + * draft falling back to the deployed version* for a single item, surfacing the + * flags the orchestrator needs: + * + * - `itemExists` — a row exists at this path (deployed OR draft_only). This + * is exactly the `opts.itemExists` the matching `save*` + * function consumes, closing the read -> write loop. + * - `deployedExists` — a non-`draft_only` (i.e. deployed) version exists. + * - `draftOnly` — the item row is a `draft_only` anchor. + * - `hasDbDraft` — a draft row exists (the `draft` field is present). + * - `value` — the draft value when `hasDbDraft`, else the deployed + * value, else `undefined`. Shaped exactly like what the + * save path writes / the editors store. + * - `meta` — `{ remoteRev, remoteDraftRev }`: the deployed version's + * id/hash and the DB draft's `created_at`. + * + * This module never touches localStorage; read precedence (live editor -> + * localStorage) is layered on top by the orchestrator. + */ +export type DbDraftRead = { + itemExists: boolean + deployedExists: boolean + draftOnly: boolean + hasDbDraft: boolean + value: V | undefined + meta: UserDraftMeta +} + +const NOT_FOUND: DbDraftRead = { + itemExists: false, + deployedExists: false, + draftOnly: false, + hasDbDraft: false, + value: undefined, + meta: {} +} + +// A missing item surfaces from the generated client as a thrown ApiError with +// `.status === 404` (the `get*ByPathWithDraft` handlers `not_found_if_none`). +// Catch *only* that case so genuine failures still propagate. +function isNotFound(e: unknown): boolean { + return typeof e === 'object' && e !== null && (e as { status?: unknown }).status === 404 +} + +export async function readScriptDbDraft( + workspace: string, + path: string +): Promise> { + let existing: Awaited> + try { + existing = await ScriptService.getScriptByPathWithDraft({ workspace, path }) + } catch (e) { + if (isNotFound(e)) return NOT_FOUND + throw e + } + const draftOnly = !!existing.draft_only + const hasDbDraft = existing.draft != null + // Scripts/flows store the draft `value` as the raw item (no wrapper). + const value = (hasDbDraft ? existing.draft : existing) as NewScript + return { + itemExists: true, + deployedExists: !draftOnly, + draftOnly, + hasDbDraft, + value, + meta: { remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at } + } +} + +export async function readFlowDbDraft(workspace: string, path: string): Promise> { + let existing: Awaited> + try { + existing = await FlowService.getFlowByPathWithDraft({ workspace, path }) + } catch (e) { + if (isNotFound(e)) return NOT_FOUND + throw e + } + const draftOnly = !!existing.draft_only + const hasDbDraft = existing.draft != null + const value = (hasDbDraft ? existing.draft : existing) as Flow + + // `remoteRev` for a flow is the latest deployed version id — a separate + // call. A `draft_only` flow has no deployed version, so skip the lookup; + // otherwise tolerate a 404 (deployed version raced away) by leaving + // `remoteRev` undefined rather than failing the whole read. + let remoteRev: number | undefined + if (!draftOnly) { + try { + const latestVersion = await FlowService.getFlowLatestVersion({ workspace, path }) + remoteRev = latestVersion.id + } catch (e) { + if (!isNotFound(e)) throw e + } + } + + return { + itemExists: true, + deployedExists: !draftOnly, + draftOnly, + hasDbDraft, + value, + meta: { remoteRev, remoteDraftRev: existing.draft_created_at } + } +} + +// The canonical app-source codecs. `core.ts` imports these (the `core -> dbDraft` +// direction is safe; `dbDraft` never imports `core`) so the read path here, the +// chat read/write sites, and the deploy path all interpret the stored app JSON +// identically to the editors. +export 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 } +} + +export 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 + } +} + +export function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta { + return { + remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined, + remoteDraftRev: app.draft_created_at + } +} + +export async function readAppDbDraft( + workspace: string, + path: string +): Promise> { + let app: Awaited> + try { + app = await AppService.getAppByPathWithDraft({ workspace, path }) + } catch (e) { + if (isNotFound(e)) return NOT_FOUND + throw e + } + const draftOnly = !!app.draft_only + const hasDbDraft = app.draft != null + // `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) + return { + itemExists: true, + deployedExists: !draftOnly, + draftOnly, + hasDbDraft, + value, + meta: appDraftMeta(app) + } +} 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 70d57db9e4..88cbafe205 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Flow, NewScript, Script } from '$lib/gen/types.gen' -import { - buildFlowDeployRequestBody, - buildScriptDeployRequestBody, - type FlowDeployMetadata, - type ScriptDeployMetadata -} from './deployRequests' +import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' import type { WorkspaceItem } from './workspaceItems' describe('global AI deploy request builders', () => { @@ -98,93 +93,6 @@ 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', @@ -235,67 +143,6 @@ 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 e52f7f7bf3..9e779779f6 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts @@ -1,8 +1,7 @@ import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen' import type { FlowDraftValue, WorkspaceItem } from './workspaceItems' -export type ScriptDeployMetadata = Partial