feat(drafts): back global chat draft read/write with db layer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-08 15:22:04 +02:00
co-authored by Claude Opus 4.8
parent b1d48fe858
commit b47fabc8fc
5 changed files with 425 additions and 191 deletions
@@ -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<V>(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<V>(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.
@@ -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<string, { value: unknown; saved_at: string }>()
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<any>('$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<T = any>(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<T>(run: () => Promise<T>): Promise<T> {
vi.useFakeTimers()
try {
@@ -193,10 +269,11 @@ async function withCompletedTestJob<T>(run: () => Promise<T>): Promise<T> {
}
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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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<any>('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',
'',
{
@@ -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<AppDraftValue> {
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<App
}
async function loadAppDraftValue(path: string, workspace: string): Promise<LoadedAppDraftValue> {
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<WorkspaceItem> {
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<WorkspaceItem> {
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<NewScript>('script', storagePath, { workspace })
const existingDraft = await readGlobalDraftValue<NewScript>(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>('flow', storagePath, { workspace })
const existingDraft = await readGlobalDraftValue<Flow>(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<ScheduleDraftConfig>('trigger_schedule', args.path, {
workspace
})
const existingDraft = await readGlobalDraftValue<ScheduleDraftConfig>(
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<ScheduleDraftConfig>(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<TriggerDraftConfig>(itemKind, path, { workspace })
const existingDraft = await readGlobalDraftValue<TriggerDraftConfig>(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<TriggerDraftConfig>(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<ResourceDraftState>('resource', args.path, { workspace })
const existingDraft = await readGlobalDraftValue<ResourceDraftState>(
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<VariableDraftState>('variable', args.path, { workspace })
const existingDraft = await readGlobalDraftValue<VariableDraftState>(
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<FlowValue | undefined> {
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}"`,
@@ -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<V>(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string
): Promise<V | undefined> {
const local = UserDraft.get<V>(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<V>(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string,
value: V,
meta?: UserDraftMeta
): Promise<void> {
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<WorkspaceItem | undefined> {
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<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)
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<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 })
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<void> {
// `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<UserDraftItemKind>(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)
}
+14
View File
@@ -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)