From c4b4961ff66a0c35ad4c48eedcf2236e1307874c Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 23 Sep 2026 12:26:13 +0200 Subject: [PATCH] fix: route a draft's chosen name to the draft it names in the AI chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A draft created in the editor is stored at a generated path until it is deployed, with the user-chosen name in the value's `draft_path` (a script keeps its chosen name in the value's own `path`); a staged rename keeps the storage path and puts the new name in `draft_path`. The chat resolved a chosen name only while that item was open in an editor, so once the tab closed a tool call with the name found no draft and fell back to the deployed item — a 404 for anything never deployed. Route chosen names in `resolveDraftStoragePath`, which every draft read, write, delete and deploy already goes through. A per-workspace table of the user's drafts and their names is loaded before each tool call; the resolver prefers the open editor, then an exact storage-path match, leaves a name with a deployed item at it alone, routes a name used by exactly one draft, and refuses when several drafts share one. `open_preview` routes through it too. The model is also given both names: the active-editor block, mentions and the list and read tools now expose the storage path as `path` with the chosen name as `draft_path`. Deploying an app whose chosen name belongs to a different deployed app now fails instead of overwriting that app, and the stale-draft guard for scripts, flows and apps reads the item the draft is stored at rather than the path it was addressed by. Co-Authored-By: Claude Opus 5 (1M context) --- .../copilot/chat/ChatContextPicker.svelte | 3 + .../chat/WorkspaceMentionPicker.svelte | 6 +- .../lib/components/copilot/chat/context.ts | 5 + .../copilot/chat/global/core.test.ts | 145 +++++++++++++++++- .../components/copilot/chat/global/core.ts | 104 +++++++++++-- .../copilot/chat/global/userDraftAdapter.ts | 113 +++++++++++++- 6 files changed, 352 insertions(+), 24 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte b/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte index 68a65d75fc..f127710660 100644 --- a/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte @@ -303,6 +303,7 @@ Content is materialized at message-prep time by `AIChatManager` — see PR #9216 path: d.path, title: d.path, summary: d.summary, + draftPath: d.draftPath, deletable: true } onSelectWorkspaceItem(element) @@ -312,6 +313,7 @@ Content is materialized at message-prep time by `AIChatManager` — see PR #9216 path: d.path, title: d.path, summary: d.summary, + draftPath: d.draftPath, deletable: true } onSelectWorkspaceItem(element) @@ -322,6 +324,7 @@ Content is materialized at message-prep time by `AIChatManager` — see PR #9216 path: d.path, title: d.path, summary: d.summary, + draftPath: d.draftPath, deletable: true } onSelectWorkspaceItem(element) diff --git a/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte b/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte index 0fb21f684f..96ea6bc985 100644 --- a/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte +++ b/frontend/src/lib/components/copilot/chat/WorkspaceMentionPicker.svelte @@ -66,6 +66,7 @@ at the workspace root because the parent menu item already names the scope. path: item.path, title: item.path, summary: item.summary, + draftPath: item.draftPath, deletable: true } satisfies WorkspaceScriptElement & { deletable: boolean } } @@ -75,6 +76,7 @@ at the workspace root because the parent menu item already names the scope. path: item.path, title: item.path, summary: item.summary, + draftPath: item.draftPath, deletable: true } satisfies WorkspaceFlowElement & { deletable: boolean } } @@ -84,6 +86,7 @@ at the workspace root because the parent menu item already names the scope. path: item.path, title: item.path, summary: item.summary, + draftPath: item.draftPath, deletable: true } satisfies WorkspaceAppElement & { deletable: boolean } } @@ -140,7 +143,8 @@ at the workspace root because the parent menu item already names the scope. onPick={handlePick} {leafIcon} {branchIcon} - leafSecondary={(leaf, scope) => relativizeWorkspacePath(workspaceItemDisplayPath(leaf.data), scope)} + leafSecondary={(leaf, scope) => + relativizeWorkspacePath(workspaceItemDisplayPath(leaf.data), scope)} onScopeChange={handleScopeChange} onFilterChange={loader.onFilterChange} rootLoading={WORKSPACE_KINDS.some((k) => !loader.loaded[k] && loader.loadingKind[k])} diff --git a/frontend/src/lib/components/copilot/chat/context.ts b/frontend/src/lib/components/copilot/chat/context.ts index f57862c179..ba92694845 100644 --- a/frontend/src/lib/components/copilot/chat/context.ts +++ b/frontend/src/lib/components/copilot/chat/context.ts @@ -263,9 +263,12 @@ export function flattenDatatablesToAppContextElements( /** Workspace script context element — reference to a script in the workspace */ export interface WorkspaceScriptElement { type: 'workspace_script' + /** Storage path: what tools take. A never-deployed draft's is a generated `draft_`. */ path: string title: string summary?: string + /** The name a draft deploys under, when it differs from `path`. */ + draftPath?: string } /** Workspace flow context element — reference to a flow in the workspace */ @@ -274,6 +277,7 @@ export interface WorkspaceFlowElement { path: string title: string summary?: string + draftPath?: string } /** Workspace app context element — reference to a (code-based) raw app in the @@ -284,6 +288,7 @@ export interface WorkspaceAppElement { path: string title: string summary?: string + draftPath?: string } /** Message-attached text file, rendered with the shared context badge. Never part 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 7a86e0ba76..21550f8ea1 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -332,8 +332,10 @@ vi.mock('$lib/gen', async () => { const idx = key.indexOf(':') const path = key.slice(idx + 1) // Like the real endpoint: friendly path from the draft JSON, only - // when set and different from the storage path. - const draftPath = (value as any)?.draft_path + // when set and different from the storage path — a script's is its + // own `path`, the other kinds' is `draft_path` (drafts.rs). + const draftPath = + key.slice(0, idx) === 'script' ? (value as any)?.path : (value as any)?.draft_path return { kind: key.slice(0, idx), path, @@ -2407,6 +2409,81 @@ describe('global AI tools', () => { expect(body.force_viewer_sensitive_inputs).toEqual(['api_key']) }) + // A never-deployed app has nothing at its chosen name but the draft_path of a draft stored + // elsewhere, so falling back to the deployed app there 404s. + it('addresses a never-deployed app by the draft_path it is staged under', async () => { + seedBackendDraft( + 'raw_app', + 'u/admin/draft_0001', + { + summary: 'Staged app', + draft_path: 'u/admin/staged_app', + files: {}, + runnables: { + greet: { + name: 'Greet', + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() { return 1 }' } + } + }, + data: { tables: [] } + }, + { workspace: WORKSPACE } + ) + + await callGlobalTool('test_run_app_runnable', { path: 'u/admin/staged_app', key: 'greet' }) + const body = vi.mocked(AppService.executeComponent).mock.calls.at(-1)?.[0].requestBody as any + expect(body.raw_code).toMatchObject({ language: 'bun' }) + + await callGlobalTool('write_app_file', { + path: 'u/admin/staged_app', + file_path: '/index.tsx', + content: 'x' + }) + // Saved back where it was read from: a write at the chosen name would fork the app + // into a second draft. + expect(getBackendDraft('raw_app', 'u/admin/draft_0001').files['/index.tsx']).toBe('x') + expect(getBackendDraft('raw_app', 'u/admin/staged_app')).toBeUndefined() + }) + + it('refuses a draft_path that two drafts are staged under', async () => { + for (const storage of ['u/admin/draft_a', 'u/admin/draft_b']) { + seedBackendDraft( + 'raw_app', + storage, + { draft_path: 'u/admin/twin', files: {}, runnables: {}, data: { tables: [] } }, + { workspace: WORKSPACE } + ) + } + await expect( + callGlobalTool('test_run_app_runnable', { path: 'u/admin/twin', key: 'greet' }) + ).rejects.toThrow(/u\/admin\/draft_a, u\/admin\/draft_b/) + }) + + // The chosen name resolves through the live editor only while it stays open; the storage + // path keeps resolving after the tab closes. + it("hands the model a live app editor's storage path", async () => { + seedBackendDraft( + 'raw_app', + 'u/admin/draft_live', + { draft_path: 'u/admin/chosen', files: {}, runnables: {}, data: { tables: [] } }, + { workspace: WORKSPACE } + ) + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'raw_app', + storagePath: 'u/admin/draft_live', + effectivePath: 'u/admin/chosen' + }) + + const listed = JSON.parse(await callGlobalTool('list_workspace_items', { types: ['app'] })) + expect(listed).toContainEqual( + expect.objectContaining({ path: 'u/admin/draft_live', draftPath: 'u/admin/chosen' }) + ) + const message = prepareGlobalUserMessage('hi', [], { workspace: WORKSPACE }) + expect(message.content).toContain('path: u/admin/draft_live\ndraft_path: u/admin/chosen\n') + }) + // The undeployed-flow 404 is the whole reason this tool exists, and the generated client // leaves the server's message in `body` while `message` is the bare status text. it("surfaces the server's message when a path runnable's target is not deployed", async () => { @@ -3162,6 +3239,36 @@ describe('global AI tools', () => { expect(ScriptService.createScript).not.toHaveBeenCalled() }) + // A script's chosen name is its value's own `path`, and a rename stays stored at the + // deployed path — so the draft this addresses is based on the item at that old path. + it('guards a renamed script draft against the version it was started from', async () => { + seedBackendDraft('script', 'f/scripts/renamed_old', { + path: 'f/scripts/renamed_new', + summary: 's', + description: '', + content: 'draft content', + language: 'bun', + kind: 'script', + parent_hash: 'base-hash', + schema: {} + }) + vi.mocked(ScriptService.existsScriptByPath).mockImplementation( + async ({ path }) => path === 'f/scripts/renamed_old' + ) + vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({ + path: 'f/scripts/renamed_old', + hash: 'new-hash', + content: 'latest deployed', + language: 'bun', + summary: 's' + } as any) + + await expect( + callGlobalTool('deploy_workspace_item', { type: 'script', path: 'f/scripts/renamed_new' }) + ).rejects.toThrow(/older deployed version/) + expect(ScriptService.createScript).not.toHaveBeenCalled() + }) + it('deploys a stale script draft when force is set', async () => { seedStaleScriptDraft('f/scripts/stale', 'base-hash') mockDeployedScript('f/scripts/stale', 'new-hash') @@ -4393,6 +4500,37 @@ describe('global AI tools', () => { ) }) + // Nothing checks a chosen name before deploy, so a draft can be staged under the path of + // an app that is already deployed. + it('keeps a deployed app at a name a draft is staged under', async () => { + const storageKey = 'u/admin/draft_clash' + const takenPath = 'f/team/taken_app' + seedBackendDraft( + 'raw_app', + storageKey, + { summary: 'Clash', files: {}, runnables: {}, data: { tables: [] }, draft_path: takenPath }, + { workspace: WORKSPACE } + ) + vi.mocked(AppService.existsApp).mockImplementation(async ({ path }) => path === takenPath) + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ + draft: { draft_path: takenPath } + } as any) + try { + // The name addresses the deployed app, which has no draft, not the draft staged under it… + await expect( + callGlobalTool('deploy_workspace_item', { type: 'app', path: takenPath }) + ).rejects.toThrow(/No draft found/) + // …and deploying that draft refuses to overwrite the deployed app. + await expect( + callGlobalTool('deploy_workspace_item', { type: 'app', path: storageKey }) + ).rejects.toThrow(/another app is already deployed there/) + expect(AppService.updateAppRaw).not.toHaveBeenCalled() + expect(AppService.createAppRaw).not.toHaveBeenCalled() + } finally { + vi.mocked(AppService.existsApp).mockImplementation(async () => false) + } + }) + it('aborts a raw app deploy when the draft_path lookup fails (non-404)', async () => { // A real lookup failure (network/5xx) must abort, not silently fall back to the // storage path and deploy there. Only a 404 justifies the storage-path fallback. @@ -6725,8 +6863,7 @@ describe('session pipeline surface (alpha)', () => { return r } ) - await Promise.resolve() - expect(handler).toHaveBeenCalled() + await vi.waitFor(() => expect(handler).toHaveBeenCalled()) expect(settled).toBe(false) release('opened') expect(await call).toBe('opened') diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 5b0490c724..87cf3fabf9 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -227,6 +227,7 @@ import { getGlobalDraftStoragePath, itemKindFor, listGlobalDrafts, + loadDraftNames, persistGlobalDraft, readGlobalDraftValue, readLocalDraftCellByKind, @@ -1364,6 +1365,7 @@ Rules: - Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items. - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. - If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule} +- A draft's \`draft_path\` is the name it deploys under and what to call it when talking to the user. Tools accept it or the draft's \`path\`; prefer \`path\`, which stays unambiguous when two drafts share a name. After a deploy the item lives at its \`draft_path\`. - Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. - To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path. - Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs. @@ -1865,6 +1867,19 @@ async function loadAppDraftValue(path: string, workspace: string): Promise = { } } -export const globalTools: Tool<{}>[] = [ +const unroutedGlobalTools: Tool<{}>[] = [ readSkillTool, openPageTool, { @@ -3479,7 +3494,8 @@ export const globalTools: Tool<{}>[] = [ if ((parsed.page ?? 1) === 1) { const draftCountByType = new Map() const prefix = parsed.path_prefix - for (const draft of await listGlobalDrafts(workspace)) { + for (const listed of await listGlobalDrafts(workspace)) { + const draft = addressedByStoragePath(workspace, listed) if (!types.includes(draft.type)) continue // A draft's staged name is often not where it is stored: the editor parks // a new script, flow or app at a generated `draft_` path, and a @@ -3542,7 +3558,14 @@ export const globalTools: Tool<{}>[] = [ (draft.value as AppDraftValue)?.policy?.execution_mode ) : undefined - return JSON.stringify(serializeWorkspaceItemForRead({ ...draft, executionMode }), null, 2) + return JSON.stringify( + serializeWorkspaceItemForRead({ + ...addressedByStoragePath(workspace, draft), + executionMode + }), + null, + 2 + ) } toolCallbacks.setToolStatus(toolId, { @@ -4253,7 +4276,7 @@ export const globalTools: Tool<{}>[] = [ ), fn: async (ctx) => { const parsed = openPreviewSchema.parse(ctx.args) - return openSessionPreview(parsed, sessionIdFromCtx(ctx)) + return openSessionPreview(parsed, ctx.workspace, sessionIdFromCtx(ctx)) } }, { @@ -4428,6 +4451,27 @@ export const globalTools: Tool<{}>[] = [ ...fileTools ] +// Every tool reaches drafts through the adapter's resolver, which can route a draft's +// chosen name to where it is stored only with the user's current drafts loaded. +export const globalTools: Tool<{}>[] = unroutedGlobalTools.map((tool) => { + const load = (p: { workspace: string; args: any }) => + loadDraftNames(p.workspace, typeof p.args?.path === 'string' ? p.args.path : undefined) + return { + ...tool, + // Also on the pre-confirmation check: it reads drafts too, and runs before `fn`. + validateBeforeConfirmation: tool.validateBeforeConfirmation + ? async (p) => { + await load(p) + return tool.validateBeforeConfirmation!(p) + } + : undefined, + fn: async (p) => { + await load(p) + return tool.fn(p) + } + } +}) + // Tools that only make sense inside an AI session (they drive the session's // side-panel preview). The regular global side-panel chat shouldn't even be // offered them — see `globalToolsFor`. @@ -4536,7 +4580,10 @@ function liveFlowTestHookFromCtx( path: string ): ((args?: Record, memoryId?: string) => Promise) | undefined { const activeEditor = getActiveGlobalEditorContext(ctx.workspace) - if (activeEditor?.type !== 'flow' || activeEditor.path !== path) { + if ( + activeEditor?.type !== 'flow' || + (activeEditor.path !== path && activeEditor.storagePath !== path) + ) { return undefined } const testActiveFlow = (ctx.helpers as GlobalToolHelpers | undefined)?.testActiveFlow @@ -4559,6 +4606,7 @@ export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined): async function openSessionPreview( args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string }, + workspace: string, sessionId: string | undefined ): Promise { if (!openPreviewHandler) { @@ -4567,7 +4615,12 @@ async function openSessionPreview( // open_preview only exists in sessions, so no sessionId check is needed here. // For a pipeline the handler awaits the editor's tool registration, so the // model's next build_pipeline_node call can't race the async canvas mount. - return await openPreviewHandler({ ...args, sessionId }) + // The preview loads the literal path, so a draft's chosen name is routed here. + const path = + args.kind === 'pipeline' + ? args.path + : getGlobalDraftStoragePath(workspace, args.kind === 'raw_app' ? 'app' : args.kind, args.path) + return await openPreviewHandler({ ...args, path, sessionId }) } // Opens a workspace *page* (Runs, Schedules, …) as a page tab in the session's @@ -7939,14 +7992,16 @@ async function deployDraft( await flushDraftOrThrow({ workspace, itemKind: type, path: storagePath }, `${type} "${path}"`) // Stale-draft guard: block when the draft was forked from an older deploy than // the current head (unless force), pointing the model at rebase_draft. + // The draft's base is the item it is stored at: a rename is staged over the old + // path, and `path` may be the new name the draft is addressed by. if (type === 'script') { - const existing = (await ScriptService.existsScriptByPath({ workspace, path })) - ? await ScriptService.getScriptByPath({ workspace, path }) + const existing = (await ScriptService.existsScriptByPath({ workspace, path: storagePath })) + ? await ScriptService.getScriptByPath({ workspace, path: storagePath }) : undefined assertDraftBasedOnLatest('script', path, draft.parentHash, existing?.hash, force) } else { - const existing = (await FlowService.existsFlowByPath({ workspace, path })) - ? await FlowService.getFlowByPath({ workspace, path }) + const existing = (await FlowService.existsFlowByPath({ workspace, path: storagePath })) + ? await FlowService.getFlowByPath({ workspace, path: storagePath }) : undefined assertDraftBasedOnLatest('flow', path, draft.parentVersionId, existing?.version_id, force) } @@ -8028,8 +8083,10 @@ async function deployDraft( // Stale-draft guard: only fetch the deployed head when the draft records // a fork base to compare against (pre-feature drafts have none). if (draft.parentVersionId != null) { - const deployedApp = (await AppService.existsApp({ workspace, path })) - ? await AppService.getAppByPath({ workspace, path }) + // The draft's base is the app it is stored at, not the name it was addressed by. + const basePath = getGlobalDraftStoragePath(workspace, 'app', path) + const deployedApp = (await AppService.existsApp({ workspace, path: basePath })) + ? await AppService.getAppByPath({ workspace, path: basePath }) : undefined assertDraftBasedOnLatest( 'app', @@ -8145,7 +8202,16 @@ async function deployDraft( } } deployedPath = targetPath - if (await AppService.existsApp({ workspace, path: targetPath })) { + const targetExists = await AppService.existsApp({ workspace, path: targetPath }) + // A draft of a deployed app is stored at that app's path, so a target elsewhere that + // already exists is another app: nothing checks a chosen name before deploy. + if (targetExists && targetPath !== storagePath) { + throw new Error( + `Cannot deploy app "${path}" to "${targetPath}": another app is already deployed there. ` + + `Ask the user for a different path for this draft.` + ) + } + if (targetExists) { // Omit custom_path on update for now. The backend preserves it when absent, while // sending it requires admin privileges; this chat deploy path does not yet mirror // the raw app editor's user/admin-specific custom_path handling. @@ -8481,7 +8547,11 @@ export function prepareGlobalUserMessage( if (activeEditor) { content += '## ACTIVE EDITOR\n' content += `type: ${activeEditor.type}\n` - content += `path: ${activeEditor.path}\n` + // Same '' storage key as in addressedByStoragePath: fall back to the chosen name. + content += `path: ${activeEditor.storagePath || activeEditor.path}\n` + if (activeEditor.storagePath && activeEditor.path !== activeEditor.storagePath) { + content += `draft_path: ${activeEditor.path}\n` + } content += `isLiveDraft: true\n\n` } @@ -8504,7 +8574,11 @@ export function prepareGlobalUserMessage( : context.type === 'workspace_flow' ? 'flow' : 'raw_app' - content += `- type: ${itemType}, path: ${context.path}\n` + const draftPath = + context.draftPath && context.draftPath !== context.path + ? `, draft_path: ${context.draftPath}` + : '' + content += `- type: ${itemType}, path: ${context.path}${draftPath}\n` } content += '\n' } diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index c53aa1b7d1..63854c9761 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -1,5 +1,5 @@ import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' -import { DraftService } from '$lib/gen' +import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte' @@ -303,16 +303,121 @@ function liveDisplayPath( } } +type StagedDraft = { storagePath: string; summary?: string } + +type DraftNames = { + /** `${itemKind}:${storagePath}` of every draft the current user has. */ + stored: Set + /** `${itemKind}:${chosen name}` → the drafts staged under that name. */ + byName: Map + /** `${itemKind}:${name}` keys where a deployed item already sits at the name. */ + deployedAt: Set +} + +const draftNamesByWorkspace = new Map() + +// Scripts keep their chosen path in the value's own `path`; the other kinds in `draft_path` +// (the same split listDrafts applies server-side). +function chosenDraftName(itemKind: UserDraftItemKind, value: unknown): string | undefined { + const v = value as { path?: string; draft_path?: string } | null | undefined + return (itemKind === 'script' ? v?.path : v?.draft_path) || undefined +} + +const deployedExists: Partial< + Record Promise> +> = { + script: (workspace, path) => ScriptService.existsScriptByPath({ workspace, path }), + flow: (workspace, path) => FlowService.existsFlowByPath({ workspace, path }), + raw_app: (workspace, path) => AppService.existsApp({ workspace, path }) +} + +/** + * Loads the chosen names of the user's drafts so the synchronous resolver below can route + * a name to its draft. A new item is stored at a generated `draft_` path until it is + * deployed, and a rename is staged over the old path, so the name the user and the model + * use is often not where the draft lives. Called before every chat tool call; `path` is + * the path that call addresses, checked against deployed items only when it is a name. + */ +export async function loadDraftNames(workspace: string, path?: string): Promise { + const names: DraftNames = { stored: new Set(), byName: new Map(), deployedAt: new Set() } + const add = ( + itemKind: UserDraftItemKind, + storagePath: string, + name: string | undefined, + summary: string | undefined + ) => { + // A new script or flow editor stores under '', which no tool call can address. + if (!storagePath) return + names.stored.add(`${itemKind}:${storagePath}`) + if (!name || name === storagePath) return + const staged = names.byName.get(`${itemKind}:${name}`) ?? [] + if (staged.some((s) => s.storagePath === storagePath)) return + staged.push({ storagePath, summary }) + names.byName.set(`${itemKind}:${name}`, staged) + } + let rows: Awaited> + try { + rows = await DraftService.listDrafts({ workspace }) + } catch (e) { + // The names loaded earlier are kept: stale routing still sends a name to the draft + // it named a moment ago, while no table at all sends a write to the name itself, + // forking the draft in two. + console.warn('Could not refresh draft names', e) + return + } + for (const row of rows) { + add(row.kind, row.path, row.draft_path, row.summary) + } + for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) { + add( + entry.itemKind, + entry.path, + chosenDraftName(entry.itemKind, entry.value), + getItemSummary(entry.value) + ) + } + // Each load only probes the name its own call addresses, so keep what earlier calls + // established: a concurrent call that addressed nothing must not drop a known + // deployed name and let this one route it to a draft. + names.deployedAt = new Set(draftNamesByWorkspace.get(workspace)?.deployedAt) + if (path) { + const probes = Object.entries(deployedExists).map(async ([itemKind, exists]) => { + const key = `${itemKind}:${path}` + if (!names.byName.has(key) || names.stored.has(key)) return + if (await exists(workspace, path)) names.deployedAt.add(key) + else names.deployedAt.delete(key) + }) + await Promise.all(probes) + } + draftNamesByWorkspace.set(workspace, names) +} + function resolveDraftStoragePath( workspace: string, itemKind: UserDraftItemKind, path: string ): string { const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) - if (!liveDraft) return path - if (path === liveDraft.storagePath || path === liveDraft.effectivePath) + if (liveDraft && (path === liveDraft.storagePath || path === liveDraft.effectivePath)) return liveDraft.storagePath - return path + + const names = draftNamesByWorkspace.get(workspace) + const key = `${itemKind}:${path}` + if (!names || names.stored.has(key)) return path + const staged = names.byName.get(key) + // A deployed item keeps its own path: drafts staged under that name of another item's + // are reached by their storage path. + if (!staged || names.deployedAt.has(key)) return path + if (staged.length > 1) { + // Refused rather than guessed: nothing checks a name for uniqueness before deploy. + const listed = staged + .map((s) => (s.summary ? `${s.storagePath} ("${s.summary}")` : s.storagePath)) + .join(', ') + throw new Error( + `Several drafts are staged under "${path}": ${listed}. Pass the path of the one you mean.` + ) + } + return staged[0].storagePath } export function getGlobalDraftStoragePath(