fix: stop a name-addressed write when the drafts cannot be loaded

Review round 3 findings:

- With no names loaded at all — a first tool call whose `listDrafts` fails —
  a chosen name read as a path of its own, and a write under it created a
  second draft beside the one it meant to edit. The call now fails instead.
  Names loaded earlier are still good enough to route with.
- The deploy's result and status line named the path the call addressed the
  draft by, which after the deploy is the one path that resolves to nothing.
  Both now name where the item landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-23 13:24:21 +02:00
co-authored by Claude Opus 5
parent db44d36d9b
commit eb1f8b0ae2
3 changed files with 40 additions and 6 deletions
@@ -401,6 +401,7 @@ import {
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
AppService,
DraftService,
EmailTriggerService,
FlowService,
FolderService,
@@ -2493,6 +2494,30 @@ describe('global AI tools', () => {
}
})
// Without names a chosen name reads as a path of its own, and a write under it would
// create a second draft beside the one it meant to edit.
it('refuses to write by a chosen name it could not load the drafts for', async () => {
const workspace = 'ws-no-draft-names'
seedBackendDraft(
'raw_app',
'u/admin/draft_unloadable',
{ summary: 'Unloadable', draft_path: 'f/sales/unloadable', files: {}, runnables: {} },
{ workspace }
)
vi.mocked(DraftService.listDrafts).mockRejectedValueOnce(new Error('server error'))
await expect(
getGlobalTool('write_app_file').fn({
args: { path: 'f/sales/unloadable', file_path: '/index.tsx', content: 'x' },
workspace,
helpers: {},
toolCallbacks,
toolId: 'no-names'
})
).rejects.toThrow(/Could not load this workspace's drafts/)
expect(getBackendDraft('raw_app', 'f/sales/unloadable', { workspace })).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(
@@ -8310,19 +8310,21 @@ async function deployDraft(
deployedInSessionHandler?.({ sessionId, kind, path: draftStoragePath, deployedPath })
}
// Named by where the item landed, not by the path the call addressed it at: a draft is
// often addressed by its storage key, which nothing resolves to once the draft is gone.
toolCallbacks.setToolStatus(toolId, {
content: `Deployed ${type} "${path}"`,
content: `Deployed ${type} "${deployedPath}"`,
result: 'Deployed',
actions
})
return JSON.stringify(
{
success: true,
message: `Deployed draft ${type} "${path}" to the workspace. Draft removed.${
message: `Deployed draft ${type} "${deployedPath}" to the workspace. Draft removed.${
deployNote ? ` ${deployNote}` : ''
}`,
type,
path,
path: deployedPath,
triggerKind
},
null,
@@ -372,9 +372,16 @@ async function readDraftNames(workspace: string, path?: string): Promise<void> {
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.
// Without names, a chosen name reads as a path of its own: a write under it creates
// a second draft beside the one it meant to edit. Names loaded earlier are good
// enough — a draft does not leave the name it had a moment ago — but with none at
// all the caller is stopped instead.
if (!draftNamesByWorkspace.has(workspace)) {
throw new Error(
`Could not load this workspace's drafts, so "${path}" cannot be matched to the draft ` +
`it may name. Try again.`
)
}
console.warn('Could not refresh draft names', e)
return
}