mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 08:03:45 +00:00
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:
co-authored by
Claude Opus 5
parent
ea0b947644
commit
c4b4961ff6
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user