From b47fabc8fc671131a64e093918842eb9ac44f406 Mon Sep 17 00:00:00 2001 From: centdix Date: Mon, 8 Jun 2026 14:06:02 +0200 Subject: [PATCH] feat(drafts): back global chat draft read/write with db layer Co-Authored-By: Claude Opus 4.8 (1M context) --- .../chat/global/MIGRATION_DB_DRAFTS.md | 90 +++++--- .../copilot/chat/global/core.test.ts | 177 +++++++++++----- .../components/copilot/chat/global/core.ts | 194 +++++++++--------- .../copilot/chat/global/userDraftAdapter.ts | 141 +++++++++++-- frontend/src/lib/userDraft.svelte.ts | 14 ++ 5 files changed, 425 insertions(+), 191 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/MIGRATION_DB_DRAFTS.md b/frontend/src/lib/components/copilot/chat/global/MIGRATION_DB_DRAFTS.md index 45683c34a0..7a866123eb 100644 --- a/frontend/src/lib/components/copilot/chat/global/MIGRATION_DB_DRAFTS.md +++ b/frontend/src/lib/components/copilot/chat/global/MIGRATION_DB_DRAFTS.md @@ -120,13 +120,13 @@ Work one tool at a time; each is independently shippable behind the existing | # | Tool / helper | What changes | |---|---------------|--------------| | 1 | `list_workspace_items` | **✅ Done** — reuse list endpoints' `includeDraftOnly` + `isDraft` flag for script/flow/app; `listGlobalDrafts` removed (see §6) | -| 2 | `read_workspace_item` (`getGlobalDraft`) | read → `DraftService.getDraft`; async | -| 3 | `write_script` / `write_flow` / `write_app` | save-then-read → awaited DB save + DB read | -| 4 | `write` trigger / schedule | same as #3 for trigger kinds | -| 5 | `write_resource` / `write_variable` | same as #3; keep secret ephemeral map in-memory | -| 6 | `delete_draft` (`deleteGlobalDraft`) | `save_draft` null; drop `UserDraft.clear` reliance | -| 7 | `clearGlobalDrafts` | enumerate via `listDrafts` then delete each | -| 8 | deploy path | unaffected by storage; verify it reads the DB draft before deploying | +| 2 | `read_workspace_item` (`getGlobalDraft`) | **✅ Done** — read → `readGlobalDraftValue` (in-memory → `DraftService.getDraft`); `getGlobalDraft` now async (see §7) | +| 3 | `write_script` / `write_flow` / `write_app` | **✅ Done** — existing-draft read + read-back via the seam; save via `saveGlobalDraftValue` (live → `UserDraft`, headless → awaited forced DB save) | +| 4 | `write` trigger / schedule | **✅ Done** — same seam as #3 for trigger kinds | +| 5 | `write_resource` / `write_variable` | **✅ Done** — same seam as #3; secret ephemeral map untouched (DB value stays `''`) | +| 6 | `delete_draft` (`deleteGlobalDraft`) | **✅ Done** — async; live → `UserDraft.remove/clear`, headless → awaited `save_draft` null | +| 7 | `clearGlobalDrafts` | **✅ Done** — enumerate via `DraftService.listDrafts` (filtered to `GLOBAL_DRAFT_KINDS`), delete each | +| 8 | deploy path | **✅ Done** — reads the DB draft via async `getGlobalDraft` before deploying; post-deploy delete is awaited | ## 6. Tool #1 — `list_workspace_items` (implemented) @@ -209,11 +209,66 @@ Lift it by adding `includeDraftOnly` (email-scoped) to those endpoints. - E2E (not yet run): with `wm_dev_global_ai` enabled, create a draft script via the global mode, reload, confirm `list_workspace_items` flags it `isDraft: true`. -## 8. Handoff — current branch state & next steps +## 7. Tools #2–#8 — the read/write seam (implemented) -**Branch `claude-change-ed070a5a`** = PR #9351 (`remove-workspace-drafts`, head -`9c8c4edb`) + a merge of latest `origin/main`, plus the tool #1 commit. Environment -already prepared in this worktree: +The read-after-write gap (§3) is closed by a small async seam in +`userDraftAdapter.ts` that all global read/write/delete paths funnel through. +Writes still flow through the synced `UserDraft` layer **when an editor is +mounted**; otherwise they go straight to the DB and are awaited. + +**`readGlobalDraftValue(workspace, itemKind, storagePath)`** — raw draft +value. In-tab mounted cell wins (`UserDraft.get`, freshest — holds unsaved +live-editor edits); else `DraftService.getDraft` (404 → `undefined` via +`isNotFoundError`). Async. Replaces the writers' `UserDraft.get<…>` existing-draft +probe, so a draft written in a prior turn/tab is now found. + +**`saveGlobalDraftValue(workspace, itemKind, storagePath, value, meta?)`** — +if `UserDraft.isLive(...)` (a handle is mounted), route through +`UserDraft.save`/`setDraftAndMeta` so the open editor updates reactively (its +background syncer persists). Else push to `UserDraftDbSyncer.save({ immediate: +true, force: true })` and **await** it, so a read-back in the same turn sees the +value. Forced — AI overwrites have no human at a conflict modal. Rev metadata is +in-memory only and is dropped on the headless path (DB stores values, not revs; +the next editor mount reseeds it). + +`UserDraft.isLive(itemKind, path, opts)` was added (`userDraft.svelte.ts`) — true +when an entry is mounted, regardless of whether it holds a value (distinct from +`has`). + +**Wiring (`core.ts`):** `getGlobalDraft` / `getGlobalDraftSlot` / `saveGlobalAppDraft` +/ `deleteGlobalDraft` / `clearGlobalDrafts` are now async; `getRequiredGlobalDraft` +and the ~15 read/write/delete call-sites thread `await`. `clearGlobalDrafts` +enumerates `DraftService.listDrafts` (filtered to `GLOBAL_DRAFT_KINDS`). +Secret-variable handling is unchanged: the draft value stays `''` and the real +secret lives only in the in-memory `secretVariableDraftValues` map. The "local +storage" copy in toasts/tool results was reworded to "draft". + +### Verification (done) + +- **Unit (`core.test.ts`):** all 61 pass (was 24/58). A hoisted in-memory + `DraftService` mock (`draftDb`) backs the tools; assertions read drafts via + `dbDraftValue(...)` (the DB store) and seed setup via `seedDbDraft(...)`. The + `getMeta` assertions were dropped (rev meta isn't DB-persisted); secret-safety + now also asserts against `dbSnapshot()`. +- **Real backend round-trip** (dev `test` workspace, `force`): `get_draft` 404 → + `save_draft` (`{status:'saved',current_timestamp}`) → `get_draft` + (`{value,saved_at}`) → `list_drafts` (`{path,typ,saved_at}`) → `save_draft` + `value:null` → `get_draft` 404. Confirms the exact request/response shapes the + seam uses against the endpoints the unit tests mock. + +### Remaining + +- **E2E with the live LLM-driven chat** (§8 step 5) is still unrun — it needs a + copilot API key and `wm_dev_global_ai` enabled; not exercisable headlessly here. +- Schedule/trigger/resource/variable drafts remain undiscoverable via + `list_workspace_items` (reachable by path through `read_workspace_item`) until + those list endpoints gain email-scoped `includeDraftOnly` (§6 limitation). + +## 8. Environment (branch `claude-change-ed070a5a`) + += PR #9351 (`remove-workspace-drafts`, head `9c8c4edb`) + a merge of latest +`origin/main` + the tool #1 commit + the tools #2–#8 seam (§7). Already prepared +in this worktree: - DB migration `20260528143710_draft_user_sync_schema` is **applied** to the dev DB (`windmill_claude_change_ed070a5a`). A fresh DB just needs `sqlx migrate run`. @@ -221,16 +276,3 @@ already prepared in this worktree: is gitignored, so a fresh checkout must regenerate it to get `DraftService.{saveDraft, getDraft, listDrafts, getDraftForUser}`. - Dev server runs with `REMOTE=http://localhost:8070 PORT=3070`. - -**Root cause of the ~28 failing `core.test.ts` tests (NOT tool #1):** the read-after-write -gap from §3. The PR's `UserDraft.save`/`setDraftAndMeta` only update the in-tab store when -a handle is *mounted*; the headless global tools never mount one, so `UserDraft.get` after -a write returns `undefined` and `getRequiredGlobalDraft` throws *"Could not read written -draft"*. In tests this also surfaces as `getXByPath mock not configured`. This blocks -tools #2–#8 and is the highest-leverage thing to fix next. - -**Suggested next step:** either tool #2 (`read_workspace_item` → `DraftService.getDraft`), -or — more impactful — close the read-after-write gap directly (make a write readable -without a mounted handle: e.g. have `UserDraft.save` seed an entry, or route the global -write/read tools through `DraftService` directly), which unblocks the bulk of the failing -tests at once. 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 8eeebd7d07..772c32e932 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -26,6 +26,30 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ vi.mock('$lib/components/vscode', () => ({})) +// In-memory stand-in for the per-user DB draft layer (`DraftService`). +// Post #9351 the global mode is headless and persists drafts through the +// DB endpoints, so the tools read/write here instead of localStorage. +// Hoisted so the `$lib/gen` mock factory (itself hoisted) can close over it. +const draftDb = vi.hoisted(() => { + const store = new Map() + let clock = 0 + const key = (kind: string, path: string) => `${kind}/${path}` + return { + store, + key, + reset() { + store.clear() + clock = 0 + }, + set(kind: string, path: string, value: unknown): string { + clock += 1 + const saved_at = `2026-01-01T00:00:${String(clock).padStart(2, '0')}.000Z` + store.set(key(kind, path), { value, saved_at }) + return saved_at + } + } +}) + vi.mock('$lib/gen', async () => { const actual = await vi.importActual('$lib/gen') @@ -107,6 +131,33 @@ vi.mock('$lib/gen', async () => { }), createVariable: vi.fn(async () => 'created'), updateVariable: vi.fn(async () => 'updated') + }), + DraftService: wrapService(actual.DraftService, { + saveDraft: vi.fn(async ({ kind, path, requestBody }: any) => { + if (requestBody.value === null) { + draftDb.store.delete(draftDb.key(kind, path)) + return { status: 'saved', current_timestamp: '1970-01-01T00:00:00.000Z' } + } + const saved_at = draftDb.set(kind, path, requestBody.value) + return { status: 'saved', current_timestamp: saved_at } + }), + getDraft: vi.fn(async ({ kind, path }: any) => { + const entry = draftDb.store.get(draftDb.key(kind, path)) + if (!entry) { + throw new actual.ApiError( + { method: 'GET', url: '' }, + { url: '', ok: false, status: 404, statusText: 'Not Found', body: undefined }, + 'no draft for the current user at that path' + ) + } + return { value: entry.value, saved_at: entry.saved_at } + }), + listDrafts: vi.fn(async () => + [...draftDb.store.entries()].map(([k, v]) => { + const slash = k.indexOf('/') + return { typ: k.slice(0, slash), path: k.slice(slash + 1), saved_at: v.saved_at } + }) + ) }) } }) @@ -181,6 +232,31 @@ function localStorageSnapshot(): string { return values.join('\n') } +// Read the value a tool persisted into the per-user DB draft layer. Post +// #9351 the headless global tools write through `DraftService` +// (mocked above as `draftDb`), not the in-tab `UserDraft` cache, so draft +// assertions read here instead of `UserDraft.get`. +function dbDraftValue(kind: string, path: string): T | undefined { + return draftDb.store.get(draftDb.key(kind, path))?.value as T | undefined +} + +// Seed a draft directly into the DB layer (replaces test setup that used +// `UserDraft.save` to pre-populate a draft — that now debounces to the DB +// asynchronously, whereas this lands synchronously). The trailing +// `{ workspace }` arg the old `UserDraft.save` calls passed is accepted and +// ignored so the call sites convert by rename alone. +function seedDbDraft(kind: string, path: string, value: unknown, _opts?: unknown): void { + draftDb.set(kind, path, value) +} + +// Serialized view of every persisted draft value — used to assert secret +// values never reach the draft store. +function dbSnapshot(): string { + return [...draftDb.store.entries()] + .map(([k, v]) => `${k}: ${JSON.stringify(v.value)}`) + .join('\n') +} + async function withCompletedTestJob(run: () => Promise): Promise { vi.useFakeTimers() try { @@ -193,10 +269,11 @@ async function withCompletedTestJob(run: () => Promise): Promise { } describe('global AI tools', () => { - beforeEach(() => { + beforeEach(async () => { __resetUserDraftForTesting() localStorage.clear() - clearGlobalDrafts(WORKSPACE) + draftDb.reset() + await clearGlobalDrafts(WORKSPACE) vi.clearAllMocks() }) @@ -273,6 +350,7 @@ describe('global AI tools', () => { expect(raw).not.toContain('super-secret-token') expect(localStorageSnapshot()).not.toContain('super-secret-token') + expect(dbSnapshot()).not.toContain('super-secret-token') expect(item).toEqual({ type: 'variable', path: 'f/secrets/api_key', @@ -299,7 +377,7 @@ describe('global AI tools', () => { resource_type: 'postgresql' }) - expect(UserDraft.get('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ + expect(dbDraftValue('resource', 'f/resources/db')).toEqual({ path: 'f/resources/db', description: 'existing database', args: { host: 'new.example.com', port: 5432 }, @@ -307,9 +385,7 @@ describe('global AI tools', () => { wsSpecific: true, resource_type: 'postgresql' }) - expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ - remoteRev: '2026-05-22T09:30:00Z' - }) + // Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer. }) it('writes variable drafts in the editor UserDraft shape', async () => { @@ -334,7 +410,7 @@ describe('global AI tools', () => { description: 'new description' }) - expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({ + expect(dbDraftValue('variable', 'f/secrets/api_key')).toEqual({ path: 'f/secrets/api_key', variable: { value: '', @@ -347,10 +423,9 @@ describe('global AI tools', () => { 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' - }) + // Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer. expect(localStorageSnapshot()).not.toContain('new-secret-token') + expect(dbSnapshot()).not.toContain('new-secret-token') }) it('deploys secret variable drafts with ephemeral values only', async () => { @@ -362,7 +437,7 @@ describe('global AI tools', () => { }) expect( - UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE }) + dbDraftValue('variable', 'f/secrets/api_key') ).toMatchObject({ path: 'f/secrets/api_key', variable: { @@ -373,6 +448,7 @@ describe('global AI tools', () => { wsSpecific: false }) expect(localStorageSnapshot()).not.toContain('new-secret-token') + expect(dbSnapshot()).not.toContain('new-secret-token') await callGlobalTool('deploy_workspace_item', { type: 'variable', @@ -389,12 +465,13 @@ describe('global AI tools', () => { ws_specific: false }) }) - expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined() + expect(dbDraftValue('variable', 'f/secrets/api_key')).toBeUndefined() expect(localStorageSnapshot()).not.toContain('new-secret-token') + expect(dbSnapshot()).not.toContain('new-secret-token') }) it('does not deploy a secret variable draft when the ephemeral value is gone', async () => { - UserDraft.save( + seedDbDraft( 'variable', 'f/secrets/api_key', { @@ -430,7 +507,7 @@ describe('global AI tools', () => { content }) - expect(UserDraft.get('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject( + expect(dbDraftValue('script', 'f/scripts/hello')).toMatchObject( { path: 'f/scripts/hello', summary: 'Hello script', @@ -489,7 +566,7 @@ describe('global AI tools', () => { }) it('lists and edits the live script editor draft through its effective path', async () => { - UserDraft.save( + seedDbDraft( 'script', '', { @@ -527,17 +604,17 @@ describe('global AI tools', () => { new_string: 'return a * b' }) - expect(UserDraft.get('script', '', { workspace: WORKSPACE })).toMatchObject({ + expect(dbDraftValue('script', '')).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 }) + dbDraftValue('script', 'u/admin/amazed_script') ).toBeUndefined() }) it('lists and writes the live flow editor draft through its effective path', async () => { - UserDraft.save( + seedDbDraft( 'flow', '', { @@ -575,16 +652,16 @@ describe('global AI tools', () => { modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) }) - expect(UserDraft.get('flow', '', { workspace: WORKSPACE })).toMatchObject({ + expect(dbDraftValue('flow', '')).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() + expect(dbDraftValue('flow', 'u/admin/live_flow')).toBeUndefined() }) it('writes the live raw app editor draft through its effective path', async () => { - UserDraft.save( + seedDbDraft( 'raw_app', '', { @@ -608,13 +685,13 @@ describe('global AI tools', () => { content: 'export default function New() { return null }' }) - expect(UserDraft.get('raw_app', '', { workspace: WORKSPACE })).toMatchObject({ + expect(dbDraftValue('raw_app', '')).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() + expect(dbDraftValue('raw_app', 'u/admin/live_app')).toBeUndefined() }) it('discards a local draft without deleting the workspace item', async () => { @@ -625,7 +702,7 @@ describe('global AI tools', () => { content: 'export async function main() { return 1 }' }) - expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined() + expect(dbDraftValue('script', 'f/scripts/discard-me')).toBeDefined() const raw = await callGlobalTool('discard_local_draft', { type: 'script', @@ -639,7 +716,7 @@ describe('global AI tools', () => { }) expect(raw).toContain('The deployed workspace item was not changed') expect( - UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE }) + dbDraftValue('script', 'f/scripts/discard-me') ).toBeUndefined() }) @@ -672,7 +749,7 @@ describe('global AI tools', () => { }) expect( - UserDraft.get('script', 'f/scripts/existing', { workspace: WORKSPACE }) + dbDraftValue('script', 'f/scripts/existing') ).toMatchObject({ path: 'f/scripts/existing', parent_hash: 'deployed-hash', @@ -681,9 +758,7 @@ describe('global AI tools', () => { content: 'new content', language: 'bun' }) - expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({ - remoteRev: 'deployed-hash' - }) + // Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer. }) it('preserves existing flow metadata and seeds freshness on first flow write', async () => { @@ -707,15 +782,13 @@ describe('global AI tools', () => { modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) }) - expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({ + expect(dbDraftValue('flow', 'f/flows/existing')).toMatchObject({ path: 'f/flows/existing', summary: 'new summary', description: 'deployed description', value: { modules: [{ id: 'step', value: { type: 'identity' } }] } }) - expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({ - remoteRev: 42 - }) + // Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer. }) it('preserves editor schedule fields when writing over an existing schedule', async () => { @@ -749,7 +822,7 @@ describe('global AI tools', () => { }) expect( - UserDraft.get('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) + dbDraftValue('trigger_schedule', 'f/schedules/nightly') ).toMatchObject({ path: 'f/schedules/nightly', schedule: '0 15 0 * * *', @@ -764,7 +837,7 @@ describe('global AI tools', () => { no_flow_overlap: true }) expect( - UserDraft.get('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) + dbDraftValue('trigger_schedule', 'f/schedules/nightly') ).not.toMatchObject({ edited_by: expect.anything() }) @@ -807,7 +880,7 @@ describe('global AI tools', () => { } }) - const draft = UserDraft.get('trigger_http', 'f/routes/api', { workspace: WORKSPACE }) + const draft = dbDraftValue('trigger_http', 'f/routes/api') expect(draft).toMatchObject({ path: 'f/routes/api', script_path: 'f/flows/new', @@ -851,7 +924,7 @@ describe('global AI tools', () => { content: 'export default function New() { return null }' }) - const draft = UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE }) + const draft = dbDraftValue('raw_app', 'f/apps/report') expect(draft).toMatchObject({ summary: 'deployed app', files: { @@ -868,13 +941,11 @@ describe('global AI tools', () => { policy: { execution_mode: 'publisher' }, custom_path: 'report' }) - expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({ - remoteRev: 4 - }) + // Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer. }) it('summarizes local raw app drafts in read_workspace_item', async () => { - UserDraft.save( + seedDbDraft( 'raw_app', 'f/apps/local', { @@ -985,7 +1056,7 @@ describe('global AI tools', () => { file_path: '/src/Helper.tsx' }) ).resolves.toBe('helper content') - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined() }) it('reads raw app files without creating a local draft', async () => { @@ -1006,7 +1077,7 @@ describe('global AI tools', () => { file_path: '/src/App.tsx' }) ).resolves.toBe('deployed content') - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined() }) it('does not persist a raw app draft when patch_app_file validation fails', async () => { @@ -1030,7 +1101,7 @@ describe('global AI tools', () => { replace_all: false }) ).rejects.toThrow() - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined() }) it('does not persist a raw app draft when delete_app_file validation fails', async () => { @@ -1051,7 +1122,7 @@ describe('global AI tools', () => { 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() + expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined() }) it('does not persist a raw app draft when delete_app_runnable validation fails', async () => { @@ -1077,11 +1148,11 @@ describe('global AI tools', () => { 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() + expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined() }) it('deploys a new raw app draft by bundling files and creating a raw app', async () => { - UserDraft.save( + seedDbDraft( 'raw_app', 'f/apps/report', { @@ -1133,7 +1204,7 @@ describe('global AI tools', () => { } }) expect(AppService.updateAppRaw).not.toHaveBeenCalled() - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined() expect(JSON.parse(raw)).toMatchObject({ success: true, type: 'app', @@ -1143,7 +1214,7 @@ describe('global AI tools', () => { it('deploys an existing raw app draft by bundling files and updating the raw app', async () => { vi.mocked(AppService.existsApp).mockResolvedValueOnce(true) - UserDraft.save( + seedDbDraft( 'raw_app', 'f/apps/report', { @@ -1182,14 +1253,14 @@ describe('global AI tools', () => { } }) expect(AppService.createAppRaw).not.toHaveBeenCalled() - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined() }) it('notifies the session preview (as raw_app) after deploying a raw app', async () => { const onDeployed = vi.fn() setDeployedInSessionHandler(onDeployed) try { - UserDraft.save( + seedDbDraft( 'raw_app', 'f/apps/report', { @@ -1430,7 +1501,7 @@ describe('global AI tools', () => { }) it('test_run_flow uses the live flow editor test hook when the active editor matches the path', async () => { - UserDraft.save( + seedDbDraft( 'flow', '', { @@ -1472,7 +1543,7 @@ describe('global AI tools', () => { }) it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => { - UserDraft.save( + seedDbDraft( 'flow', '', { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 64f17d3c9e..fd43134840 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -105,7 +105,9 @@ import { getGlobalDraft, getGlobalDraftStoragePath, listLiveEditorDrafts, + readGlobalDraftValue, saveGlobalAppDraft, + saveGlobalDraftValue, setEphemeralSecretVariableDraftValue, triggerKindToUserDraftKind } from './userDraftAdapter' @@ -1012,7 +1014,7 @@ function appDraftMeta(app: { versions?: number[] }): UserDraftMeta { } async function loadAppValueForRead(path: string, workspace: string): Promise { - const draft = getGlobalDraft(workspace, 'app', path) + const draft = await getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return draft.value as AppDraftValue } @@ -1022,7 +1024,7 @@ async function loadAppValueForRead(path: string, workspace: string): Promise { - const draft = getGlobalDraft(workspace, 'app', path) + const draft = await getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return { value: draft.value as AppDraftValue } } @@ -1037,7 +1039,7 @@ function saveAppDraft( path: string, value: AppDraftValue, meta?: UserDraftMeta -): WorkspaceItem { +): Promise { return saveGlobalAppDraft(workspace, path, value, meta) } @@ -1547,7 +1549,7 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: message, error: message }) return JSON.stringify({ success: false, error: message }) } - const draft = getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) + const draft = await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) if (draft) { toolCallbacks.setToolStatus(toolId, { content: `Read local draft ${parsed.type} "${parsed.path}"` @@ -2167,17 +2169,17 @@ function buildVariableDeployRequestBody( function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: string): void { ctx.toolCallbacks.setToolStatus(ctx.toolId, { - content: `Saving ${type} "${path}" to local storage…` + content: `Saving ${type} "${path}" draft…` }) } -function getRequiredGlobalDraft( +async function getRequiredGlobalDraft( workspace: string, type: WorkspaceItemType, path: string, triggerKind?: TriggerKind -): WorkspaceItem { - const draft = getGlobalDraft(workspace, type, path, triggerKind) +): Promise { + const draft = await getGlobalDraft(workspace, type, path, triggerKind) if (!draft) { throw new Error(`Could not read written draft ${type} "${path}".`) } @@ -2192,13 +2194,13 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra : stored ctx.toolCallbacks.setToolStatus(ctx.toolId, { - content: `${verb} ${stored.type} "${stored.path}" in local storage`, - result: `Saved to local storage` + content: `${verb} ${stored.type} "${stored.path}" draft`, + result: `Saved draft` }) return JSON.stringify( { success: true, - message: `${verb} ${stored.type} "${stored.path}" in local storage (a browser-only local draft, not a workspace draft). It was not deployed.`, + message: `${verb} ${stored.type} "${stored.path}" as a draft (a personal draft saved to your account, not yet deployed to the workspace).`, item: serializedItem }, null, @@ -2214,7 +2216,7 @@ async function writeScriptDraft( startDraftWrite(ctx, 'script', args.path) const storagePath = getGlobalDraftStoragePath(workspace, 'script', args.path) - const existingDraft = UserDraft.get('script', storagePath, { workspace }) + const existingDraft = await readGlobalDraftValue(workspace, 'script', storagePath) const backendExists = existingDraft ? false : await ScriptService.existsScriptByPath({ workspace, path: args.path }) @@ -2227,7 +2229,7 @@ async function writeScriptDraft( content: args.content, language: args.language } - UserDraft.save('script', storagePath, draft, { workspace }) + await saveGlobalDraftValue(workspace, 'script', storagePath, draft) } else if (backendExists) { const existing = await ScriptService.getScriptByPath({ workspace, @@ -2242,13 +2244,9 @@ async function writeScriptDraft( content: args.content, language: args.language } - UserDraft.setDraftAndMeta( - 'script', - storagePath, - draft, - { remoteRev: existing.hash }, - { workspace } - ) + await saveGlobalDraftValue(workspace, 'script', storagePath, draft, { + remoteRev: existing.hash + }) } else { const draft: NewScript = { path: args.path, @@ -2260,11 +2258,11 @@ async function writeScriptDraft( language: args.language, kind: 'script' } - UserDraft.save('script', storagePath, draft, { workspace }) + await saveGlobalDraftValue(workspace, 'script', storagePath, draft) } return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'script', args.path), + await getRequiredGlobalDraft(workspace, 'script', args.path), existingDraft !== undefined || backendExists, ctx ) @@ -2284,7 +2282,7 @@ async function writeFlowDraft( value.groups = structuredClone(draftValue.groups) } - const existingDraft = UserDraft.get('flow', storagePath, { workspace }) + const existingDraft = await readGlobalDraftValue(workspace, 'flow', storagePath) const backendExists = existingDraft ? false : await FlowService.existsFlowByPath({ workspace, path: args.path }) @@ -2297,7 +2295,7 @@ async function writeFlowDraft( value, schema: draftValue.schema ?? existingDraft.schema } - UserDraft.save('flow', storagePath, draft, { workspace }) + await saveGlobalDraftValue(workspace, 'flow', storagePath, draft) } else if (backendExists) { const [existing, latestVersion] = await Promise.all([ FlowService.getFlowByPath({ workspace, path: args.path }), @@ -2310,13 +2308,9 @@ async function writeFlowDraft( value, schema: draftValue.schema ?? existing.schema } - UserDraft.setDraftAndMeta( - 'flow', - storagePath, - draft, - { remoteRev: latestVersion.id }, - { workspace } - ) + await saveGlobalDraftValue(workspace, 'flow', storagePath, draft, { + remoteRev: latestVersion.id + }) } else { const draft: Flow = { path: args.path, @@ -2328,11 +2322,11 @@ async function writeFlowDraft( archived: false, extra_perms: {} } - UserDraft.save('flow', storagePath, draft, { workspace }) + await saveGlobalDraftValue(workspace, 'flow', storagePath, draft) } return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'flow', args.path), + await getRequiredGlobalDraft(workspace, 'flow', args.path), existingDraft !== undefined || backendExists, ctx ) @@ -2342,9 +2336,11 @@ async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promis const { workspace } = ctx startDraftWrite(ctx, 'schedule', args.path) - const existingDraft = UserDraft.get('trigger_schedule', args.path, { - workspace - }) + const existingDraft = await readGlobalDraftValue( + workspace, + 'trigger_schedule', + args.path + ) const backendExists = existingDraft ? false : await ScheduleService.existsSchedule({ workspace, path: args.path }) @@ -2359,10 +2355,10 @@ async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promis : undefined const draft = mergeDraftConfig(base, args as DraftConfig, args.path) - UserDraft.save('trigger_schedule', args.path, draft, { workspace }) + await saveGlobalDraftValue(workspace, 'trigger_schedule', args.path, draft) return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'schedule', args.path), + await getRequiredGlobalDraft(workspace, 'schedule', args.path), existingDraft !== undefined || backendExists, ctx ) @@ -2378,7 +2374,7 @@ async function writeTriggerDraft( const itemKind = triggerKindToUserDraftKind(args.kind) startDraftWrite(ctx, 'trigger', path) - const existingDraft = UserDraft.get(itemKind, path, { workspace }) + const existingDraft = await readGlobalDraftValue(workspace, itemKind, path) const backendExists = existingDraft ? false : await triggerServices[args.kind].exists({ workspace, path }) @@ -2390,10 +2386,10 @@ async function writeTriggerDraft( : undefined const draft = mergeDraftConfig(base, config, path) - UserDraft.save(itemKind, path, draft, { workspace }) + await saveGlobalDraftValue(workspace, itemKind, path, draft) return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'trigger', path, args.kind), + await getRequiredGlobalDraft(workspace, 'trigger', path, args.kind), existingDraft !== undefined || backendExists, ctx ) @@ -2403,30 +2399,37 @@ async function writeResourceDraft(args: CreateResource, ctx: WriteDraftCtx): Pro const { workspace } = ctx startDraftWrite(ctx, 'resource', args.path) - const existingDraft = UserDraft.get('resource', args.path, { workspace }) + const existingDraft = await readGlobalDraftValue( + workspace, + 'resource', + args.path + ) const backendExists = existingDraft ? false : await ResourceService.existsResource({ workspace, path: args.path }) if (existingDraft) { - UserDraft.save('resource', args.path, createResourceToDraftState(args, existingDraft), { - workspace - }) + await saveGlobalDraftValue( + workspace, + 'resource', + args.path, + createResourceToDraftState(args, existingDraft) + ) } else if (backendExists) { const existing = await ResourceService.getResource({ workspace, path: args.path }) - UserDraft.setDraftAndMeta( + await saveGlobalDraftValue( + workspace, 'resource', args.path, createResourceToDraftState(args, resourceToDraftState(existing)), - { remoteRev: existing.edited_at }, - { workspace } + { remoteRev: existing.edited_at } ) } else { - UserDraft.save('resource', args.path, createResourceToDraftState(args), { workspace }) + await saveGlobalDraftValue(workspace, 'resource', args.path, createResourceToDraftState(args)) } return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'resource', args.path), + await getRequiredGlobalDraft(workspace, 'resource', args.path), existingDraft !== undefined || backendExists, ctx ) @@ -2436,35 +2439,42 @@ async function writeVariableDraft(args: CreateVariable, ctx: WriteDraftCtx): Pro const { workspace } = ctx startDraftWrite(ctx, 'variable', args.path) - const existingDraft = UserDraft.get('variable', args.path, { workspace }) + const existingDraft = await readGlobalDraftValue( + workspace, + 'variable', + args.path + ) const backendExists = existingDraft ? false : await VariableService.existsVariable({ workspace, path: args.path }) if (existingDraft) { - UserDraft.save('variable', args.path, createVariableToDraftState(args, existingDraft), { - workspace - }) + await saveGlobalDraftValue( + workspace, + 'variable', + args.path, + createVariableToDraftState(args, existingDraft) + ) } else if (backendExists) { const existing = await VariableService.getVariable({ workspace, path: args.path, decryptSecret: false }) - UserDraft.setDraftAndMeta( + await saveGlobalDraftValue( + workspace, 'variable', args.path, createVariableToDraftState(args, variableToDraftState(existing)), - { remoteRev: existing.edited_at }, - { workspace } + { remoteRev: existing.edited_at } ) } else { - UserDraft.save('variable', args.path, createVariableToDraftState(args), { workspace }) + await saveGlobalDraftValue(workspace, 'variable', args.path, createVariableToDraftState(args)) } syncEphemeralSecretVariableDraftValue(workspace, args) return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'variable', args.path), + await getRequiredGlobalDraft(workspace, 'variable', args.path), existingDraft !== undefined || backendExists, ctx ) @@ -2474,7 +2484,7 @@ async function loadScriptForEdit( path: string, workspace: string ): Promise<{ content: string; language: ScriptLang; summary?: string }> { - const draft = getGlobalDraft(workspace, 'script', path) + const draft = await getGlobalDraft(workspace, 'script', path) if (draft) { if (typeof draft.value !== 'string' || !draft.language) { throw new Error(`Draft script "${path}" is missing content or language.`) @@ -2509,7 +2519,7 @@ async function loadFlowDraftValue( path: string, workspace: string ): Promise<{ flow: FlowDraftValue; summary?: string }> { - const draft = getGlobalDraft(workspace, 'flow', path) + const draft = await getGlobalDraft(workspace, 'flow', path) if (draft) { if (draft.value === undefined || typeof draft.value === 'string') { throw new Error(`Draft flow "${path}" has no value.`) @@ -2634,7 +2644,7 @@ async function loadScriptForFlowStep( moduleValue: { path: string; hash?: string }, workspace: string ): Promise<{ content: string; language: ScriptLang }> { - const draft = getGlobalDraft(workspace, 'script', moduleValue.path) + const draft = await getGlobalDraft(workspace, 'script', moduleValue.path) if (draft) { if (typeof draft.value !== 'string' || !draft.language) { throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`) @@ -2652,7 +2662,7 @@ async function loadDraftFlowPreviewValue( path: string, workspace: string ): Promise { - if (!getGlobalDraft(workspace, 'flow', path)) { + if (!(await getGlobalDraft(workspace, 'flow', path))) { return undefined } const nestedFlow = await loadFlowDraftValue(path, workspace) @@ -2772,7 +2782,7 @@ async function initApp( const { workspace, toolId, toolCallbacks } = ctx const { path, summary, framework } = args - if (getGlobalDraft(workspace, 'app', path)) { + if (await getGlobalDraft(workspace, 'app', path)) { throw new Error( `A local draft for app "${path}" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first.` ) @@ -2784,7 +2794,7 @@ async function initApp( } toolCallbacks.setToolStatus(toolId, { - content: `Saving app "${path}" to local storage (${framework} template)…` + content: `Saving app "${path}" draft (${framework} template)…` }) const template = FRAMEWORK_TEMPLATES[framework] @@ -2794,16 +2804,16 @@ async function initApp( runnables: { [STARTER_RUNNABLE_KEY]: { ...STARTER_RUNNABLE } } } await recomputeAppPolicy(value) - const stored = saveAppDraft(workspace, path, value) + const stored = await saveAppDraft(workspace, path, value) toolCallbacks.setToolStatus(toolId, { - content: `Saved app "${path}" to local storage (${framework})`, - result: 'Saved to local storage' + content: `Saved app "${path}" draft (${framework})`, + result: 'Saved draft' }) return JSON.stringify( { success: true, - message: `Initialized app "${path}" in local storage from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (a browser-only local draft, not a workspace draft). Use write_app_file / write_app_runnable to evolve it.`, + message: `Initialized app "${path}" draft from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (a personal draft saved to your account, not yet deployed to the workspace). Use write_app_file / write_app_runnable to evolve it.`, item: stored }, null, @@ -2856,16 +2866,16 @@ async function writeAppFile( const { value, meta } = await loadAppDraftValue(args.path, workspace) value.files = { ...value.files, [target.filePath]: args.content } - const stored = saveAppDraft(workspace, args.path, value, meta) + const stored = await saveAppDraft(workspace, args.path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Updated ${target.filePath} in app "${args.path}"`, - result: 'Saved to local storage' + result: 'Saved draft' }) return JSON.stringify( { success: true, - message: `Updated app "${args.path}" in local storage with frontend file "${target.filePath}".`, + message: `Updated app "${args.path}" (draft) with frontend file "${target.filePath}".`, item: stored }, null, @@ -2896,16 +2906,16 @@ async function deleteAppFile( } const { [target.filePath]: _removed, ...remaining } = value.files value.files = remaining - const stored = saveAppDraft(workspace, args.path, value, meta) + const stored = await saveAppDraft(workspace, args.path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Removed ${target.filePath} from app "${args.path}"`, - result: 'Saved to local storage' + result: 'Saved draft' }) return JSON.stringify( { success: true, - message: `Removed "${target.filePath}" from app "${args.path}" in local storage.`, + message: `Removed "${target.filePath}" from app "${args.path}" (draft).`, item: stored }, null, @@ -2980,15 +2990,15 @@ async function patchAppFile( } } - const stored = saveAppDraft(workspace, path, value, meta) + const stored = await saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Patched ${target.filePath} in app "${path}"`, - result: 'Saved to local storage' + result: 'Saved draft' }) return JSON.stringify( { success: true, - message: `Patched "${target.filePath}" in app "${path}" in local storage.`, + message: `Patched "${target.filePath}" in app "${path}" (draft).`, item: stored }, null, @@ -3022,16 +3032,16 @@ async function writeAppRunnable( const persisted = buildPersistedRunnable(input, existing) value.runnables = { ...value.runnables, [key]: persisted } await recomputeAppPolicy(value) - const stored = saveAppDraft(workspace, path, value, meta) + const stored = await saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Updated runnable "${key}" in app "${path}"`, - result: 'Saved to local storage' + result: 'Saved draft' }) return JSON.stringify( { success: true, - message: `Updated app "${path}" in local storage with runnable "${key}".`, + message: `Updated app "${path}" (draft) with runnable "${key}".`, item: stored }, null, @@ -3056,16 +3066,16 @@ async function deleteAppRunnable( const { [key]: _removed, ...remaining } = value.runnables value.runnables = remaining await recomputeAppPolicy(value) - const stored = saveAppDraft(workspace, path, value, meta) + const stored = await saveAppDraft(workspace, path, value, meta) toolCallbacks.setToolStatus(toolId, { content: `Removed runnable "${key}" from app "${path}"`, - result: 'Saved to local storage' + result: 'Saved draft' }) return JSON.stringify( { success: true, - message: `Removed runnable "${key}" from app "${path}" in local storage.`, + message: `Removed runnable "${key}" from app "${path}" (draft).`, item: stored }, null, @@ -3143,21 +3153,21 @@ async function discardLocalDraft( throw new Error('trigger_kind is required when discarding a trigger draft.') } - const draft = getGlobalDraft(workspace, type, path, triggerKind) + const draft = await getGlobalDraft(workspace, type, path, triggerKind) if (!draft) { throw new Error(`No local draft found for ${type} "${path}".`) } - deleteGlobalDraft(workspace, type, path, triggerKind) + await deleteGlobalDraft(workspace, type, path, triggerKind) toolCallbacks.setToolStatus(toolId, { - content: `Discarded ${type} "${path}" from local storage`, - result: 'Discarded from local storage' + content: `Discarded ${type} "${path}" draft`, + result: 'Discarded draft' }) return JSON.stringify( { success: true, - message: `Discarded the local-storage draft for ${type} "${path}". The deployed workspace item was not changed.`, + message: `Discarded the draft for ${type} "${path}". The deployed workspace item was not changed.`, type, path, triggerKind @@ -3183,7 +3193,7 @@ async function deployDraft( throw new Error('trigger_kind is required when deploying a trigger.') } - const draft = getGlobalDraft(workspace, type, path, triggerKind) + const draft = await getGlobalDraft(workspace, type, path, triggerKind) if (!draft) { throw new Error(`No local draft found for ${type} "${path}".`) } @@ -3363,7 +3373,7 @@ async function deployDraft( } } - deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) + await deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) // Reload the session preview if it's open on the deployed item. Map the // deploy type to the preview kind — a raw app deploys under 'app' but the @@ -3433,7 +3443,7 @@ async function deleteWorkspaceItem( break } - deleteGlobalDraft(workspace, type, path, triggerKind) + await deleteGlobalDraft(workspace, type, path, triggerKind) toolCallbacks.setToolStatus(toolId, { content: `Deleted ${type} "${path}"`, diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index bb8657aa3e..345603a2e3 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -1,4 +1,5 @@ import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' +import { ApiError, DraftService } from '$lib/gen' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { UserDraft, @@ -6,6 +7,7 @@ import { type UserDraftItemKind, type UserDraftMeta } from '$lib/userDraft.svelte' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { type AppDraftValue, type ResourceDraftState, @@ -298,7 +300,78 @@ export function getGlobalDraftStoragePath( return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path } -function getGlobalDraftSlot( +function isNotFoundError(e: unknown): boolean { + return e instanceof ApiError && e.status === 404 +} + +/** + * Raw draft value for `(workspace, itemKind, storagePath)`. The in-tab + * mounted cell wins — it's the freshest, holding any unsaved live-editor + * edits that haven't been flushed to the server yet — otherwise the + * per-user DB draft. Returns `undefined` when no draft exists in either + * place. Async because the headless fallback is a fetch. + * + * Post #9351 the global mode is headless (never mounts a handle), so the + * in-memory branch only hits when an editor is open on the same item; the + * DB branch is the common one and is what makes a draft written in a + * previous turn / tab readable here. + */ +export async function readGlobalDraftValue( + workspace: string, + itemKind: UserDraftItemKind, + storagePath: string +): Promise { + const local = UserDraft.get(itemKind, storagePath, { workspace }) + if (local !== undefined) return local + try { + const resp = await DraftService.getDraft({ workspace, kind: itemKind, path: storagePath }) + return resp.value as V + } catch (e) { + if (isNotFoundError(e)) return undefined + throw e + } +} + +/** + * Persist a draft value for `(workspace, itemKind, storagePath)`. + * + * When an editor handle is mounted for this entry, route through the + * in-memory `UserDraft` layer so the open editor reflects the write + * reactively (its background syncer still persists it). When headless — + * the common path for AI-driven writes — push straight to the DB and + * AWAIT it, so a read-back immediately after sees the value: this closes + * the read-after-write gap (`UserDraft.save` alone never seeds the in-tab + * cell for an unmounted entry, so a subsequent `UserDraft.get` would + * return `undefined`). Forced — AI overwrites have no human at a conflict + * modal. Rev metadata is in-memory only and is dropped on the headless + * path (the DB stores values, not revs); the next editor mount reseeds it. + */ +export async function saveGlobalDraftValue( + workspace: string, + itemKind: UserDraftItemKind, + storagePath: string, + value: V, + meta?: UserDraftMeta +): Promise { + if (UserDraft.isLive(itemKind, storagePath, { workspace })) { + if (meta) { + UserDraft.setDraftAndMeta(itemKind, storagePath, value, meta, { workspace }) + } else { + UserDraft.save(itemKind, storagePath, value, { workspace }) + } + return + } + await UserDraftDbSyncer.save({ + workspace, + itemKind, + path: storagePath, + value, + immediate: true, + force: true + }) +} + +async function getGlobalDraftSlot( workspace: string, type: WorkspaceItemType, path: string, @@ -307,7 +380,7 @@ function getGlobalDraftSlot( const itemKind = itemKindFor(type, triggerKind) if (!itemKind) return undefined const storagePath = resolveDraftStoragePath(workspace, itemKind, path) - const draft = UserDraft.get(itemKind, storagePath, { workspace }) + const draft = await readGlobalDraftValue(workspace, itemKind, storagePath) if (draft === undefined) return undefined const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath) @@ -325,13 +398,13 @@ function getGlobalDraftSlot( return { itemKind, storagePath, displayPath, item } } -export function getGlobalDraft( +export async function getGlobalDraft( workspace: string, type: WorkspaceItemType, path: string, triggerKind?: TriggerKind -): WorkspaceItem | undefined { - return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item +): Promise { + return (await getGlobalDraftSlot(workspace, type, path, triggerKind))?.item } const LIVE_EDITOR_DRAFT_KINDS = [ @@ -385,20 +458,16 @@ export function listLiveEditorDrafts( return out } -export function saveGlobalAppDraft( +export async function saveGlobalAppDraft( workspace: string, path: string, value: AppDraftValue, meta?: UserDraftMeta -): WorkspaceItem { +): Promise { 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) + await saveGlobalDraftValue(workspace, 'raw_app', storagePath, normalized, meta) + const stored = await getGlobalDraft(workspace, 'app', path) if (!stored) throw new Error(`Could not read written app draft "${path}".`) return stored } @@ -407,28 +476,56 @@ type DeleteGlobalDraftOptions = { preserveLiveDraft?: boolean } -export function deleteGlobalDraft( +export async function deleteGlobalDraft( workspace: string, type: WorkspaceItemType, path: string, triggerKind?: TriggerKind, options: DeleteGlobalDraftOptions = {} -): void { +): Promise { 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 }) + if (UserDraft.isLive(itemKind, storagePath, { workspace })) { + // An editor is open on this draft: reset its in-memory cell so the + // UI reflects the delete (`remove`/`clear` both POST `value: null`). + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) { + UserDraft.remove(itemKind, storagePath, { workspace }) + } else { + UserDraft.clear(itemKind, storagePath, { workspace }) + } } else { - UserDraft.clear(itemKind, storagePath, { workspace }) + // Headless: delete the DB row directly and await it so a read-back + // in the same turn no longer sees the draft. + await UserDraftDbSyncer.save({ + workspace, + itemKind, + path: storagePath, + value: null, + immediate: true, + force: true + }) } 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 }) +export async function clearGlobalDrafts(workspace: string): Promise { + // `UserDraft.list` only sees in-tab mounted entries post #9351, so + // enumerate the user's persisted drafts from the DB instead and delete + // each global-mode kind. + const kinds = new Set(GLOBAL_DRAFT_KINDS) + const drafts = await DraftService.listDrafts({ workspace }) + for (const draft of drafts) { + if (!kinds.has(draft.typ)) continue + await UserDraftDbSyncer.save({ + workspace, + itemKind: draft.typ, + path: draft.path, + value: null, + immediate: true, + force: true + }) } clearEphemeralSecretVariableDraftValues(workspace) } diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index ee6255a9e3..9a106bff39 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -408,6 +408,20 @@ export const UserDraft = { return entry.state.val !== undefined }, + /** + * Whether a live handle/entry is currently mounted for + * `(workspace, itemKind, path)` in this tab — regardless of whether + * it holds a draft value yet. Distinct from `has`, which additionally + * requires a non-`undefined` value. Headless callers (the global AI + * chat) use this to decide whether a write should flow through the + * in-memory cell — so a mounted editor reflects it reactively — or go + * straight to the DB syncer. + */ + isLive(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean { + const ws = resolveWorkspace(opts) + return entries.has(mapKey(ws, itemKind, path)) + }, + remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path)