From 1eef53170b1b2afb75b9812e33787d1f28cf50dd Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 25 May 2026 16:18:57 +0200 Subject: [PATCH] feat: plug global chat drafts into userdraft (#9291) * refactor: move global chat drafts to userdraft * feat: share script and flow drafts with editors * feat: share trigger drafts with editors * feat: share raw app drafts with editor * feat: share resource drafts with editors * docs: rename global chat drafts copy * feat: add global chat draft discard tool * fix: resolve global chat editor draft paths * fix: remove editor draft path resolver * feat: track live editor drafts in userdraft * fix: snapshot live userdraft reads * chore: checkpoint pending global draft changes * fix: address global draft review issues * fix: defer raw app draft persistence * docs: remove pr investigation docs * fix: persist live global draft writes --- .../src/lib/components/FlowBuilder.svelte | 19 + .../copilot/chat/global/core.test.ts | 943 +++++++++++++++- .../components/copilot/chat/global/core.ts | 1004 ++++++++++++----- .../chat/global/deployRequests.test.ts | 2 +- .../copilot/chat/global/deployRequests.ts | 2 +- .../copilot/chat/global/draftStore.svelte.ts | 192 ---- .../copilot/chat/global/draftStore.test.ts | 56 - .../copilot/chat/global/userDraftAdapter.ts | 395 +++++++ .../copilot/chat/global/workspaceItems.ts | 122 ++ frontend/src/lib/components/flow_builder.ts | 1 + .../components/raw_apps/RawAppEditor.svelte | 5 +- .../raw_apps/RawAppEditorHeader.svelte | 20 +- frontend/src/lib/userDraft.svelte.ts | 67 +- frontend/src/lib/userDraft.test.ts | 121 +- .../(root)/(logged)/apps_raw/add/+page.svelte | 25 +- .../apps_raw/edit/[...path]/+page.svelte | 39 +- .../(root)/(logged)/flows/add/+page.svelte | 1 + .../flows/edit/[...path]/+page.svelte | 1 + .../(logged)/global_drafts/+page.svelte | 40 +- .../(root)/(logged)/scripts/add/+page.svelte | 12 + .../scripts/edit/[...path]/+page.svelte | 12 + 21 files changed, 2492 insertions(+), 587 deletions(-) delete mode 100644 frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts delete mode 100644 frontend/src/lib/components/copilot/chat/global/draftStore.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts create mode 100644 frontend/src/lib/components/copilot/chat/global/workspaceItems.ts diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 2dbdec772e..8c36de8b11 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -100,6 +100,7 @@ import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' + import { UserDraft } from '$lib/userDraft.svelte' let { initialPath = $bindable(''), @@ -123,6 +124,7 @@ children, loadedFromHistoryFromUrl, noInitial = false, + liveEditorDraftStoragePath = undefined, onSaveInitial, onSaveDraft, onDeploy, @@ -588,6 +590,23 @@ const flowEditorDrawer = writable(undefined) const history = initHistory(untrack(() => flowStore).val) const pathStore = writable(untrack(() => pathStoreInit) ?? initialPath) + + $effect(() => { + if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return + const workspace = $workspaceStore + UserDraft.setLiveEditorDraft({ + workspace, + itemKind: 'flow', + storagePath: liveEditorDraftStoragePath, + effectivePath: $pathStore + }) + return () => + UserDraft.clearLiveEditorDraft('flow', { + workspace, + storagePath: liveEditorDraftStoragePath + }) + }) + const captureOn = writable(false) const showCaptureHint = writable(undefined) const flowInputEditorStateStore = writable({ 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 13b870901b..7dce5eedad 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -42,17 +42,75 @@ vi.mock('$lib/gen', async () => { return { ...actual, + ScriptService: wrapService(actual.ScriptService, { + existsScriptByPath: vi.fn(async () => false), + createScript: vi.fn(async () => 'created'), + getScriptByPathWithDraft: vi.fn(async () => { + throw new Error('getScriptByPathWithDraft mock not configured') + }), + listScripts: vi.fn(async () => []) + }), FlowService: wrapService(actual.FlowService, { - existsFlowByPath: vi.fn(async () => false) + existsFlowByPath: vi.fn(async () => false), + createFlow: vi.fn(async () => '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') + }), + getFlowLatestVersion: vi.fn(async () => ({ id: 1 })), + listFlows: vi.fn(async () => []) + }), + ScheduleService: wrapService(actual.ScheduleService, { + existsSchedule: vi.fn(async () => false), + getSchedule: vi.fn(async () => { + throw new Error('getSchedule mock not configured') + }) + }), + HttpTriggerService: wrapService(actual.HttpTriggerService, { + existsHttpTrigger: vi.fn(async () => false), + getHttpTrigger: vi.fn(async () => { + throw new Error('getHttpTrigger mock not configured') + }) + }), + AppService: wrapService(actual.AppService, { + existsApp: vi.fn(async () => false), + getAppByPathWithDraft: vi.fn(async () => { + throw new Error('getAppByPathWithDraft mock not configured') + }), + listApps: vi.fn(async () => []) + }), + ResourceService: wrapService(actual.ResourceService, { + existsResource: vi.fn(async () => false), + getResource: vi.fn(async () => { + throw new Error('getResource mock not configured') + }) }), VariableService: wrapService(actual.VariableService, { - existsVariable: vi.fn(async () => false) + existsVariable: vi.fn(async () => false), + getVariable: vi.fn(async () => { + throw new Error('getVariable mock not configured') + }), + createVariable: vi.fn(async () => 'created'), + updateVariable: vi.fn(async () => 'updated') }) } }) -import { globalTools, prepareGlobalUserMessage } from './core' -import { globalDraftStore } from './draftStore.svelte' +import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core' +import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte' +import { clearGlobalDrafts } from './userDraftAdapter' +import { + AppService, + FlowService, + HttpTriggerService, + ResourceService, + ScheduleService, + ScriptService, + VariableService +} from '$lib/gen' import type { Tool, ToolCallbacks } from '../shared' const WORKSPACE = 'global-core-test' @@ -84,9 +142,20 @@ async function callGlobalTool( }) } +function localStorageSnapshot(): string { + const values: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key) values.push(`${key}: ${localStorage.getItem(key)}`) + } + return values.join('\n') +} + describe('global AI tools', () => { beforeEach(() => { - globalDraftStore.clearDrafts(WORKSPACE) + __resetUserDraftForTesting() + localStorage.clear() + clearGlobalDrafts(WORKSPACE) vi.clearAllMocks() }) @@ -105,6 +174,7 @@ describe('global AI tools', () => { const item = JSON.parse(raw) expect(raw).not.toContain('super-secret-token') + expect(localStorageSnapshot()).not.toContain('super-secret-token') expect(item).toEqual({ type: 'variable', path: 'f/secrets/api_key', @@ -113,6 +183,837 @@ describe('global AI tools', () => { }) }) + it('writes resource drafts in the editor UserDraft shape', async () => { + vi.mocked(ResourceService.existsResource).mockResolvedValueOnce(true) + vi.mocked(ResourceService.getResource).mockResolvedValueOnce({ + path: 'f/resources/db', + description: 'existing database', + value: { host: 'old.example.com', port: 5432 }, + resource_type: 'postgresql', + labels: ['prod'], + ws_specific: true, + edited_at: '2026-05-22T09:30:00Z' + } as any) + + await callGlobalTool('write_resource', { + path: 'f/resources/db', + value: { host: 'new.example.com', port: 5432 }, + resource_type: 'postgresql' + }) + + expect(UserDraft.get('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ + path: 'f/resources/db', + description: 'existing database', + args: { host: 'new.example.com', port: 5432 }, + labels: ['prod'], + wsSpecific: true, + resource_type: 'postgresql' + }) + expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ + remoteRev: '2026-05-22T09:30:00Z' + }) + }) + + it('writes variable drafts in the editor UserDraft shape', async () => { + vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true) + vi.mocked(VariableService.getVariable).mockResolvedValueOnce({ + path: 'f/secrets/api_key', + value: undefined, + is_secret: true, + description: 'old description', + account: 123, + is_oauth: true, + expires_at: '2026-06-22T09:30:00Z', + labels: ['prod'], + ws_specific: true, + edited_at: '2026-05-22T09:30:00Z' + } as any) + + await callGlobalTool('write_variable', { + path: 'f/secrets/api_key', + value: 'new-secret-token', + is_secret: true, + description: 'new description' + }) + + expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({ + path: 'f/secrets/api_key', + variable: { + value: '', + is_secret: true, + description: 'new description' + }, + labels: ['prod'], + wsSpecific: true, + account: 123, + is_oauth: true, + expires_at: '2026-06-22T09:30:00Z' + }) + expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({ + remoteRev: '2026-05-22T09:30:00Z' + }) + expect(localStorageSnapshot()).not.toContain('new-secret-token') + }) + + it('deploys secret variable drafts with ephemeral values only', async () => { + await callGlobalTool('write_variable', { + path: 'f/secrets/api_key', + value: 'new-secret-token', + is_secret: true, + description: 'new description' + }) + + expect( + UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/secrets/api_key', + variable: { + value: '', + is_secret: true, + description: 'new description' + }, + wsSpecific: false + }) + expect(localStorageSnapshot()).not.toContain('new-secret-token') + + await callGlobalTool('deploy_workspace_item', { + type: 'variable', + path: 'f/secrets/api_key' + }) + + expect(VariableService.createVariable).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'f/secrets/api_key', + value: 'new-secret-token', + is_secret: true, + description: 'new description', + ws_specific: false + }) + }) + expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined() + expect(localStorageSnapshot()).not.toContain('new-secret-token') + }) + + it('does not deploy a secret variable draft when the ephemeral value is gone', async () => { + UserDraft.save( + 'variable', + 'f/secrets/api_key', + { + path: 'f/secrets/api_key', + variable: { + value: '', + is_secret: true, + description: 'new description' + }, + labels: undefined, + wsSpecific: false + }, + { workspace: WORKSPACE } + ) + + await expect( + callGlobalTool('deploy_workspace_item', { + type: 'variable', + path: 'f/secrets/api_key' + }) + ).rejects.toThrow('secret draft values are kept only in memory') + expect(VariableService.createVariable).not.toHaveBeenCalled() + expect(VariableService.updateVariable).not.toHaveBeenCalled() + }) + + it('writes script drafts into UserDraft', async () => { + const content = 'export async function main() {\n\treturn "hello"\n}' + + await callGlobalTool('write_script', { + path: 'f/scripts/hello', + summary: 'Hello script', + language: 'bun', + content + }) + + expect(UserDraft.get('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject( + { + path: 'f/scripts/hello', + summary: 'Hello script', + language: 'bun', + content + } + ) + }) + + 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" }' + }) + + const raw = await callGlobalTool('list_workspace_items', { + types: ['script'], + path_prefix: 'f/matching/', + limit: 1 + }) + + expect(JSON.parse(raw)).toEqual([ + expect.objectContaining({ + type: 'script', + path: 'f/matching/inside', + isDraft: true + }) + ]) + }) + + it('lists and edits the live script editor draft through its effective path', async () => { + UserDraft.save( + 'script', + '', + { + path: 'u/admin/amazed_script', + summary: 'Live script', + description: '', + content: 'export async function main(a: number, b: number) {\n\treturn a + b\n}', + schema: {}, + is_template: false, + language: 'bun', + kind: 'script' + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'script', + storagePath: '', + effectivePath: 'u/admin/amazed_script' + }) + + const listRaw = await callGlobalTool('list_workspace_items', { types: ['script'] }) + expect(JSON.parse(listRaw)).toContainEqual( + expect.objectContaining({ + type: 'script', + path: 'u/admin/amazed_script', + isDraft: true, + isLiveDraft: true + }) + ) + + await callGlobalTool('edit_script', { + path: 'u/admin/amazed_script', + old_string: 'return a + b', + new_string: 'return a * b' + }) + + expect(UserDraft.get('script', '', { workspace: WORKSPACE })).toMatchObject({ + path: 'u/admin/amazed_script', + content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}' + }) + expect( + UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + + it('lists and writes the live flow editor draft through its effective path', async () => { + UserDraft.save( + 'flow', + '', + { + path: '', + summary: 'Live flow', + value: { modules: [] }, + schema: {}, + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'flow', + storagePath: '', + effectivePath: 'u/admin/live_flow' + }) + + const listRaw = await callGlobalTool('list_workspace_items', { types: ['flow'] }) + expect(JSON.parse(listRaw)).toContainEqual( + expect.objectContaining({ + type: 'flow', + path: 'u/admin/live_flow', + isDraft: true, + isLiveDraft: true + }) + ) + + await callGlobalTool('write_flow', { + path: 'u/admin/live_flow', + summary: 'Updated live flow', + modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) + }) + + expect(UserDraft.get('flow', '', { workspace: WORKSPACE })).toMatchObject({ + path: 'u/admin/live_flow', + summary: 'Updated live flow', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] } + }) + expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('writes the live raw app editor draft through its effective path', async () => { + 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/live_app' + }) + + await callGlobalTool('write_app_file', { + path: 'u/admin/live_app', + file_path: '/src/New.tsx', + content: 'export default function New() { return null }' + }) + + 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 }' + } + }) + expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('discards a local draft without deleting the workspace item', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/discard-me', + summary: 'Temporary draft', + language: 'bun', + content: 'export async function main() { return 1 }' + }) + + expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined() + + const raw = await callGlobalTool('discard_local_draft', { + type: 'script', + path: 'f/scripts/discard-me' + }) + + expect(JSON.parse(raw)).toMatchObject({ + success: true, + type: 'script', + path: 'f/scripts/discard-me' + }) + expect(raw).toContain('The deployed workspace item was not changed') + expect( + UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + + it('requires trigger_kind when discarding a trigger draft', async () => { + await expect( + callGlobalTool('discard_local_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({ + path: 'f/scripts/existing', + hash: 'deployed-hash', + draft_created_at: '2026-05-22T10:00:00Z', + summary: 'deployed summary', + description: 'deployed description', + content: 'old deployed content', + language: 'bun', + kind: 'script', + draft: { + path: 'f/scripts/existing', + summary: 'db draft summary', + description: 'db draft description', + content: 'old draft content', + language: 'bun', + kind: 'script' + } + } as any) + + await callGlobalTool('write_script', { + path: 'f/scripts/existing', + summary: 'new summary', + language: 'bun', + content: 'new content' + }) + + expect( + UserDraft.get('script', 'f/scripts/existing', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/scripts/existing', + parent_hash: 'deployed-hash', + summary: 'new summary', + description: 'db draft description', + content: 'new content', + language: 'bun' + }) + expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({ + remoteRev: 'deployed-hash', + remoteDraftRev: '2026-05-22T10:00:00Z' + }) + }) + + it('preserves existing flow metadata and seeds freshness on first flow write', async () => { + vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) + vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any) + vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ + path: 'f/flows/existing', + 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: {}, + draft_created_at: '2026-05-22T10:00:00Z', + draft: { + path: 'f/flows/existing', + summary: 'db draft summary', + description: 'db draft description', + value: { modules: [] }, + schema: { properties: { draft: { type: 'string' } } }, + edited_by: 'admin', + edited_at: '2026-05-22T09:30:00Z', + archived: false, + extra_perms: {} + } + } as any) + + await callGlobalTool('write_flow', { + path: 'f/flows/existing', + summary: 'new summary', + modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) + }) + + expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({ + path: 'f/flows/existing', + summary: 'new summary', + description: 'db draft description', + value: { modules: [{ id: 'step', value: { type: 'identity' } }] } + }) + expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({ + remoteRev: 42, + remoteDraftRev: '2026-05-22T10:00:00Z' + }) + }) + + it('preserves editor schedule fields when writing over an existing schedule', async () => { + vi.mocked(ScheduleService.existsSchedule).mockResolvedValueOnce(true) + vi.mocked(ScheduleService.getSchedule).mockResolvedValueOnce({ + path: 'f/schedules/nightly', + schedule: '0 0 0 * * *', + timezone: 'UTC', + enabled: true, + script_path: 'f/scripts/old', + is_flow: false, + args: {}, + extra_perms: { 'u/viewer': true }, + email: 'admin@windmill.dev', + permissioned_as: 'u/admin', + edited_by: 'admin', + edited_at: '2026-05-22T09:00:00Z', + summary: 'old summary', + description: 'keep this description', + no_flow_overlap: true, + cron_version: 'v2' + } as any) + + await callGlobalTool('write_schedule', { + path: 'f/schedules/nightly', + schedule: '0 15 0 * * *', + timezone: 'Europe/Paris', + script_path: 'f/flows/new', + is_flow: true, + args: { limit: 5 } + }) + + expect( + UserDraft.get('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/schedules/nightly', + schedule: '0 15 0 * * *', + timezone: 'Europe/Paris', + script_path: 'f/flows/new', + is_flow: true, + args: { limit: 5 }, + extra_perms: { 'u/viewer': true }, + permissioned_as: 'u/admin', + summary: 'old summary', + description: 'keep this description', + no_flow_overlap: true + }) + expect( + UserDraft.get('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) + ).not.toMatchObject({ + edited_by: expect.anything() + }) + }) + + it('preserves editor trigger fields when writing over an existing trigger', async () => { + vi.mocked(HttpTriggerService.existsHttpTrigger).mockResolvedValueOnce(true) + vi.mocked(HttpTriggerService.getHttpTrigger).mockResolvedValueOnce({ + path: 'f/routes/api', + script_path: 'f/scripts/old', + is_flow: false, + route_path: 'api/old', + http_method: 'post', + request_type: 'sync', + authentication_method: 'none', + is_static_website: false, + workspaced_route: false, + wrap_body: false, + raw_string: false, + mode: 'enabled', + extra_perms: { 'u/viewer': true }, + workspace_id: WORKSPACE, + edited_by: 'admin', + edited_at: '2026-05-22T09:00:00Z', + permissioned_as: 'u/admin', + summary: 'old route', + description: 'keep route description' + } as any) + + await callGlobalTool('write_trigger', { + kind: 'http', + config: { + path: 'f/routes/api', + script_path: 'f/flows/new', + is_flow: true, + route_path: 'api/new', + http_method: 'get', + authentication_method: 'windmill', + is_static_website: false + } + }) + + const draft = UserDraft.get('trigger_http', 'f/routes/api', { workspace: WORKSPACE }) + expect(draft).toMatchObject({ + path: 'f/routes/api', + script_path: 'f/flows/new', + is_flow: true, + route_path: 'api/new', + http_method: 'get', + authentication_method: 'windmill', + extra_perms: { 'u/viewer': true }, + permissioned_as: 'u/admin', + summary: 'old route', + description: 'keep route description' + }) + expect(draft).not.toMatchObject({ + workspace_id: expect.anything(), + edited_by: expect.anything() + }) + }) + + it('seeds raw app draft metadata on first app write', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [3, 4], + draft_created_at: '2026-05-22T10:30:00Z', + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + }, + policy: { execution_mode: 'publisher' }, + custom_path: 'report', + draft: { + summary: 'saved app draft', + value: { + files: { '/src/App.tsx': 'draft content' }, + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' } + }, + policy: { execution_mode: 'anonymous' } + } + } as any) + + await callGlobalTool('write_app_file', { + path: 'f/apps/report', + file_path: '/src/New.tsx', + content: 'export default function New() { return null }' + }) + + const draft = UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE }) + expect(draft).toMatchObject({ + summary: 'saved app draft', + 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' + }) + expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({ + remoteRev: 4, + remoteDraftRev: '2026-05-22T10:30:00Z' + }) + }) + + it('summarizes local raw app drafts in read_workspace_item', async () => { + UserDraft.save( + 'raw_app', + 'f/apps/local', + { + summary: 'local app', + files: { '/src/App.tsx': 'const frontendSecret = "do-not-dump"' }, + runnables: { + main: { + type: 'inline', + inlineScript: { + language: 'bun', + content: 'const backendSecret = "do-not-dump"' + } + } + }, + data: { tables: ['orders'] } + }, + { workspace: WORKSPACE } + ) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'app', + path: 'f/apps/local' + }) + const item = JSON.parse(raw) + + expect(raw).not.toContain('frontendSecret') + expect(raw).not.toContain('backendSecret') + expect(item).toMatchObject({ + type: 'app', + path: 'f/apps/local', + summary: 'local app', + isDraft: true, + value: { + frontend: [{ path: '/src/App.tsx', size: 'const frontendSecret = "do-not-dump"'.length }], + backend: [ + expect.objectContaining({ + key: 'main', + name: 'main', + type: 'inline', + language: 'bun', + contentSize: 'const backendSecret = "do-not-dump"'.length + }) + ], + data: { tables: ['orders'] } + } + }) + expect(item.value.backend[0]).not.toHaveProperty('content') + }) + + it('summarizes backend raw app drafts from the same source as file reads', async () => { + const appWithDraft = { + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: ['deployed'] } + }, + draft: { + summary: 'saved app draft', + value: { + files: { + '/src/App.tsx': 'draft content', + '/src/DraftOnly.tsx': 'draft-only content' + }, + runnables: { + main: { + type: 'inline', + inlineScript: { + language: 'bun', + content: 'export async function main() { return "draft" }' + } + } + }, + data: { tables: ['draft'] } + } + } + } + vi.mocked(AppService.getAppByPathWithDraft) + .mockResolvedValueOnce(appWithDraft as any) + .mockResolvedValueOnce(appWithDraft as any) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'app', + path: 'f/apps/report' + }) + const item = JSON.parse(raw) + + expect(raw).not.toContain('draft-only content') + expect(item).toMatchObject({ + type: 'app', + path: 'f/apps/report', + summary: 'saved app draft', + value: { + frontend: [ + { path: '/src/App.tsx', size: 'draft content'.length }, + { path: '/src/DraftOnly.tsx', size: 'draft-only content'.length } + ], + backend: [ + expect.objectContaining({ + key: 'main', + name: 'main', + type: 'inline', + language: 'bun', + contentSize: 'export async function main() { return "draft" }'.length + }) + ], + data: { tables: ['draft'] } + }, + isDraft: false + }) + + await expect( + callGlobalTool('read_app_file', { + path: 'f/apps/report', + file_path: '/src/DraftOnly.tsx' + }) + ).resolves.toBe('draft-only content') + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('reads raw app files without creating a local draft', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + 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: [] } + } + } + } as any) + + await expect( + callGlobalTool('read_app_file', { + path: 'f/apps/report', + file_path: '/src/App.tsx' + }) + ).resolves.toBe('draft content') + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('does not persist a raw app draft when patch_app_file validation fails', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + } + } as any) + + await expect( + callGlobalTool('patch_app_file', { + path: 'f/apps/report', + file_path: '/src/App.tsx', + old_string: 'missing content', + new_string: 'replacement', + replace_all: false + }) + ).rejects.toThrow() + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('does not persist a raw app draft when delete_app_file validation fails', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: {}, + data: { tables: [] } + } + } as any) + + await expect( + callGlobalTool('delete_app_file', { + path: 'f/apps/report', + file_path: '/src/Missing.tsx' + }) + ).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".') + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + + it('does not persist a raw app draft when delete_app_runnable validation fails', async () => { + vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + path: 'f/apps/report', + summary: 'deployed app', + versions: [5], + value: { + files: { '/src/App.tsx': 'deployed content' }, + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: [] } + } + } as any) + + await expect( + callGlobalTool('delete_app_runnable', { + path: 'f/apps/report', + key: 'missing' + }) + ).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".') + expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + }) + it('fills an empty rawscript module through set_flow_module_code', async () => { await callGlobalTool('write_flow', { path: 'f/flows/empty-module', @@ -138,7 +1039,7 @@ describe('global AI tools', () => { module_id: 'empty_step', code }) - ).resolves.toContain('Updated AI draft flow') + ).resolves.toContain('Updated local draft flow') await expect( callGlobalTool('read_flow_module_code', { @@ -239,6 +1140,36 @@ describe('global AI tools', () => { }) }) +describe('prepareGlobalSystemMessage', () => { + it('keeps global chat draft instructions concise and user-facing', () => { + const message = prepareGlobalSystemMessage() + const content = message.content + + expect(content).toContain('Draft tools create or update local drafts only') + expect(content).toContain( + 'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft' + ) + expect(content).not.toContain('AI draft') + expect(content).not.toContain('UserDraft') + expect(content).not.toContain('localStorage') + expect(content).not.toContain('frontend AI draft store') + }) + + it('exposes separate tools for discarding drafts and deleting workspace items', () => { + const discard = getGlobalTool('discard_local_draft') + const deleteItem = getGlobalTool('delete_workspace_item') + + expect(discard.def.function.description).toBe( + 'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' + ) + expect(deleteItem.def.function.description).toBe( + 'Delete a deployed workspace item. Mutates the workspace.' + ) + expect(discard.requiresConfirmation).toBe(true) + expect(deleteItem.requiresConfirmation).toBe(true) + }) +}) + describe('prepareGlobalUserMessage', () => { it('includes selected workspace item references without contents', () => { const message = prepareGlobalUserMessage('Update these items', [ diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 6beabd21d6..11c94e677b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -18,11 +18,16 @@ import { import { $ScriptLang } from '$lib/gen/schemas.gen' import type { AppWithLastVersion, + CreateResource, + CreateVariable, Flow, FlowValue, ListableApp, ListableResource, ListableVariable, + NewSchedule, + NewScript, + Resource, Schedule, Script, ScriptLang @@ -34,6 +39,7 @@ import { 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, @@ -41,12 +47,7 @@ import { validateEditableFlowJson } from '../flow/editableFlowJson' import { createInlineScriptSession } from '../flow/inlineScriptsUtils' -import { - getFlowPrompt, - getRawAppPrompt, - getResourcePrompt, - getScriptPrompt -} from '$system_prompts' +import { getFlowPrompt, getRawAppPrompt, getResourcePrompt, getScriptPrompt } from '$system_prompts' import type { ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam @@ -61,6 +62,8 @@ import { type ToolDisplayAction } from '../shared' import type { ContextElement } from '../context' +import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte' +import { emptySchema } from '$lib/utils' import { resourceRequestSchema, scheduleRequestSchema, @@ -69,15 +72,28 @@ import { } from '../workspaceToolsZod.gen' import { getWorkspaceItemKey, - globalDraftStore, TRIGGER_KINDS, type AppDraftValue, type FlowDraftValue, + type ResourceDraftState, type TriggerKind, + type TriggerRequestBody, + type VariableDraftState, type WorkspaceItem, type WorkspaceItemType -} from './draftStore.svelte' +} from './workspaceItems' import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' +import { + clearEphemeralSecretVariableDraftValue, + deleteGlobalDraft, + getEphemeralSecretVariableDraftValue, + getGlobalDraft, + getGlobalDraftStoragePath, + listGlobalDrafts, + saveGlobalAppDraft, + setEphemeralSecretVariableDraftValue, + triggerKindToUserDraftKind +} from './userDraftAdapter' const ITEM_TYPES = [ 'script', @@ -154,9 +170,7 @@ const readWorkspaceItemSchema = z.object({ }) const writeScriptSchema = z.object({ - path: z - .string() - .describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'), + path: z.string().describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'), summary: z.string().optional().describe('Short human-readable summary.'), language: scriptLangSchema.describe('Script language.'), content: z.string().describe('Full script source code.') @@ -178,7 +192,7 @@ const setFlowModuleCodeSchema = z.object({ .describe( 'Module id whose inline rawscript content to overwrite. Must reference a module whose value.type is "rawscript". Use patch_flow_json for structural changes.' ), - code: z.string().describe('New script source. Replaces the module\'s value.content entirely.') + code: z.string().describe("New script source. Replaces the module's value.content entirely.") }) // Flow structure fields are taken as JSON strings rather than typed objects @@ -187,9 +201,7 @@ const setFlowModuleCodeSchema = z.object({ // rejects those keywords ("Unknown name $ref/$defs"). Same trick as // set_flow_json in chat/flow/core.ts. const writeFlowSchema = z.object({ - path: z - .string() - .describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'), + path: z.string().describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'), summary: z.string().optional().describe('Short human-readable summary.'), modules: z.string().describe('JSON string containing the complete flow modules array.'), schema: z @@ -300,6 +312,14 @@ const deleteWorkspaceItemSchema = z.object({ .describe('Required when type is trigger. Identifies which trigger service to call.') }) +const discardLocalDraftSchema = z.object({ + type: itemTypeSchema, + path: z.string().describe('Workspace path of the local draft to discard.'), + trigger_kind: triggerKindSchema + .optional() + .describe('Required when type is trigger. Must match the draft trigger kind.') +}) + const deployWorkspaceItemSchema = z.object({ type: itemTypeSchema, path: z.string().describe('Workspace path of the draft to deploy.'), @@ -436,7 +456,12 @@ const deleteAppRunnableSchema = z.object({ key: z.string().describe('Key of the backend runnable to remove.') }) -const FRAMEWORK_KEYS = ['react19', 'react18', 'svelte5', 'vue'] as const satisfies readonly FrameworkKey[] +const FRAMEWORK_KEYS = [ + 'react19', + 'react18', + 'svelte5', + 'vue' +] as const satisfies readonly FrameworkKey[] const initAppSchema = z.object({ path: z @@ -467,28 +492,31 @@ const initAppSchema = z.object({ const GLOBAL_SYSTEM_PROMPT = `You are Windmill's global workspace assistant. -You can inspect workspace scripts, flows, schedules, triggers, resources, variables, and apps, then create draft changes in the frontend AI draft store. +Use tools to inspect workspace items and create local drafts for scripts, flows, schedules, triggers, resources, variables, and raw apps. -Important rules: -- write_{script,flow,schedule,trigger,resource,variable} create or overwrite drafts. They do not save, deploy, or mutate workspace items. -- edit_script and patch_flow_json apply small exact-text edits and save the result as a draft. Prefer them for localized changes; use write_* for large rewrites. -- For flows specifically: read_workspace_item and patch_flow_json work on a COMPACT view where rawscript module bodies are replaced with the placeholder "inline_script.". Use read_flow_module_code / set_flow_module_code to inspect or overwrite an inline script body; use patch_flow_json for structural edits. -- deploy_workspace_item persists a draft to the workspace via the real backend create/update API and removes the draft. Requires user confirmation. Only call after the user has reviewed the draft and explicitly asked to deploy. -- delete_workspace_item permanently removes a workspace item (and any matching draft). Irreversible. Requires user confirmation. Only call when the user has explicitly asked to delete. -- Use list_workspace_items before broad reads. -- Use read_workspace_item before overwriting an existing item, unless the user already provided the complete current item. For triggers, pass trigger_kind. -- Variable values are NEVER returned by read_workspace_item or list_workspace_items — only metadata (path, description, is_secret). The model cannot read secret values, by design. -- For resources that need secrets, write a Variable first (with is_secret: true), then in the resource value reference it as "$var:path/to/variable". When deploying both, deploy the variable before the resource. -- Use search_resource_types before write_resource to discover the resource_type name and the JSON Schema its value must match. -- Use get_instructions before writing a script, flow, resource, or app. For scripts, pass the target language; when modifying, use the language from the item you read. -- Schedules, triggers, and variables do not need get_instructions — their tool schemas describe every field. -- When a required decision is ambiguous, use askUserQuestion with two to six clear answer strings instead of guessing. -- A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions. -- Apps (raw apps): use list_workspace_items with types: ['app'] to find them, read_workspace_item with type 'app' for a metadata summary (file paths + runnable list, no contents), then read_app_file to read individual files. Edit with write_app_file / patch_app_file / delete_app_file for frontend files and write_app_runnable / delete_app_runnable for backend runnables. Frontend file paths start with "/" (e.g. /index.tsx). Backend inline runnables are addressed as "backend//main.{ts|py}". /wmill.d.ts is generated and cannot be written. -- To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user — do not silently default to react19, even though it is the recommended choice. -- Apps cannot be deployed from chat. The app editor bundles JS/CSS before save; tell the user to open the app editor to deploy app drafts. -- Keep context targeted. Do not read unrelated items. -- Be explicit with the user when you create or update a draft.` +Rules: +- Draft tools create or update local 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 refers to the open editor, use the item marked isLiveDraft=true. +- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a local draft to the workspace. +- Use discard_local_draft to remove an unsaved local 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. +- Ask the user when a required decision is ambiguous. +- Keep context targeted. + +Flows: +- read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". +- Use read_flow_module_code and set_flow_module_code for inline script bodies. +- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites. + +Raw apps: +- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents. +- Use write_app_file, patch_app_file, and delete_app_file for frontend files. +- Use write_app_runnable and delete_app_runnable for backend runnables. +- Use init_app only after confirming framework, path, and summary with the user. +- Apps cannot be deployed from chat; tell the user to open the app editor.` const DEFAULT_LIST_TYPES = ['script', 'flow'] as const satisfies readonly WorkspaceItemType[] @@ -548,6 +576,16 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { } } + if (item.type === 'app' && item.value && typeof item.value === 'object' && 'files' in item.value) { + return { + type: 'app', + path: item.path, + summary: item.summary, + value: summarizeAppValue(item.value as AppDraftValue), + isDraft: item.isDraft + } + } + if (item.type !== 'flow' || !item.value) return item const flowDraft = item.value as FlowDraftValue const session = createInlineScriptSession() @@ -658,7 +696,7 @@ function buildPersistedRunnable( { type: 'static', value: v, fieldType: 'object' } ]) ) - : existing?.fields ?? {} + : (existing?.fields ?? {}) if (input.type === 'inline') { if (!input.inlineScript) { @@ -711,11 +749,18 @@ type AppMetadata = { data?: any } +type LoadedAppDraftValue = { + value: AppDraftValue + meta?: UserDraftMeta +} + function summarizeAppValue(value: AppDraftValue): AppMetadata { - const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map(([path, content]) => ({ - path, - size: typeof content === 'string' ? content.length : 0 - })) + const frontend: AppFrontendFileMetadata[] = Object.entries(value.files).map( + ([path, content]) => ({ + path, + size: typeof content === 'string' ? content.length : 0 + }) + ) const backend: AppBackendRunnableMetadata[] = Object.entries(value.runnables).map( ([key, runnable]) => { const converted = convertPersistedToBackendRunnable(runnable as PersistedRunnable, key) @@ -815,32 +860,73 @@ function getInlineRunnableContent( return { content: runnable.inlineScript?.content ?? '', runnable } } -async function loadAppDraftValue(path: string, workspace: string): Promise { - const draft = globalDraftStore.getDraft(workspace, 'app', path) +function normalizeRawAppData(value: Record): AppDraftValue['data'] { + if (value.data?.creation) { + return { + tables: value.data.tables ?? [], + datatable: value.data.creation.datatable, + schema: value.data.creation.schema + } + } + if (value.data) { + return value.data + } + if (value.datatables) { + return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables } + } + if (value.dataTableRefs) { + return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs } + } + return { ...DEFAULT_RAW_APP_DATA } +} + +function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { + const value = (app.value ?? {}) as Record + return { + summary: app.summary ?? '', + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: normalizeRawAppData(value), + policy: app.policy ?? fallback?.policy, + custom_path: app.custom_path ?? fallback?.custom_path + } +} + +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 + } +} + +async function loadAppValueForRead(path: string, workspace: string): Promise { + const draft = getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return draft.value as AppDraftValue } - const app = await AppService.getAppByPath({ workspace, path }) - const value = (app.value ?? {}) as Partial - return { - summary: app.summary, - files: { ...(value.files ?? {}) }, - runnables: { ...(value.runnables ?? {}) }, - data: value.data, - policy: app.policy as any, - custom_path: app.custom_path - } + const app = await AppService.getAppByPathWithDraft({ workspace, path }) + return appSourceToDraftValue(app.draft ?? app, app) } -function saveAppDraft(workspace: string, path: string, value: AppDraftValue): WorkspaceItem { - return globalDraftStore.setDraft(workspace, { - type: 'app', - path, - summary: value.summary, - value, - isDraft: true - }) +async function loadAppDraftValue(path: string, workspace: string): Promise { + const draft = getGlobalDraft(workspace, 'app', path) + if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { + return { value: draft.value as AppDraftValue } + } + + const app = await AppService.getAppByPathWithDraft({ workspace, path }) + const value = appSourceToDraftValue(app.draft ?? app, app) + return { value, meta: appDraftMeta(app) } +} + +function saveAppDraft( + workspace: string, + path: string, + value: AppDraftValue, + meta?: UserDraftMeta +): WorkspaceItem { + return saveGlobalAppDraft(workspace, path, value, meta) } type TriggerLike = { path: string; summary?: string | null } @@ -863,11 +949,7 @@ function triggerToItem( type TriggerService = { exists(args: { workspace: string; path: string }): Promise get(args: { workspace: string; path: string }): Promise - list(args: { - workspace: string - pathStart?: string - perPage?: number - }): Promise + list(args: { workspace: string; pathStart?: string; perPage?: number }): Promise create(args: { workspace: string; requestBody: any }): Promise update(args: { workspace: string; path: string; requestBody: any }): Promise delete(args: { workspace: string; path: string }): Promise @@ -948,31 +1030,6 @@ const triggerServices: Record = { } } -async function workspaceItemExists( - type: WorkspaceItemType, - path: string, - workspace: string, - triggerKind?: TriggerKind -): Promise { - switch (type) { - case 'script': - return ScriptService.existsScriptByPath({ workspace, path }) - case 'flow': - return FlowService.existsFlowByPath({ workspace, path }) - case 'schedule': - return ScheduleService.existsSchedule({ workspace, path }) - case 'trigger': - if (!triggerKind) return false - return triggerServices[triggerKind].exists({ workspace, path }) - case 'resource': - return ResourceService.existsResource({ workspace, path }) - case 'variable': - return VariableService.existsVariable({ workspace, path }) - case 'app': - return AppService.existsApp({ workspace, path }) - } -} - async function readWorkspaceItem( type: WorkspaceItemType, path: string, @@ -997,7 +1054,7 @@ async function readWorkspaceItem( ) case 'resource': return resourceToItem( - await ResourceService.getResource({ workspace, path }) as ListableResource, + (await ResourceService.getResource({ workspace, path })) as ListableResource, true ) case 'variable': @@ -1008,18 +1065,13 @@ async function readWorkspaceItem( ) case 'app': { // Returns lightweight metadata only — file/runnable contents come via read_app_file. - const app = await AppService.getAppByPath({ workspace, path }) - const value = (app.value ?? {}) as Partial - const metadata = summarizeAppValue({ - summary: app.summary, - files: value.files ?? {}, - runnables: value.runnables ?? {}, - data: value.data - }) + const app = await AppService.getAppByPathWithDraft({ workspace, path }) + const value = appSourceToDraftValue(app.draft ?? app) + const metadata = summarizeAppValue(value) return { type: 'app', path: app.path, - summary: app.summary, + summary: value.summary, value: metadata as unknown as AppDraftValue, isDraft: false } @@ -1141,7 +1193,7 @@ function getFlowInstructions(): string { - \`read_workspace_item\` and \`patch_flow_json\` operate on a **compact view** of the flow: every rawscript module's \`value.content\` is replaced with the placeholder \`"inline_script."\` so inline script bodies don't bloat tool I/O. Schema, groups, preprocessor_module and failure_module are all shown in this view. - Inline rawscript content is **not** part of the JSON \`patch_flow_json\` sees. Edits to inline bodies happen via dedicated tools: - \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module. - - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the AI draft. + - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the local draft. - Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups. Use \`set_flow_module_code\` for changes inside a specific rawscript body. - \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments use **non-compact** flow modules (rawscript content is the actual code, not a placeholder). @@ -1207,7 +1259,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( getInstructionsSchema, 'get_instructions', - 'Get Windmill authoring instructions for scripts, flows, resources, or apps. For scripts, pass the target language.' + 'Get authoring guidance for scripts, flows, resources, or apps.' ), fn: async ({ args, toolId, toolCallbacks }) => { const parsed = getInstructionsSchema.parse(args) @@ -1223,7 +1275,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( askUserQuestionSchema, 'askUserQuestion', - 'Ask the user a multiple-choice question and wait for their selection before continuing.' + 'Ask the user a multiple-choice question.' ), fn: async ({ args, toolId, toolCallbacks }) => { const parsed = askUserQuestionSchema.parse(args) @@ -1277,7 +1329,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( listWorkspaceItemsSchema, 'list_workspace_items', - 'List workspace items (scripts, flows, schedules, triggers, resources, variables, apps) and AI drafts. Returns metadata only (no value). Defaults to scripts and flows.' + 'List workspace items and local drafts. Returns metadata only.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = listWorkspaceItemsSchema.parse(args) @@ -1296,8 +1348,9 @@ export const globalTools: Tool<{}>[] = [ byKey.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item) } - for (const draft of globalDraftStore.listDrafts(workspace)) { + for (const draft of listGlobalDrafts(workspace)) { if (!types.includes(draft.type)) continue + if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), { ...draft, value: undefined @@ -1318,7 +1371,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readWorkspaceItemSchema, 'read_workspace_item', - 'Read one workspace item or AI draft by type and path. Returns the full workspace item including value.' + 'Read one workspace item or local draft.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = readWorkspaceItemSchema.parse(args) @@ -1327,15 +1380,10 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: message, error: message }) return JSON.stringify({ success: false, error: message }) } - const draft = globalDraftStore.getDraft( - workspace, - parsed.type, - parsed.path, - parsed.trigger_kind - ) + const draft = getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) if (draft) { toolCallbacks.setToolStatus(toolId, { - content: `Read AI draft ${parsed.type} "${parsed.path}"` + content: `Read local draft ${parsed.type} "${parsed.path}"` }) return JSON.stringify(serializeWorkspaceItemForRead(draft), null, 2) } @@ -1343,12 +1391,7 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: `Reading ${parsed.type} "${parsed.path}"...` }) - const item = await readWorkspaceItem( - parsed.type, - parsed.path, - workspace, - parsed.trigger_kind - ) + const item = await readWorkspaceItem(parsed.type, parsed.path, workspace, parsed.trigger_kind) toolCallbacks.setToolStatus(toolId, { content: `Read ${parsed.type} "${parsed.path}"` }) return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2) } @@ -1357,32 +1400,18 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeScriptSchema, 'write_script', - 'Create or overwrite an AI draft script. Does not save or deploy. Read the existing script first when overwriting.' + 'Create or overwrite a local draft script.' ), showDetails: true, streamArguments: true, showFade: true, fn: async (ctx) => { const parsed = writeScriptSchema.parse(ctx.args) - return writeDraft( - { - type: 'script', - path: parsed.path, - summary: parsed.summary, - language: parsed.language, - value: parsed.content, - isDraft: true - }, - ctx - ) + return writeScriptDraft(parsed, ctx) } }, { - def: createToolDef( - writeFlowSchema, - 'write_flow', - 'Create or overwrite an AI draft flow. Does not save or deploy. Read the existing flow first when overwriting. Uses the same flow-structure arguments as set_flow_json plus path and summary.' - ), + def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a local draft flow.'), showDetails: true, streamArguments: true, showFade: true, @@ -1398,13 +1427,11 @@ export const globalTools: Tool<{}>[] = [ failure_module: parseOptionalJsonArg(parsed.failure_module, 'failure_module'), groups: parseOptionalJsonArg(parsed.groups, 'groups') }) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path: parsed.path, summary: parsed.summary, - value: editableFlowToDraftValue(editable), - isDraft: true + flow: editableFlowToDraftValue(editable) }, ctx ) @@ -1414,7 +1441,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeScheduleSchema, 'write_schedule', - 'Create or overwrite an AI draft schedule. Does not save or deploy. Provide script_path and is_flow to point to the runnable.', + 'Create or overwrite a local draft schedule.', { strict: false } ), showDetails: true, @@ -1422,23 +1449,14 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeScheduleSchema.parse(ctx.args) - return writeDraft( - { - type: 'schedule', - path: parsed.path, - summary: parsed.summary ?? undefined, - value: parsed, - isDraft: true - }, - ctx - ) + return writeScheduleDraft(parsed, ctx) } }, { def: createToolDef( writeTriggerSchema, 'write_trigger', - 'Create or overwrite an AI draft trigger. Does not save or deploy. Provide kind plus the kind-specific config (including path, script_path, is_flow).', + 'Create or overwrite a local draft trigger.', { strict: false } ), showDetails: true, @@ -1446,25 +1464,14 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeTriggerSchema.parse(ctx.args) - const config = parsed.config as { path: string; summary?: string | null } - return writeDraft( - { - type: 'trigger', - triggerKind: parsed.kind, - path: config.path, - summary: config.summary ?? undefined, - value: parsed.config, - isDraft: true - }, - ctx - ) + return writeTriggerDraft(parsed, ctx) } }, { def: createToolDef( editScriptSchema, 'edit_script', - 'Find/replace exact text in a script. Edits the existing draft if one exists, otherwise reads the workspace script and saves the result as a new draft.' + 'Find/replace exact text in a script and save a local draft.' ), showDetails: true, streamArguments: true, @@ -1478,7 +1485,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( patchFlowJsonSchema, 'patch_flow_json', - 'Find/replace exact text in a flow value (compact JSON). Edits the existing draft if one exists, otherwise reads the workspace flow and saves the result as a new draft. Use write_flow for larger structural rewrites.' + 'Find/replace exact text in compact flow JSON and save a local draft.' ), showDetails: true, streamArguments: true, @@ -1492,13 +1499,13 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deployWorkspaceItemSchema, 'deploy_workspace_item', - 'Persist an AI draft to the workspace by calling the real backend create/update API. This MUTATES the workspace. Requires user confirmation.', + 'Deploy a local draft to the workspace. Mutates the workspace.', { strict: false } ), showDetails: true, showFade: true, requiresConfirmation: true, - confirmationMessage: 'Deploy AI draft to workspace', + confirmationMessage: 'Deploy local draft to workspace', fn: async (ctx) => { const parsed = deployWorkspaceItemSchema.parse(ctx.args) return deployDraft(parsed, ctx) @@ -1508,7 +1515,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deleteWorkspaceItemSchema, 'delete_workspace_item', - 'Permanently delete a workspace item by path. This MUTATES the workspace and is irreversible. Also clears any matching AI draft. Requires user confirmation.' + 'Delete a deployed workspace item. Mutates the workspace.' ), showDetails: true, showFade: true, @@ -1519,11 +1526,26 @@ export const globalTools: Tool<{}>[] = [ return deleteWorkspaceItem(parsed, ctx) } }, + { + def: createToolDef( + discardLocalDraftSchema, + 'discard_local_draft', + 'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' + ), + showDetails: true, + showFade: true, + requiresConfirmation: true, + confirmationMessage: 'Discard local draft', + fn: async (ctx) => { + const parsed = discardLocalDraftSchema.parse(ctx.args) + return discardLocalDraft(parsed, ctx) + } + }, { def: createToolDef( writeResourceSchema, 'write_resource', - 'Create or overwrite an AI draft resource. Does not save or deploy. Reference secret values via $var:path/to/variable; create the variable separately with write_variable.', + 'Create or overwrite a local draft resource.', { strict: false } ), showDetails: true, @@ -1531,23 +1553,14 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeResourceSchema.parse(ctx.args) - return writeDraft( - { - type: 'resource', - path: parsed.path, - summary: parsed.description, - value: parsed, - isDraft: true - }, - ctx - ) + return writeResourceDraft(parsed, ctx) } }, { def: createToolDef( writeVariableSchema, 'write_variable', - 'Create or overwrite an AI draft variable. Does not save or deploy. Use is_secret: true for secret values. After deploy, reference from a resource as $var:path/to/variable.', + 'Create or overwrite a local draft variable.', { strict: false } ), showDetails: true, @@ -1555,23 +1568,14 @@ export const globalTools: Tool<{}>[] = [ showFade: true, fn: async (ctx) => { const parsed = writeVariableSchema.parse(ctx.args) - return writeDraft( - { - type: 'variable', - path: parsed.path, - summary: parsed.description, - value: parsed, - isDraft: true - }, - ctx - ) + return writeVariableDraft(parsed, ctx) } }, { def: createToolDef( searchResourceTypesSchema, 'search_resource_types', - 'Search for resource types in the workspace by substring. Returns names, descriptions, and JSON Schemas — use this before write_resource to know what shape value should have.' + 'Search workspace resource types and schemas.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = searchResourceTypesSchema.parse(args) @@ -1600,7 +1604,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readFlowModuleCodeSchema, 'read_flow_module_code', - 'Read the inline rawscript content of one flow module by id. Reads from the AI draft when one exists, otherwise from the workspace flow. Use this instead of patch_flow_json when you only need to inspect an inline script body.' + 'Read inline script code from one flow module.' ), fn: async (ctx) => { const parsed = readFlowModuleCodeSchema.parse(ctx.args) @@ -1611,7 +1615,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( setFlowModuleCodeSchema, 'set_flow_module_code', - 'Overwrite the inline rawscript content of one flow module by id. Saves to the AI draft only — does not deploy. Use this for inline script body changes; structural changes (module ids, paths, input_transforms, branches) go through patch_flow_json.' + 'Overwrite inline script code in one flow module and save a local draft.' ), showDetails: true, streamArguments: true, @@ -1625,7 +1629,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( initAppSchema, 'init_app', - 'Initialize a new raw app draft from a framework template. Errors if an app already exists at the path or a draft is already in flight. Confirm framework, path, and summary with the user before calling — do not silently default to react19.', + 'Initialize a local draft raw app from a framework template.', { strict: false } ), showDetails: true, @@ -1639,7 +1643,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readAppFileSchema, 'read_app_file', - 'Read one frontend file or inline backend runnable script from a raw app. Use file_path "/foo.tsx" for frontend files and "backend//main.{ts|py}" for inline runnables. Prefers the AI draft when one exists.' + 'Read one raw app frontend file or inline backend runnable.' ), fn: async (ctx) => { const parsed = readAppFileSchema.parse(ctx.args) @@ -1650,7 +1654,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeAppFileSchema, 'write_app_file', - 'Create or overwrite a frontend file in an app draft. Saves to the AI draft only — does not deploy. First write snapshots the workspace app onto the draft.' + 'Create or overwrite a frontend file in a local app draft.' ), showDetails: true, streamArguments: true, @@ -1664,7 +1668,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deleteAppFileSchema, 'delete_app_file', - 'Remove a frontend file from an app draft. Saves to the AI draft only — does not deploy.' + 'Remove a frontend file from a local app draft.' ), fn: async (ctx) => { const parsed = deleteAppFileSchema.parse(ctx.args) @@ -1675,7 +1679,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( patchAppFileSchema, 'patch_app_file', - 'Find/replace exact text in a frontend file or inline backend runnable script. Saves the result to the AI draft.' + 'Find/replace exact text in a raw app file and save a local draft.' ), showDetails: true, streamArguments: true, @@ -1689,7 +1693,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeAppRunnableSchema, 'write_app_runnable', - 'Create or overwrite a backend runnable in an app draft. Saves to the AI draft only — does not deploy. Re-derives the app policy after the change.', + 'Create or overwrite a backend runnable in a local app draft.', { strict: false } ), showDetails: true, @@ -1704,7 +1708,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deleteAppRunnableSchema, 'delete_app_runnable', - 'Remove a backend runnable from an app draft. Saves to the AI draft only — does not deploy. Re-derives the app policy after the change.' + 'Remove a backend runnable from a local app draft.' ), fn: async (ctx) => { const parsed = deleteAppRunnableSchema.parse(ctx.args) @@ -1719,11 +1723,433 @@ type WriteDraftCtx = { toolCallbacks: ToolCallbacks } +type DraftConfig = Record +type ScheduleDraftConfig = NewSchedule & DraftConfig +type TriggerDraftConfig = TriggerRequestBody & DraftConfig & { path: string } + +function stripBackendMetadata(value: T): T { + const draft = structuredClone(value) + delete draft.workspace_id + delete draft.edited_by + delete draft.edited_at + delete draft.email + delete draft.error + return draft +} + +function mergeDraftConfig( + base: T | undefined, + overrides: DraftConfig, + path: string +): T { + return { + ...(base ? stripBackendMetadata(base) : {}), + ...structuredClone(overrides), + path + } as unknown as T +} + +function resourceToDraftState(resource: Resource): ResourceDraftState { + return { + path: resource.path, + description: resource.description ?? '', + args: structuredClone((resource.value ?? {}) as Record), + labels: resource.labels ?? undefined, + wsSpecific: resource.ws_specific ?? false, + resource_type: resource.resource_type + } +} + +function createResourceToDraftState( + args: CreateResource, + base?: ResourceDraftState +): ResourceDraftState { + return { + ...base, + path: args.path, + description: args.description ?? base?.description ?? '', + args: structuredClone((args.value ?? base?.args ?? {}) as Record), + labels: args.labels ?? base?.labels, + wsSpecific: args.ws_specific ?? base?.wsSpecific ?? false, + resource_type: args.resource_type ?? base?.resource_type + } +} + +function variableToDraftState(variable: ListableVariable): VariableDraftState { + return { + path: variable.path, + variable: { + value: variable.value ?? '', + is_secret: variable.is_secret, + description: variable.description ?? '' + }, + labels: variable.labels ?? undefined, + wsSpecific: variable.ws_specific ?? false, + account: variable.account, + is_oauth: variable.is_oauth, + expires_at: variable.expires_at + } +} + +function createVariableToDraftState( + args: CreateVariable, + base?: VariableDraftState +): VariableDraftState { + return { + ...base, + path: args.path, + variable: { + value: args.is_secret ? '' : args.value, + is_secret: args.is_secret, + description: args.description + }, + labels: args.labels ?? base?.labels, + wsSpecific: args.ws_specific ?? base?.wsSpecific ?? false, + account: args.account ?? base?.account, + is_oauth: args.is_oauth ?? base?.is_oauth, + expires_at: args.expires_at ?? base?.expires_at + } +} + +function syncEphemeralSecretVariableDraftValue(workspace: string, args: CreateVariable): void { + const storagePath = getGlobalDraftStoragePath(workspace, 'variable', args.path) + if (args.is_secret) { + setEphemeralSecretVariableDraftValue(workspace, storagePath, args.value) + } else { + clearEphemeralSecretVariableDraftValue(workspace, storagePath) + } +} + +function buildVariableDeployRequestBody( + workspace: string, + path: string, + draftValue: CreateVariable +): CreateVariable { + const requestBody = structuredClone(draftValue) + if (!requestBody.is_secret) return requestBody + + const storagePath = getGlobalDraftStoragePath(workspace, 'variable', path) + const secretValue = getEphemeralSecretVariableDraftValue(workspace, storagePath) + if (secretValue === undefined) { + throw new Error( + `Secret value for local draft variable "${path}" is no longer available because secret draft values are kept only in memory. Run write_variable again before deploying this secret.` + ) + } + + return { ...requestBody, value: secretValue } +} + +function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void { + ctx.toolCallbacks.setToolStatus(ctx.toolId, { + content: `Writing draft ${type} "${path}"...` + }) +} + +function getRequiredGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): WorkspaceItem { + const draft = getGlobalDraft(workspace, type, path, triggerKind) + if (!draft) { + throw new Error(`Could not read written draft ${type} "${path}".`) + } + return draft +} + +function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDraftCtx): string { + const verb = existed ? 'Updated' : 'Created' + const serializedItem = + stored.type === 'variable' || stored.type === 'flow' + ? serializeWorkspaceItemForRead(stored) + : stored + + ctx.toolCallbacks.setToolStatus(ctx.toolId, { + content: `${verb} local draft ${stored.type} "${stored.path}"`, + result: `Draft ${verb.toLowerCase()}` + }) + return JSON.stringify( + { + success: true, + message: `${verb} local draft ${stored.type} "${stored.path}". The workspace was not saved or deployed.`, + item: serializedItem + }, + null, + 2 + ) +} + +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 backendExists = existingDraft + ? false + : await ScriptService.existsScriptByPath({ workspace, path: args.path }) + + if (existingDraft) { + const draft: NewScript = { + ...structuredClone(existingDraft), + path: args.path, + summary: args.summary ?? existingDraft.summary, + content: args.content, + language: args.language + } + UserDraft.save('script', storagePath, draft, { workspace }) + } else if (backendExists) { + const existing = await ScriptService.getScriptByPathWithDraft({ + workspace, + path: args.path + }) + const base = (existing.draft ?? existing) as NewScript + const draft: NewScript = { + ...structuredClone(base), + parent_hash: existing.hash, + path: args.path, + summary: args.summary ?? base.summary, + content: args.content, + language: args.language + } + UserDraft.setDraftAndMeta( + 'script', + storagePath, + draft, + { remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at }, + { workspace } + ) + } else { + const draft: NewScript = { + path: args.path, + summary: args.summary ?? '', + description: '', + content: args.content, + schema: emptySchema(), + is_template: false, + language: args.language, + kind: 'script' + } + UserDraft.save('script', storagePath, draft, { workspace }) + } + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'script', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeFlowDraft( + args: { path: string; summary?: string; flow: FlowDraftValue }, + ctx: WriteDraftCtx +): 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) + if (draftValue.groups !== undefined && draftValue.groups !== null) { + value.groups = structuredClone(draftValue.groups) + } + + const existingDraft = UserDraft.get('flow', storagePath, { workspace }) + const backendExists = existingDraft + ? false + : await FlowService.existsFlowByPath({ workspace, path: args.path }) + + if (existingDraft) { + const draft: Flow = { + ...structuredClone(existingDraft), + path: args.path, + summary: args.summary ?? existingDraft.summary, + value, + schema: draftValue.schema ?? existingDraft.schema + } + UserDraft.save('flow', storagePath, draft, { workspace }) + } else if (backendExists) { + const [existing, latestVersion] = await Promise.all([ + FlowService.getFlowByPathWithDraft({ workspace, path: args.path }), + FlowService.getFlowLatestVersion({ workspace, path: args.path }) + ]) + const base = (existing.draft ?? existing) as Flow + const draft: Flow = { + ...structuredClone(base), + path: args.path, + summary: args.summary ?? base.summary, + value, + schema: draftValue.schema ?? base.schema + } + UserDraft.setDraftAndMeta( + 'flow', + storagePath, + draft, + { remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at }, + { workspace } + ) + } else { + const draft: Flow = { + path: args.path, + summary: args.summary ?? '', + value, + schema: draftValue.schema ?? emptySchema(), + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + } + UserDraft.save('flow', storagePath, draft, { workspace }) + } + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'flow', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'schedule', args.path) + + const existingDraft = UserDraft.get('trigger_schedule', args.path, { + workspace + }) + const backendExists = existingDraft + ? false + : await ScheduleService.existsSchedule({ workspace, path: args.path }) + + const base = existingDraft + ? existingDraft + : backendExists + ? ((await ScheduleService.getSchedule({ + workspace, + path: args.path + })) as ScheduleDraftConfig) + : undefined + const draft = mergeDraftConfig(base, args as DraftConfig, args.path) + + UserDraft.save('trigger_schedule', args.path, draft, { workspace }) + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'schedule', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeTriggerDraft( + args: { kind: TriggerKind; config: unknown }, + ctx: WriteDraftCtx +): Promise { + const { workspace } = ctx + const config = args.config as TriggerDraftConfig + const path = config.path + const itemKind = triggerKindToUserDraftKind(args.kind) + startDraftWrite(ctx, 'trigger', path) + + const existingDraft = UserDraft.get(itemKind, path, { workspace }) + const backendExists = existingDraft + ? false + : await triggerServices[args.kind].exists({ workspace, path }) + + const base = existingDraft + ? existingDraft + : backendExists + ? ((await triggerServices[args.kind].get({ workspace, path })) as TriggerDraftConfig) + : undefined + const draft = mergeDraftConfig(base, config, path) + + UserDraft.save(itemKind, path, draft, { workspace }) + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'trigger', path, args.kind), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeResourceDraft(args: CreateResource, ctx: WriteDraftCtx): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'resource', args.path) + + const existingDraft = UserDraft.get('resource', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await ResourceService.existsResource({ workspace, path: args.path }) + + if (existingDraft) { + UserDraft.save('resource', args.path, createResourceToDraftState(args, existingDraft), { + workspace + }) + } else if (backendExists) { + const existing = await ResourceService.getResource({ workspace, path: args.path }) + UserDraft.setDraftAndMeta( + 'resource', + args.path, + createResourceToDraftState(args, resourceToDraftState(existing)), + { remoteRev: existing.edited_at }, + { workspace } + ) + } else { + UserDraft.save('resource', args.path, createResourceToDraftState(args), { workspace }) + } + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'resource', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + +async function writeVariableDraft(args: CreateVariable, ctx: WriteDraftCtx): Promise { + const { workspace } = ctx + startDraftWrite(ctx, 'variable', args.path) + + const existingDraft = UserDraft.get('variable', args.path, { workspace }) + const backendExists = existingDraft + ? false + : await VariableService.existsVariable({ workspace, path: args.path }) + + if (existingDraft) { + UserDraft.save('variable', args.path, createVariableToDraftState(args, existingDraft), { + workspace + }) + } else if (backendExists) { + const existing = await VariableService.getVariable({ + workspace, + path: args.path, + decryptSecret: false + }) + UserDraft.setDraftAndMeta( + 'variable', + args.path, + createVariableToDraftState(args, variableToDraftState(existing)), + { remoteRev: existing.edited_at }, + { workspace } + ) + } else { + UserDraft.save('variable', args.path, createVariableToDraftState(args), { workspace }) + } + syncEphemeralSecretVariableDraftValue(workspace, args) + + return finishDraftWrite( + getRequiredGlobalDraft(workspace, 'variable', args.path), + existingDraft !== undefined || backendExists, + ctx + ) +} + async function loadScriptForEdit( path: string, workspace: string ): Promise<{ content: string; language: ScriptLang; summary?: string }> { - const draft = globalDraftStore.getDraft(workspace, 'script', path) + 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.`) @@ -1743,14 +2169,12 @@ async function editScript( const base = await loadScriptForEdit(path, ctx.workspace) const updated = findAndReplace(base.content, oldString, newString, replaceAll, 'script source') - return writeDraft( + return writeScriptDraft( { - type: 'script', path, summary: base.summary, language: base.language, - value: updated, - isDraft: true + content: updated }, ctx ) @@ -1760,7 +2184,7 @@ async function loadFlowDraftValue( path: string, workspace: string ): Promise<{ flow: FlowDraftValue; summary?: string }> { - const draft = globalDraftStore.getDraft(workspace, 'flow', path) + 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.`) @@ -1807,18 +2231,16 @@ async function patchFlowJson( const patchedEditable = validateEditableFlowJson(parsedValue) const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, patchedEditable, session) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path, summary: base.summary, - value: { + flow: { ...base.flow, value: newFlowValue, schema: patchedEditable.schema, groups: patchedEditable.groups - }, - isDraft: true + } }, ctx ) @@ -1865,13 +2287,11 @@ async function setFlowModuleCode( } session.set(args.module_id, args.code) const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, editable, session) - return writeDraft( + return writeFlowDraft( { - type: 'flow', path: args.path, summary: base.summary, - value: { ...base.flow, value: newFlowValue }, - isDraft: true + flow: { ...base.flow, value: newFlowValue } }, ctx ) @@ -1889,9 +2309,9 @@ async function initApp( const { workspace, toolId, toolCallbacks } = ctx const { path, summary, framework, data } = args - if (globalDraftStore.getDraft(workspace, 'app', path)) { + if (getGlobalDraft(workspace, 'app', path)) { throw new Error( - `An AI draft for app "${path}" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first.` + `A local draft for app "${path}" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first.` ) } if (await AppService.existsApp({ workspace, path })) { @@ -1927,7 +2347,7 @@ async function initApp( return JSON.stringify( { success: true, - message: `Initialized AI draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}". Use write_app_file / write_app_runnable to evolve the draft.`, + message: `Initialized local draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}". Use write_app_file / write_app_runnable to evolve the draft.`, item: stored }, null, @@ -1945,7 +2365,7 @@ async function readAppFile( content: `Reading ${target.filePath} from app "${args.path}"...` }) - const value = await loadAppDraftValue(args.path, workspace) + const value = await loadAppValueForRead(args.path, workspace) if (target.kind === 'frontend') { const content = value.files[target.filePath] @@ -1978,9 +2398,9 @@ async function writeAppFile( content: `Writing ${target.filePath} to app "${args.path}"...` }) - const value = await loadAppDraftValue(args.path, workspace) + const { value, meta } = await loadAppDraftValue(args.path, workspace) value.files = { ...value.files, [target.filePath]: args.content } - const stored = saveAppDraft(workspace, args.path, value) + const stored = saveAppDraft(workspace, args.path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Updated ${target.filePath} in app "${args.path}"`, @@ -1989,7 +2409,7 @@ async function writeAppFile( return JSON.stringify( { success: true, - message: `Updated AI draft app "${args.path}" with frontend file "${target.filePath}".`, + message: `Updated local draft app "${args.path}" with frontend file "${target.filePath}".`, item: stored }, null, @@ -2014,13 +2434,13 @@ async function deleteAppFile( content: `Deleting ${target.filePath} from app "${args.path}"...` }) - const value = await loadAppDraftValue(args.path, workspace) + const { value, meta } = 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 = saveAppDraft(workspace, args.path, value) + const stored = saveAppDraft(workspace, args.path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Removed ${target.filePath} from app "${args.path}"`, @@ -2029,7 +2449,7 @@ async function deleteAppFile( return JSON.stringify( { success: true, - message: `Removed "${target.filePath}" from AI draft app "${args.path}".`, + message: `Removed "${target.filePath}" from local draft app "${args.path}".`, item: stored }, null, @@ -2048,15 +2468,23 @@ async function patchAppFile( ctx: WriteDraftCtx ): Promise { const { workspace, toolId, toolCallbacks } = ctx - const { path, file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll } = args + const { + path, + file_path: filePath, + old_string: oldString, + new_string: newString, + replace_all: replaceAll + } = args const target = resolveAppFileTarget(filePath) if (target.kind === 'frontend') { assertNotGeneratedAppFile(target.filePath) } - toolCallbacks.setToolStatus(toolId, { content: `Patching ${target.filePath} in app "${path}"...` }) + toolCallbacks.setToolStatus(toolId, { + content: `Patching ${target.filePath} in app "${path}"...` + }) - const value = await loadAppDraftValue(path, workspace) + const { value, meta } = await loadAppDraftValue(path, workspace) let currentContent: string let runnable: PersistedRunnable | undefined @@ -2088,14 +2516,15 @@ async function patchAppFile( [target.key]: { ...runnable!, inlineScript: { - language: runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'), + language: + runnable!.inlineScript?.language ?? (target.extension === 'py' ? 'python3' : 'bun'), content: updated } } } } - const stored = saveAppDraft(workspace, path, value) + const stored = saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Patched ${target.filePath} in app "${path}"`, result: 'Draft updated' @@ -2103,7 +2532,7 @@ async function patchAppFile( return JSON.stringify( { success: true, - message: `Patched "${target.filePath}" in AI draft app "${path}".`, + message: `Patched "${target.filePath}" in local draft app "${path}".`, item: stored }, null, @@ -2112,10 +2541,7 @@ async function patchAppFile( } async function recomputeAppPolicy(value: AppDraftValue): Promise { - value.policy = (await updateRawAppPolicy( - value.runnables as any, - value.policy as any - )) as any + value.policy = (await updateRawAppPolicy(value.runnables as any, value.policy as any)) as any } async function writeAppRunnable( @@ -2128,12 +2554,12 @@ async function writeAppRunnable( content: `Writing runnable "${key}" to app "${path}"...` }) - const value = await loadAppDraftValue(path, workspace) + const { value, meta } = 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 = saveAppDraft(workspace, path, value) + const stored = saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Updated runnable "${key}" in app "${path}"`, @@ -2142,7 +2568,7 @@ async function writeAppRunnable( return JSON.stringify( { success: true, - message: `Updated AI draft app "${path}" with runnable "${key}".`, + message: `Updated local draft app "${path}" with runnable "${key}".`, item: stored }, null, @@ -2160,14 +2586,14 @@ async function deleteAppRunnable( content: `Removing runnable "${key}" from app "${path}"...` }) - const value = await loadAppDraftValue(path, workspace) + const { value, meta } = 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 = saveAppDraft(workspace, path, value) + const stored = saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Removed runnable "${key}" from app "${path}"`, @@ -2176,7 +2602,7 @@ async function deleteAppRunnable( return JSON.stringify( { success: true, - message: `Removed runnable "${key}" from AI draft app "${path}".`, + message: `Removed runnable "${key}" from local draft app "${path}".`, item: stored }, null, @@ -2196,10 +2622,7 @@ const triggerLabels: Record = { azure: 'Azure Event Grid trigger' } -function createOpenScheduleAction( - path: string, - targetKind: 'script' | 'flow' -): ToolDisplayAction { +function createOpenScheduleAction(path: string, targetKind: 'script' | 'flow'): ToolDisplayAction { return { id: `open-deployed-schedule:${path}`, type: 'open_created_resource', @@ -2246,6 +2669,41 @@ function createOpenVariableAction(path: string): ToolDisplayAction { } } +async function discardLocalDraft( + args: { type: WorkspaceItemType; path: string; trigger_kind?: TriggerKind }, + ctx: WriteDraftCtx +): Promise { + const { workspace, toolId, toolCallbacks } = ctx + const { type, path, trigger_kind: triggerKind } = args + + if (type === 'trigger' && !triggerKind) { + throw new Error('trigger_kind is required when discarding a trigger draft.') + } + + 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 local draft ${type} "${path}"`, + result: 'Draft discarded' + }) + return JSON.stringify( + { + success: true, + message: `Discarded local draft ${type} "${path}". The deployed workspace item was not changed.`, + type, + path, + triggerKind + }, + null, + 2 + ) +} + async function deployDraft( args: { type: WorkspaceItemType @@ -2268,9 +2726,9 @@ async function deployDraft( throw new Error('trigger_kind is required when deploying a trigger.') } - const draft = globalDraftStore.getDraft(workspace, type, path, triggerKind) + const draft = getGlobalDraft(workspace, type, path, triggerKind) if (!draft) { - throw new Error(`No AI draft found for ${type} "${path}".`) + 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.`) @@ -2346,7 +2804,11 @@ async function deployDraft( break } case 'variable': { - const requestBody = draft.value as any + const requestBody = buildVariableDeployRequestBody( + workspace, + path, + draft.value as CreateVariable + ) if (await VariableService.existsVariable({ workspace, path })) { await VariableService.updateVariable({ workspace, path, requestBody }) } else { @@ -2357,7 +2819,7 @@ async function deployDraft( } } - globalDraftStore.deleteDraft(workspace, type, path, triggerKind) + deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) toolCallbacks.setToolStatus(toolId, { content: `Deployed ${type} "${path}"`, @@ -2367,7 +2829,7 @@ async function deployDraft( return JSON.stringify( { success: true, - message: `Deployed AI draft ${type} "${path}" to the workspace. Draft removed from the AI draft store.`, + message: `Deployed local draft ${type} "${path}" to the workspace. Draft removed from the local draft system.`, type, path, triggerKind @@ -2416,7 +2878,7 @@ async function deleteWorkspaceItem( break } - globalDraftStore.deleteDraft(workspace, type, path, triggerKind) + deleteGlobalDraft(workspace, type, path, triggerKind) toolCallbacks.setToolStatus(toolId, { content: `Deleted ${type} "${path}"`, @@ -2425,7 +2887,7 @@ async function deleteWorkspaceItem( return JSON.stringify( { success: true, - message: `Deleted ${type} "${path}" from the workspace. Any matching AI draft was also cleared.`, + message: `Deleted ${type} "${path}" from the workspace. Any matching local draft was also cleared.`, type, path, triggerKind @@ -2435,38 +2897,6 @@ async function deleteWorkspaceItem( ) } -async function writeDraft(item: WorkspaceItem, ctx: WriteDraftCtx): Promise { - const { workspace, toolId, toolCallbacks } = ctx - toolCallbacks.setToolStatus(toolId, { - content: `Writing draft ${item.type} "${item.path}"...` - }) - - const exists = - globalDraftStore.getDraft(workspace, item.type, item.path, item.triggerKind) !== undefined || - (await workspaceItemExists(item.type, item.path, workspace, item.triggerKind)) - - const stored = globalDraftStore.setDraft(workspace, item) - const serializedItem = - stored.type === 'variable' || stored.type === 'flow' - ? serializeWorkspaceItemForRead(stored) - : stored - - const verb = exists ? 'Updated' : 'Created' - toolCallbacks.setToolStatus(toolId, { - content: `${verb} AI draft ${item.type} "${item.path}"`, - result: `Draft ${verb.toLowerCase()}` - }) - return JSON.stringify( - { - success: true, - message: `${verb} AI draft ${item.type} "${item.path}". The workspace was not saved or deployed.`, - item: serializedItem - }, - null, - 2 - ) -} - export function prepareGlobalSystemMessage( customPrompt?: string ): ChatCompletionSystemMessageParam { 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 f79bbd2a4e..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,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { Flow, NewScript, Script } from '$lib/gen/types.gen' import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests' -import type { WorkspaceItem } from './draftStore.svelte' +import type { WorkspaceItem } from './workspaceItems' describe('global AI deploy request builders', () => { it('preserves existing script metadata while replacing draft-controlled fields', () => { diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts index c9b2630c51..9e779779f6 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts @@ -1,5 +1,5 @@ import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen' -import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte' +import type { FlowDraftValue, WorkspaceItem } from './workspaceItems' type ScriptWithDeployMetadata = Script & Partial> diff --git a/frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts b/frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts deleted file mode 100644 index c0fb8985f5..0000000000 --- a/frontend/src/lib/components/copilot/chat/global/draftStore.svelte.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { - AzureTriggerData, - CreateResource, - CreateVariable, - FlowValue, - GcpTriggerData, - NewHttpTrigger, - NewKafkaTrigger, - NewMqttTrigger, - NewNatsTrigger, - NewPostgresTrigger, - NewSchedule, - NewSqsTrigger, - NewWebsocketTrigger, - Policy, - ScriptLang -} from '$lib/gen/types.gen' - -/** - * Flow draft value. Mirrors what the backend's create/update flow API expects - * — the OpenFlow value, plus the inputs schema and (optional) groups. - * - * Schema and groups are split out from FlowValue intentionally so that - * deploy_workspace_item can preserve them through the draft → workspace - * round-trip; an earlier version dropped them on every deploy. - */ -export type FlowDraftValue = { - value: FlowValue - schema?: Record | null - groups?: NonNullable | null -} - -export const TRIGGER_KINDS = [ - 'http', - 'websocket', - 'kafka', - 'nats', - 'postgres', - 'mqtt', - 'sqs', - 'gcp', - 'azure' -] as const - -export type TriggerKind = (typeof TRIGGER_KINDS)[number] - -export type TriggerRequestBody = - | NewHttpTrigger - | NewWebsocketTrigger - | NewKafkaTrigger - | NewNatsTrigger - | NewPostgresTrigger - | NewMqttTrigger - | NewSqsTrigger - | GcpTriggerData - | AzureTriggerData - -export type WorkspaceItemType = - | 'script' - | 'flow' - | 'schedule' - | 'trigger' - | 'resource' - | 'variable' - | 'app' - -export type AppDraftValue = { - summary?: string - files: Record - runnables: Record - data?: any - policy?: Policy - custom_path?: string -} - -export type WorkspaceItem = { - type: WorkspaceItemType - path: string - summary?: string - language?: ScriptLang - triggerKind?: TriggerKind - value?: - | string - | FlowDraftValue - | NewSchedule - | TriggerRequestBody - | CreateResource - | CreateVariable - | AppDraftValue - isDraft: boolean -} - -export function getWorkspaceItemKey( - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind -): string { - if (type === 'trigger') { - return `trigger:${triggerKind ?? ''}:${path}` - } - return `${type}:${path}` -} - -function clone(value: T): T { - return structuredClone($state.snapshot(value)) as T -} - -class GlobalDraftStore { - private drafts = $state>>({}) - - private getWorkspaceDrafts(workspace: string): Record { - return this.drafts[workspace] ?? {} - } - - private ensureWorkspaceDrafts(workspace: string): Record { - if (!this.drafts[workspace]) { - this.drafts[workspace] = {} - } - return this.drafts[workspace] - } - - listDrafts(workspace: string): WorkspaceItem[] { - return Object.values(this.getWorkspaceDrafts(workspace)).map(clone) - } - - getDraft( - workspace: string, - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind - ): WorkspaceItem | undefined { - const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)] - return draft ? clone(draft) : undefined - } - - setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem { - const stored: WorkspaceItem = { ...clone(item), isDraft: true } - this.ensureWorkspaceDrafts(workspace)[ - getWorkspaceItemKey(item.type, item.path, item.triggerKind) - ] = stored - return clone(stored) - } - - deleteDraft( - workspace: string, - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind - ): void { - const drafts = this.drafts[workspace] - if (!drafts) return - - delete drafts[getWorkspaceItemKey(type, path, triggerKind)] - if (Object.keys(drafts).length === 0) { - delete this.drafts[workspace] - } - } - - clearDrafts(workspace: string): void { - delete this.drafts[workspace] - } - - getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'script', path) - } - - getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'flow', path) - } - - getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'schedule', path) - } - - getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'trigger', path, kind) - } - - getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'resource', path) - } - - getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'variable', path) - } - - getAppDraft(workspace: string, path: string): WorkspaceItem | undefined { - return this.getDraft(workspace, 'app', path) - } -} - -export const globalDraftStore = new GlobalDraftStore() diff --git a/frontend/src/lib/components/copilot/chat/global/draftStore.test.ts b/frontend/src/lib/components/copilot/chat/global/draftStore.test.ts deleted file mode 100644 index a05126a568..0000000000 --- a/frontend/src/lib/components/copilot/chat/global/draftStore.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { globalDraftStore } from './draftStore.svelte' - -const WORKSPACE_A = 'draft-store-test-a' -const WORKSPACE_B = 'draft-store-test-b' - -function clearTestDrafts() { - globalDraftStore.clearDrafts(WORKSPACE_A) - globalDraftStore.clearDrafts(WORKSPACE_B) -} - -describe('globalDraftStore', () => { - beforeEach(clearTestDrafts) - - it('lists and reads drafts only from the requested workspace', () => { - globalDraftStore.setDraft(WORKSPACE_A, { - type: 'script', - path: 'f/shared/path', - language: 'bun', - value: 'export async function main() {}', - isDraft: true - }) - - expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe( - 'export async function main() {}' - ) - expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined() - expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1) - expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0) - }) - - it('deletes and clears drafts only from the requested workspace', () => { - globalDraftStore.setDraft(WORKSPACE_A, { - type: 'flow', - path: 'f/shared/path', - value: { value: { modules: [] }, schema: null, groups: null }, - isDraft: true - }) - globalDraftStore.setDraft(WORKSPACE_B, { - type: 'flow', - path: 'f/shared/path', - value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null }, - isDraft: true - }) - - globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path') - - expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined() - expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined() - - globalDraftStore.clearDrafts(WORKSPACE_B) - - expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0) - expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0) - }) -}) diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts new file mode 100644 index 0000000000..9a6d9125f4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -0,0 +1,395 @@ +import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' +import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' +import { + UserDraft, + type UserDraftEntry, + type UserDraftItemKind, + type UserDraftMeta +} from '$lib/userDraft.svelte' +import { + getWorkspaceItemKey, + type AppDraftValue, + type ResourceDraftState, + type TriggerKind, + type TriggerRequestBody, + type VariableDraftState, + type WorkspaceItem, + type WorkspaceItemType +} from './workspaceItems' + +const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = { + http: 'trigger_http', + websocket: 'trigger_websocket', + kafka: 'trigger_kafka', + nats: 'trigger_nats', + postgres: 'trigger_postgres', + mqtt: 'trigger_mqtt', + sqs: 'trigger_sqs', + gcp: 'trigger_gcp', + azure: 'trigger_azure' +} as const satisfies Record + +const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries( + Object.entries(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND).map(([triggerKind, draftKind]) => [ + draftKind, + triggerKind + ]) +) as Partial> + +const GLOBAL_DRAFT_KINDS = [ + 'script', + 'flow', + 'raw_app', + 'trigger_schedule', + 'trigger_http', + 'trigger_websocket', + 'trigger_kafka', + 'trigger_nats', + 'trigger_postgres', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'resource', + 'variable' +] as const satisfies UserDraftItemKind[] + +const secretVariableDraftValues = new Map>() + +function clone(value: T): T { + return structuredClone(value) as T +} + +function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue { + return { + summary: value.summary, + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: value.data ?? { ...DEFAULT_RAW_APP_DATA }, + policy: value.policy === undefined ? undefined : clone(value.policy), + custom_path: value.custom_path + } +} + +function getItemSummary(value: unknown): string | undefined { + return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined +} + +export function setEphemeralSecretVariableDraftValue( + workspace: string, + path: string, + value: string +): void { + let workspaceValues = secretVariableDraftValues.get(workspace) + if (!workspaceValues) { + workspaceValues = new Map() + secretVariableDraftValues.set(workspace, workspaceValues) + } + workspaceValues.set(path, value) +} + +export function getEphemeralSecretVariableDraftValue( + workspace: string, + path: string +): string | undefined { + return secretVariableDraftValues.get(workspace)?.get(path) +} + +export function clearEphemeralSecretVariableDraftValue(workspace: string, path: string): void { + const workspaceValues = secretVariableDraftValues.get(workspace) + if (!workspaceValues) return + workspaceValues.delete(path) + if (workspaceValues.size === 0) secretVariableDraftValues.delete(workspace) +} + +function clearEphemeralSecretVariableDraftValues(workspace: string): void { + secretVariableDraftValues.delete(workspace) +} + +function itemKindFor( + type: WorkspaceItemType, + triggerKind?: TriggerKind +): UserDraftItemKind | undefined { + switch (type) { + case 'script': + case 'flow': + case 'resource': + case 'variable': + return type + case 'app': + return 'raw_app' + case 'schedule': + return 'trigger_schedule' + case 'trigger': + return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined + } +} + +export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind { + return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind] +} + +function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem { + return { + type: 'script', + path, + summary: draft.summary, + language: draft.language, + value: draft.content, + isDraft: true + } +} + +function flowDraftToWorkspaceItem(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 + } +} + +function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceItem { + const value = normalizeAppDraftValue(draft) + return { + type: 'app', + path, + summary: value.summary, + value, + isDraft: true + } +} + +function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem { + return { + type: 'schedule', + path, + summary: draft.summary ?? undefined, + value: clone(draft), + isDraft: true + } +} + +function triggerDraftToWorkspaceItem( + kind: TriggerKind, + path: string, + draft: TriggerRequestBody +): WorkspaceItem { + return { + type: 'trigger', + triggerKind: kind, + path, + summary: getItemSummary(draft), + value: clone(draft), + isDraft: true + } +} + +function resourceDraftToWorkspaceItem(path: string, draft: ResourceDraftState): WorkspaceItem { + return { + type: 'resource', + path, + summary: draft.description || undefined, + value: { + path, + value: clone(draft.args), + description: draft.description, + resource_type: draft.resource_type ?? '', + labels: draft.labels, + ws_specific: draft.wsSpecific + }, + isDraft: true + } +} + +function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState): WorkspaceItem { + return { + type: 'variable', + path, + summary: draft.variable.description || undefined, + value: { + path, + value: draft.variable.value, + is_secret: draft.variable.is_secret, + description: draft.variable.description, + account: draft.account, + is_oauth: draft.is_oauth, + expires_at: draft.expires_at, + labels: draft.labels, + ws_specific: draft.wsSpecific + }, + isDraft: true + } +} + +function userDraftEntryToWorkspaceItem( + entry: UserDraftEntry, + path = entry.path, + isLiveDraft = false +): WorkspaceItem | undefined { + let item: WorkspaceItem | undefined + switch (entry.itemKind) { + case 'script': + item = scriptDraftToWorkspaceItem(path, entry.value as NewScript) + break + case 'flow': + item = flowDraftToWorkspaceItem(path, entry.value as Flow) + break + case 'raw_app': + item = appDraftToWorkspaceItem(path, entry.value as AppDraftValue) + break + case 'trigger_schedule': + item = scheduleDraftToWorkspaceItem(path, entry.value as NewSchedule) + break + case 'resource': + item = resourceDraftToWorkspaceItem(path, entry.value as ResourceDraftState) + break + case 'variable': + item = variableDraftToWorkspaceItem(path, entry.value as VariableDraftState) + break + default: { + const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind] + item = triggerKind + ? triggerDraftToWorkspaceItem(triggerKind, path, entry.value as TriggerRequestBody) + : undefined + } + } + return item && isLiveDraft ? { ...item, isLiveDraft: true } : item +} + +function liveDisplayPath( + workspace: string, + itemKind: UserDraftItemKind, + storagePath: string +): { displayPath: string; isLiveDraft: boolean } { + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + if (liveDraft?.storagePath !== storagePath) { + return { displayPath: storagePath, isLiveDraft: false } + } + return { + displayPath: liveDraft.effectivePath || storagePath, + isLiveDraft: true + } +} + +function resolveDraftStoragePath( + workspace: string, + itemKind: UserDraftItemKind, + path: string +): string { + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + if (!liveDraft) return path + if (path === liveDraft.storagePath || path === liveDraft.effectivePath) + return liveDraft.storagePath + return path +} + +export function getGlobalDraftStoragePath( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): string { + const itemKind = itemKindFor(type, triggerKind) + return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path +} + +function getGlobalDraftSlot( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +) { + const itemKind = itemKindFor(type, triggerKind) + if (!itemKind) return undefined + const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + const draft = UserDraft.get(itemKind, storagePath, { workspace }) + if (draft === undefined) return undefined + + const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath) + const entry = { + workspace, + itemKind, + path: storagePath, + value: draft, + meta: {}, + persisted: false, + live: false + } + const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft) + if (!item) return undefined + return { itemKind, storagePath, displayPath, item } +} + +export function getGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): WorkspaceItem | undefined { + return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item +} + +export function listGlobalDrafts(workspace: string): WorkspaceItem[] { + const drafts = new Map() + for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) { + const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path) + const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft) + if (!draft) continue + drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft) + } + return Array.from(drafts.values()) +} + +export function saveGlobalAppDraft( + workspace: string, + path: string, + value: AppDraftValue, + meta?: UserDraftMeta +): WorkspaceItem { + const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path) + const normalized = normalizeAppDraftValue(value) + if (meta) { + UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace }) + } else { + UserDraft.save('raw_app', storagePath, normalized, { workspace }) + } + const stored = getGlobalDraft(workspace, 'app', path) + if (!stored) throw new Error(`Could not read written app draft "${path}".`) + return stored +} + +type DeleteGlobalDraftOptions = { + preserveLiveDraft?: boolean +} + +export function deleteGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind, + options: DeleteGlobalDraftOptions = {} +): void { + const itemKind = itemKindFor(type, triggerKind) + if (!itemKind) return + const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) { + UserDraft.remove(itemKind, storagePath, { workspace }) + } else { + UserDraft.clear(itemKind, storagePath, { workspace }) + } + if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath) +} + +export function clearGlobalDrafts(workspace: string): void { + for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) { + UserDraft.clear(draft.itemKind, draft.path, { workspace }) + } + clearEphemeralSecretVariableDraftValues(workspace) +} diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts new file mode 100644 index 0000000000..acc2a5721a --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts @@ -0,0 +1,122 @@ +import type { + AzureTriggerData, + CreateResource, + CreateVariable, + FlowValue, + GcpTriggerData, + NewHttpTrigger, + NewKafkaTrigger, + NewMqttTrigger, + NewNatsTrigger, + NewPostgresTrigger, + NewSchedule, + NewSqsTrigger, + NewWebsocketTrigger, + Policy, + ScriptLang +} from '$lib/gen/types.gen' + +/** + * Flow draft value. Mirrors what the backend's create/update flow API expects + * -- the OpenFlow value, plus the inputs schema and (optional) groups. + * + * Schema and groups are split out from FlowValue intentionally so that + * deploy_workspace_item can preserve them through the draft -> workspace + * round-trip; an earlier version dropped them on every deploy. + */ +export type FlowDraftValue = { + value: FlowValue + schema?: Record | null + groups?: NonNullable | null +} + +export const TRIGGER_KINDS = [ + 'http', + 'websocket', + 'kafka', + 'nats', + 'postgres', + 'mqtt', + 'sqs', + 'gcp', + 'azure' +] as const + +export type TriggerKind = (typeof TRIGGER_KINDS)[number] + +export type TriggerRequestBody = + | NewHttpTrigger + | NewWebsocketTrigger + | NewKafkaTrigger + | NewNatsTrigger + | NewPostgresTrigger + | NewMqttTrigger + | NewSqsTrigger + | GcpTriggerData + | AzureTriggerData + +export type WorkspaceItemType = + | 'script' + | 'flow' + | 'schedule' + | 'trigger' + | 'resource' + | 'variable' + | 'app' + +export type AppDraftValue = { + summary?: string + files: Record + runnables: Record + data?: any + policy?: Policy + custom_path?: string +} + +export type ResourceDraftState = { + path: string + description: string + args: Record + labels: string[] | undefined + wsSpecific: boolean + resource_type?: string +} + +export type VariableDraftState = { + path: string + variable: { value: string; is_secret: boolean; description: string } + labels: string[] | undefined + wsSpecific: boolean + account?: number + is_oauth?: boolean + expires_at?: string +} + +export type WorkspaceItem = { + type: WorkspaceItemType + path: string + summary?: string + language?: ScriptLang + triggerKind?: TriggerKind + value?: + | string + | FlowDraftValue + | NewSchedule + | TriggerRequestBody + | CreateResource + | CreateVariable + | AppDraftValue + isDraft: boolean + isLiveDraft?: boolean +} + +export function getWorkspaceItemKey( + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): string { + if (type === 'trigger') { + return `trigger:${triggerKind ?? ''}:${path}` + } + return `${type}:${path}` +} diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index 427fb9a9fd..e91a06e430 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -33,6 +33,7 @@ export type FlowBuilderProps = { stepsState: Record } noInitial?: boolean + liveEditorDraftStoragePath?: string onSaveInitial?: ({ path, id }: { path: string; id: string }) => void onSaveDraft?: ({ path, diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 46edb5d230..5e0a6a1b09 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -70,6 +70,7 @@ * key when the editor is rendered in a context that wants its own * preference. */ sidebarStorageKey?: string + liveEditorDraftStoragePath?: string } let { @@ -84,7 +85,8 @@ savedApp = $bindable(undefined), diffDrawer = undefined, defaultSidebarCollapsed = false, - sidebarStorageKey = 'raw-app-sidebar-collapsed' + sidebarStorageKey = 'raw-app-sidebar-collapsed', + liveEditorDraftStoragePath = undefined }: Props = $props() export const version: number | undefined = undefined @@ -934,6 +936,7 @@ {newApp} {newPath} appPath={path} + {liveEditorDraftStoragePath} {files} {data} {runnables} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index b60327896f..495a4ebcc1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -125,6 +125,7 @@ onOpenYamlEditor?: () => void sidebarCollapsed?: boolean onToggleSidebar?: () => void + liveEditorDraftStoragePath?: string } let { @@ -148,7 +149,8 @@ onRedo = undefined, onOpenYamlEditor = undefined, sidebarCollapsed = false, - onToggleSidebar = undefined + onToggleSidebar = undefined, + liveEditorDraftStoragePath = undefined }: Props = $props() let newEditedPath = $state( @@ -159,6 +161,22 @@ ) ) + $effect(() => { + if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return + const workspace = $workspaceStore + UserDraft.setLiveEditorDraft({ + workspace, + itemKind: 'raw_app', + storagePath: liveEditorDraftStoragePath, + effectivePath: newEditedPath || appPath || savedApp?.path + }) + return () => + UserDraft.clearLiveEditorDraft('raw_app', { + workspace, + storagePath: liveEditorDraftStoragePath + }) + }) + let deployedValue: Value | undefined = $state(undefined) // Value to diff against let deployedBy: string | undefined = $state(undefined) // Author let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 3d83952513..03db43dc1a 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -130,7 +130,26 @@ export type UserDraftEntry = { live: boolean } +export type LiveEditorDraft = { + workspace: string + itemKind: UserDraftItemKind + storagePath: string + effectivePath?: string +} + +export type LiveEditorDraftSpec = { + itemKind: UserDraftItemKind + storagePath: string + effectivePath?: string + workspace?: string +} + +export type ClearLiveEditorDraftOptions = UserDraftOptions & { + storagePath?: string +} + const entries = new Map() +const liveEditorDrafts = new Map() function resolveWorkspace(opts?: UserDraftOptions): string { const ws = opts?.workspace ?? get(workspaceStore) @@ -230,6 +249,10 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s return `userdraft/w/${workspace}/${itemKind}/${path}` } +function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string { + return `${workspace}/${itemKind}` +} + function parseLocalStorageKey( key: string, workspace: string, @@ -331,10 +354,13 @@ export const UserDraft = { const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) if (entry) { - // Notify observers; preserve existing rev metadata. `untrack`ed - // read — see `set draft` below for why. + // Static writes are external mutations. Update live observers and + // force the storage slot to match, even if the live entry still has + // its initial-write skip armed. const current = untrack(() => entry.state.val as StoredDraft | undefined) - entry.state.val = wrap(value, extractMeta(current)) + const meta = extractMeta(current) + entry.state.setWithoutPersist(wrap(value, meta)) + persistDirect(localStorageKey(ws, itemKind, path), value, meta) return } // No live handle: preserve any persisted meta so the staleness @@ -361,10 +387,10 @@ export const UserDraft = { const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) if (entry) { - entry.state.val = wrap(value, meta) // Static writes represent explicit external draft mutations. A // freshly acquired live entry may still have the initial-write skip // armed, so force the storage slot to match the live value. + entry.state.setWithoutPersist(wrap(value, meta)) persistDirect(localStorageKey(ws, itemKind, path), value, meta) return } @@ -401,9 +427,9 @@ export const UserDraft = { const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) if (entry) { - return unwrap(entry.state.val as StoredDraft | undefined) + return snapshotDraftValue(unwrap(entry.state.val as StoredDraft | undefined)) } - return unwrap(readPersisted(localStorageKey(ws, itemKind, path))) + return snapshotDraftValue(unwrap(readPersisted(localStorageKey(ws, itemKind, path)))) }, /** @@ -523,6 +549,34 @@ export const UserDraft = { return Array.from(out.values()) }, + setLiveEditorDraft(spec: LiveEditorDraftSpec): void { + const ws = resolveWorkspace({ workspace: spec.workspace }) + liveEditorDrafts.set(liveEditorDraftKey(ws, spec.itemKind), { + workspace: ws, + itemKind: spec.itemKind, + storagePath: spec.storagePath, + effectivePath: spec.effectivePath || undefined + }) + }, + + getLiveEditorDraft( + itemKind: UserDraftItemKind, + opts?: UserDraftOptions + ): LiveEditorDraft | undefined { + const ws = resolveWorkspace(opts) + const draft = liveEditorDrafts.get(liveEditorDraftKey(ws, itemKind)) + return draft ? { ...draft } : undefined + }, + + clearLiveEditorDraft(itemKind: UserDraftItemKind, opts?: ClearLiveEditorDraftOptions): void { + const ws = resolveWorkspace(opts) + const key = liveEditorDraftKey(ws, itemKind) + const draft = liveEditorDrafts.get(key) + if (!draft) return + if (opts?.storagePath !== undefined && draft.storagePath !== opts.storagePath) return + liveEditorDrafts.delete(key) + }, + /** * Like `remove`, but also resets any live handle's `draft` to * `fallback` in-memory (so reactive readers see it immediately) and @@ -802,4 +856,5 @@ export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void /** Test-only: clear all in-memory entries. */ export function __resetUserDraftForTesting(): void { entries.clear() + liveEditorDrafts.clear() } diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index f703953f11..e2dd97418f 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -18,6 +18,7 @@ vi.mock('svelte', async (importOriginal) => { const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } = await import('./userDraft.svelte') const { workspaceStore } = await import('./stores') +const { deleteGlobalDraft } = await import('./components/copilot/chat/global/userDraftAdapter') function flushDestroyCallbacks(): void { const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length) @@ -119,6 +120,78 @@ describe('UserDraft.save / get / remove (no observers)', () => { }) }) +describe('UserDraft live editor draft registry', () => { + it('stores the live editor storage path and effective path per workspace and kind', () => { + UserDraft.setLiveEditorDraft({ + itemKind: 'script', + storagePath: '', + effectivePath: 'u/me/generated_script' + }) + + expect(UserDraft.getLiveEditorDraft('script')).toEqual({ + workspace: 'test_ws', + itemKind: 'script', + storagePath: '', + effectivePath: 'u/me/generated_script' + }) + }) + + it('keeps live editor registrations isolated by workspace', () => { + UserDraft.setLiveEditorDraft({ + workspace: 'ws_a', + itemKind: 'flow', + storagePath: '', + effectivePath: 'u/me/a' + }) + UserDraft.setLiveEditorDraft({ + workspace: 'ws_b', + itemKind: 'flow', + storagePath: '', + effectivePath: 'u/me/b' + }) + + expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_a' })?.effectivePath).toBe( + 'u/me/a' + ) + expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_b' })?.effectivePath).toBe( + 'u/me/b' + ) + }) + + it('clears only the matching live editor storage path when provided', () => { + UserDraft.setLiveEditorDraft({ + itemKind: 'raw_app', + storagePath: '', + effectivePath: 'u/me/live_app' + }) + + UserDraft.clearLiveEditorDraft('raw_app', { storagePath: 'u/me/other' }) + expect(UserDraft.getLiveEditorDraft('raw_app')).toBeDefined() + + UserDraft.clearLiveEditorDraft('raw_app', { storagePath: '' }) + expect(UserDraft.getLiveEditorDraft('raw_app')).toBeUndefined() + }) + + it('can remove persisted global draft storage without blanking the live editor', () => { + const draft = { path: 'u/me/live_script', content: 'export async function main() {}' } + localStorage.setItem('userdraft/w/test_ws/script/', wrapped(draft)) + const handle = UserDraft.use('script', '') + UserDraft.setLiveEditorDraft({ + itemKind: 'script', + storagePath: '', + effectivePath: 'u/me/live_script' + }) + + deleteGlobalDraft('test_ws', 'script', 'u/me/live_script', undefined, { + preserveLiveDraft: true + }) + flushPersist() + + expect(handle.draft).toEqual(draft) + expect(localStorage.getItem('userdraft/w/test_ws/script/')).toBeNull() + }) +}) + describe('UserDraft.use() — observer sync', () => { it('loads the existing localStorage value on first use', () => { localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded')) @@ -138,24 +211,29 @@ describe('UserDraft.use() — observer sync', () => { expect(a.draft).toBe(99) }) - it('save() propagates to live use() handles (in-memory)', () => { + it('save() propagates to live use() handles and persists immediately', () => { const handle = UserDraft.use('flow', 'u/me/observed') expect(handle.draft).toBeUndefined() - // First write through a live entry is treated as the "initial value" - // (saveInitialValue=false) and is NOT persisted — observers still see it. UserDraft.save('flow', 'u/me/observed', 7) expect(handle.draft).toBe(7) - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBeNull() + expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(7)) - // Subsequent writes persist. UserDraft.save('flow', 'u/me/observed', 9) expect(handle.draft).toBe(9) - flushPersist() expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9)) }) + it('get() returns a cloneable snapshot of live handle values', () => { + const handle = UserDraft.use<{ path: string; nested: { value: number } }>('script', '') + handle.draft = { path: 'u/me/live', nested: { value: 1 } } + + const draft = UserDraft.get<{ path: string; nested: { value: number } }>('script', '') + expect(draft).toEqual({ path: 'u/me/live', nested: { value: 1 } }) + expect(draft).not.toBe(handle.draft) + expect(() => structuredClone(draft)).not.toThrow() + }) + it('remove() clears localStorage without touching the in-memory handle', () => { // Seed localStorage so the live handle initialises from it. localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1)) @@ -411,6 +489,28 @@ describe('UserDraft — rev metadata for staleness checks', () => { ) }) + it('UserDraft.save persists immediately when a live handle exists', () => { + const handle = UserDraft.use('flow', 'u/me/live-save') + + UserDraft.save('flow', 'u/me/live-save', 'external') + + expect(handle.draft).toBe('external') + expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save')).toBe(wrapped('external')) + }) + + it('UserDraft.save preserves live rev metadata while forcing persistence', () => { + const handle = UserDraft.use('flow', 'u/me/live-save-meta') + handle.setDraftAndMeta('baseline', { remoteRev: 5 }) + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBeNull() + + UserDraft.save('flow', 'u/me/live-save-meta', 'external') + + expect(handle.draft).toBe('external') + expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBe( + JSON.stringify({ value: 'external', remoteRev: 5 }) + ) + }) + it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => { localStorage.setItem( 'userdraft/w/test_ws/flow/u/me/legacy', @@ -503,8 +603,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => { UserDraft.save('flow', 'u/me/ref', 2) expect(a.draft).toBe(2) - // Now persisted (second write after the baseline). - flushPersist() + // External save() calls persist immediately, even with a live handle. expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2)) // Releasing the second handle drops the entry; subsequent save() @@ -878,9 +977,7 @@ describe('UserDraft.list / clear / setDraftAndMeta', () => { ) flushPersist() - expect(storedShape(key)).toBe( - wrapped({ path: 'f/rewrite-after-clear', content: 'new' }) - ) + expect(storedShape(key)).toBe(wrapped({ path: 'f/rewrite-after-clear', content: 'new' })) }) it('list hides persisted drafts when a live handle has cleared the value', () => { diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index fb68f09f1b..716000215f 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -79,6 +79,8 @@ runnables: Record data: RawAppData summary: string + policy?: Policy + custom_path?: string }>('raw_app', '') // Restore the persisted autosave so a plain reload of /apps_raw/add // resumes the last session. Captured once; the $effect below mirrors @@ -114,13 +116,15 @@ let summary = $state(restoredDraft?.summary ?? '') let files: Record = $state(restoredDraft?.files ?? react19Template) - let policy: Policy = $state({ - on_behalf_of: $userStore?.username.includes('@') - ? $userStore?.username - : `u/${$userStore?.username}`, - on_behalf_of_email: $userStore?.email, - execution_mode: 'publisher' - }) + let policy: Policy = $state( + restoredDraft?.policy ?? { + on_behalf_of: $userStore?.username.includes('@') + ? $userStore?.username + : `u/${$userStore?.username}`, + on_behalf_of_email: $userStore?.email, + execution_mode: 'publisher' + } + ) let runnables: Record = $state(restoredDraft?.runnables ?? defaultRunnables) /** Data configuration including tables and creation policy */ @@ -133,13 +137,14 @@ readFieldsRecursively(files) readFieldsRecursively(runnables) readFieldsRecursively(data) + readFieldsRecursively(policy) void summary untrack(() => { if (firstMirror) { firstMirror = false draftHandle.setDraftAndMeta(undefined, {}) } - draftHandle.draft = { files, runnables, data, summary } + draftHandle.draft = { files, runnables, data, summary, policy } }) }) @@ -150,11 +155,12 @@ const d = draftHandle.draft if (d == null) return untrack(() => { - if (localDraftDiffers(d, { files, runnables, data, summary })) { + if (localDraftDiffers(d, { files, runnables, data, summary, policy })) { files = d.files runnables = d.runnables data = d.data summary = d.summary + if (d.policy !== undefined) policy = d.policy } }) }) @@ -666,6 +672,7 @@ bind:data {policy} path={''} + liveEditorDraftStoragePath="" bind:summary newApp /> diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 93417aa3f4..84ce23e7a5 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -32,6 +32,8 @@ runnables: Record data: RawAppData summary: string + policy?: any + custom_path?: string } let files: Record | undefined = $state(undefined) @@ -105,25 +107,45 @@ // Persist the bundle whenever any of the four pieces of state changes. $effect(() => { - if (!files) return - readFieldsRecursively(files) + const currentFiles = files + if (!currentFiles) return + readFieldsRecursively(currentFiles) readFieldsRecursively(runnables) readFieldsRecursively(data) + readFieldsRecursively(policy) void summary - draftHandle.draft = { files, runnables, data, summary } + draftHandle.draft = { + files: currentFiles, + runnables, + data, + summary, + policy, + custom_path: savedApp?.custom_path + } }) // Reflect an external UserDraft.save into the form. Idempotent; the // `!files` guard skips the reload window so it doesn't fight loadApp. $effect(() => { const d = draftHandle.draft - if (d == null || !files) return + const currentFiles = files + if (d == null || !currentFiles) return untrack(() => { - if (localDraftDiffers(d, { files, runnables, data, summary })) { + if ( + localDraftDiffers(d, { + files: currentFiles, + runnables, + data, + summary, + policy, + custom_path: savedApp?.custom_path + }) + ) { files = d.files runnables = d.runnables data = d.data summary = d.summary + if (d.policy !== undefined) policy = d.policy } }) }) @@ -192,7 +214,9 @@ (backendSource.value?.datatables ? { ...DEFAULT_DATA, tables: backendSource.value.datatables } : { ...DEFAULT_DATA }), - summary: backendSource.summary ?? '' + summary: backendSource.summary ?? '', + policy: backendSource.policy ?? app_w_draft.policy, + custom_path: backendSource.custom_path ?? app_w_draft.custom_path } if ( @@ -240,7 +264,7 @@ runnables = localDraft.runnables data = localDraft.data summary = localDraft.summary - policy = app_w_draft.policy + policy = localDraft.policy ?? app_w_draft.policy newPath = app_w_draft.path files = localDraft.files } else { @@ -370,6 +394,7 @@ bind:summary {newPath} path={page.params.path ?? ''} + liveEditorDraftStoragePath={path} {policy} bind:savedApp {diffDrawer} diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index cbeb9becb5..0af3ea5b3c 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -200,6 +200,7 @@ onNavigate={(item) => goto(editPathFor(item))} {initialPath} {pathStoreInit} + liveEditorDraftStoragePath="" bind:this={flowBuilder} newFlow {initialArgs} diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index c33d6b6278..7d3d3c14b4 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -367,6 +367,7 @@ {flowStore} {flowStateStore} initialPath={page.params.path ?? ''} + liveEditorDraftStoragePath={flowDraftPath} newFlow={false} {selectedId} {initialArgs} diff --git a/frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte b/frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte index 4b7f8e8d0c..d626c084bc 100644 --- a/frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte @@ -1,9 +1,11 @@ @@ -41,9 +65,9 @@
-

Global AI drafts

+

Global local drafts

- Dev-only inspector for the in-memory global draft store. + Dev-only inspector for global local drafts.