fix: route a draft's chosen name to the draft it names in the AI chat

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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-23 12:26:13 +02:00
co-authored by Claude Opus 5
parent ea0b947644
commit c4b4961ff6
6 changed files with 352 additions and 24 deletions
@@ -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)
@@ -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])}
@@ -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_<uuid>`. */
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
@@ -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')
@@ -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<Loade
return { value: appSourceToDraftValue(app, app) }
}
/**
* A live editor's draft is displayed under its chosen name, which resolves to the draft only
* while that editor stays open. The model is handed the storage path instead, which keeps
* resolving after the tab closes, and the chosen name as `draftPath`.
*/
function addressedByStoragePath(workspace: string, item: WorkspaceItem): WorkspaceItem {
if (!item.isLiveDraft) return item
const storagePath = getGlobalDraftStoragePath(workspace, item.type, item.path, item.triggerKind)
// A new script or flow editor stores under '', which no tool call can address.
if (!storagePath || storagePath === item.path) return item
return { ...item, path: storagePath, draftPath: item.path }
}
async function saveAppDraft(
workspace: string,
path: string,
@@ -3280,7 +3295,7 @@ export const openPageTool: Tool<{}> = {
}
}
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<string, number>()
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_<uuid>` 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<string, any>, memoryId?: string) => Promise<string | undefined>) | 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<string> {
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'
}
@@ -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<string>
/** `${itemKind}:${chosen name}` → the drafts staged under that name. */
byName: Map<string, StagedDraft[]>
/** `${itemKind}:${name}` keys where a deployed item already sits at the name. */
deployedAt: Set<string>
}
const draftNamesByWorkspace = new Map<string, DraftNames>()
// 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<UserDraftItemKind, (workspace: string, path: string) => Promise<boolean>>
> = {
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_<uuid>` 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<void> {
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<ReturnType<typeof DraftService.listDrafts>>
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(