fix(copilot): resolve inline_script placeholders in global flow writes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-07-22 14:40:59 +02:00
co-authored by Claude Fable 5
parent 4a28fa2c06
commit cc84330a4f
5 changed files with 178 additions and 14 deletions
@@ -2,8 +2,14 @@ import { z } from 'zod'
import type { FlowModule, FlowValue } from '$lib/gen'
import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree'
import { SPECIAL_MODULE_IDS } from '../shared'
import type { InlineScriptSession } from './inlineScriptsUtils'
import { validateFlowGroups, validateFlowNotes, type FlowGroup, type FlowNote } from './helperUtils'
import { findUnresolvedInlineScriptRefs, type InlineScriptSession } from './inlineScriptsUtils'
import {
replaceNewInlineScriptRefsWithEmptyCode,
validateFlowGroups,
validateFlowNotes,
type FlowGroup,
type FlowNote
} from './helperUtils'
import { flowModuleSchema, flowModulesSchema } from './openFlowZod.gen'
/**
@@ -384,7 +390,7 @@ export function buildEditableFlowJson(
}
}
function restoreSpecialRawscriptModule(
export function restoreSpecialRawscriptModule(
module: FlowModule | null,
session: InlineScriptSession
): FlowModule | null {
@@ -420,3 +426,33 @@ export function applyEditableFlowJsonToFlow(
notes: editable.notes ?? undefined
}
}
/**
* Post-restore normalization for draft flow writes. A placeholder that survived
* restore either names its own module — a new inline body, blanked in place so
* the caller's empty-body warning sends the model to the set-module-code tool —
* or names nothing, in which case the write is rejected so a literal
* `inline_script.*` string is never persisted as runnable content.
*/
export function finalizeUnresolvedInlineScripts(value: {
modules: FlowModule[]
preprocessor_module?: FlowModule | null
failure_module?: FlowModule | null
}): void {
const specials = [value.preprocessor_module, value.failure_module].filter(
(module): module is FlowModule => module != null
)
const blanked = new Set<string>()
replaceNewInlineScriptRefsWithEmptyCode(value.modules, blanked)
replaceNewInlineScriptRefsWithEmptyCode(specials, blanked)
const unresolved = [
...findUnresolvedInlineScriptRefs(value.modules),
...findUnresolvedInlineScriptRefs(specials)
]
if (unresolved.length > 0) {
const refs = unresolved.map((id) => `"inline_script.${id}"`).join(', ')
throw new Error(
`Unresolved inline script reference(s): ${refs}. A rawscript "content" placeholder must be "inline_script.<its own module id>" (a new body, filled afterwards with the set-module-code tool) or reference an existing rawscript module to keep its current body. Nothing was saved.`
)
}
}
@@ -344,7 +344,7 @@ function assertResolvedInlineScripts(
}
}
function replaceNewInlineScriptRefsWithEmptyCode(
export function replaceNewInlineScriptRefsWithEmptyCode(
modules: FlowModule[],
emptyInlineScriptModuleIds: Set<string>
): void {
@@ -223,7 +223,7 @@ function restoreInlineScriptReferences(
})
}
function findUnresolvedInlineScriptRefs(modules: FlowModule[]): string[] {
export function findUnresolvedInlineScriptRefs(modules: FlowModule[]): string[] {
const unresolvedRefs: string[] = []
function checkModule(module: FlowModule) {
@@ -2986,12 +2986,91 @@ describe('global AI tools', () => {
expect(result.message).toContain('"write_to_pg"')
expect(result.message).not.toContain('"call_api"')
// The existing module's body must survive the patch round-trip.
// The placeholder is blanked, never persisted as literal content, and the
// existing module's body survives the patch round-trip.
await expect(
callGlobalTool('read_flow_module_code', { path, module_id: 'write_to_pg' })
).resolves.toBe('')
await expect(
callGlobalTool('read_flow_module_code', { path, module_id: 'call_api' })
).resolves.toBe('export async function main() { return 1 }')
})
it('rejects a patch whose inline_script placeholder references no module', async () => {
const path = 'f/flows/patch-bad-ref'
await callGlobalTool('write_flow', {
path,
modules: JSON.stringify([
{
id: 'call_api',
value: {
type: 'rawscript',
language: 'bun',
content: 'export async function main() { return 1 }',
input_transforms: {}
}
}
])
})
await expect(
callGlobalTool('patch_flow_json', {
path,
old_string: '"input_transforms":{}}}]',
new_string:
'"input_transforms":{}}},{"id":"write_to_pg","value":{"type":"rawscript","language":"postgresql","content":"inline_script.call_apy","input_transforms":{}}}]'
})
).rejects.toThrow(/Unresolved inline script reference/)
// The rejected patch must not have touched the draft.
await expect(
callGlobalTool('read_flow_module_code', { path, module_id: 'call_api' })
).resolves.toBe('export async function main() { return 1 }')
})
it('write_flow resolves placeholders to existing module bodies on overwrite', async () => {
const path = 'f/flows/overwrite-keep-bodies'
const code = 'export async function main() { return 1 }'
await callGlobalTool('write_flow', {
path,
modules: JSON.stringify([
{
id: 'call_api',
value: { type: 'rawscript', language: 'bun', content: code, input_transforms: {} }
}
])
})
const result = JSON.parse(
await callGlobalTool('write_flow', {
path,
summary: 'Reordered',
modules: JSON.stringify([
{
id: 'call_api',
value: {
type: 'rawscript',
language: 'bun',
content: 'inline_script.call_api',
input_transforms: {}
}
},
{
id: 'notify',
value: { type: 'rawscript', language: 'bun', content: '', input_transforms: {} }
}
])
})
)
expect(result.success).toBe(true)
expect(result.message).toContain('"notify"')
expect(result.message).not.toContain('"call_api"')
await expect(
callGlobalTool('read_flow_module_code', { path, module_id: 'call_api' })
).resolves.toBe(code)
})
it('writes flows with flow-mode arguments and reads compact flow value', async () => {
const writeResult = JSON.parse(
await callGlobalTool('write_flow', {
@@ -26,6 +26,7 @@ import type {
CreateResource,
CreateVariable,
Flow,
FlowModule,
FlowValue,
Job,
ListableApp,
@@ -57,9 +58,14 @@ import {
applyEditableFlowJsonToFlow,
buildEditableFlowJson,
type EditableFlowJson,
finalizeUnresolvedInlineScripts,
restoreSpecialRawscriptModule,
validateEditableFlowJson
} from '../flow/editableFlowJson'
import { createInlineScriptSession } from '../flow/inlineScriptsUtils'
import {
createInlineScriptSession,
findUnresolvedInlineScriptRefs
} from '../flow/inlineScriptsUtils'
import { searchNpmPackagesTool } from '../script/core'
import {
getDatatableSdkReference,
@@ -1946,10 +1952,11 @@ function getFlowInstructions(): string {
- \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module.
- \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the draft.
- Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use \`set_flow_module_code\` for changes inside a specific rawscript body.
- When **adding a new rawscript module** with \`patch_flow_json\`, set its \`content\` to the placeholder \`"inline_script.<moduleId>"\` (matching the compact view) — never real code — then **immediately fill the body** with \`set_flow_module_code(path, module_id, code)\`. Until you do, the module's saved content is the literal placeholder string and the flow cannot run; the tool result lists the module ids still awaiting code.
- When **adding a new rawscript module** with \`patch_flow_json\`, set its \`content\` to the placeholder \`"inline_script.<moduleId>"\` (matching the compact view) — never real code — then **immediately fill the body** with \`set_flow_module_code(path, module_id, code)\`. The module is saved with an empty body until you do; the tool result lists the module ids still awaiting code. A placeholder that names anything other than the module's own id or an existing rawscript module is rejected.
- \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments are **non-compact** flow modules, but you do NOT have to inline the actual rawscript code:
- For a short one-liner body, inline it directly in \`content\`.
- For multi-line code, or any body containing quotes, create the rawscript module with **empty content** (\`"content": ""\`) and fill each body afterwards with \`set_flow_module_code(path, module_id, code)\`. Embedding large code in the \`modules\` JSON string forces triple-nested escaping and frequently corrupts the JSON — keep code out of the flow structure. \`write_flow\` reports which modules still have empty bodies so you know what to fill.
- When overwriting an **existing** flow, set \`"content": "inline_script.<moduleId>"\` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.
# Windmill flow authoring reference
@@ -2849,16 +2856,17 @@ export const globalTools: Tool<{}>[] = [
groups: parseOptionalJsonArg(parsed.groups, 'groups'),
notes: parseOptionalJsonArg(parsed.notes, 'notes')
})
const resolved = await resolveWriteFlowInlineScripts(parsed.path, editable, ctx.workspace)
const result = await writeFlowDraft(
{
path: parsed.path,
summary: parsed.summary,
description: parsed.description,
flow: editableFlowToDraftValue(editable)
flow: editableFlowToDraftValue(resolved)
},
ctx
)
return appendEmptyInlineScriptWarning(result, editable)
return appendEmptyInlineScriptWarning(result, resolved)
}
},
{
@@ -4326,6 +4334,46 @@ async function loadFlowDraftValue(
}
}
/**
* `write_flow` accepts `inline_script.<id>` placeholders so the model can
* overwrite a flow without re-sending (or even reading) unchanged rawscript
* bodies. Resolve them against the current draft/deployed flow: an id with a
* stored body keeps it, a new module's own-id placeholder becomes an empty body
* (to fill via set_flow_module_code), and anything else rejects the write.
*/
async function resolveWriteFlowInlineScripts(
path: string,
editable: EditableFlowJson,
workspace: string
): Promise<EditableFlowJson> {
const specials = [editable.preprocessor_module, editable.failure_module].filter(
(module): module is FlowModule => module != null
)
const hasPlaceholders =
findUnresolvedInlineScriptRefs(editable.modules).length > 0 ||
findUnresolvedInlineScriptRefs(specials).length > 0
if (!hasPlaceholders) {
return editable
}
const session = createInlineScriptSession()
if (
(await getGlobalDraft(workspace, 'flow', path)) ||
(await FlowService.existsFlowByPath({ workspace, path }))
) {
const base = await loadFlowDraftValue(path, workspace)
buildEditableFlowJson(flowDraftAsEditableInput(base.flow), session)
}
const resolved: EditableFlowJson = {
...editable,
modules: session.restoreInlineScriptReferences(editable.modules),
preprocessor_module: restoreSpecialRawscriptModule(editable.preprocessor_module, session),
failure_module: restoreSpecialRawscriptModule(editable.failure_module, session)
}
finalizeUnresolvedInlineScripts(resolved)
return resolved
}
async function patchFlowJson(
args: { path: string; old_string: string; new_string: string; replace_all: boolean },
ctx: WriteDraftCtx
@@ -4358,6 +4406,7 @@ async function patchFlowJson(
const patchedEditable = validateEditableFlowJson(parsedValue)
const newFlowValue = applyEditableFlowJsonToFlow(base.flow.value, patchedEditable, session)
finalizeUnresolvedInlineScripts(newFlowValue)
const result = await writeFlowDraft(
{
@@ -4373,10 +4422,10 @@ async function patchFlowJson(
ctx
)
// A patch that adds a new rawscript module carries an `inline_script.<id>`
// placeholder with no session entry, so the restore above keeps the literal
// placeholder as the saved content. Check the restored value (patchedEditable
// still holds placeholders for every rawscript) and tell the model which
// bodies to fill via set_flow_module_code.
// placeholder with no session entry; finalize blanks it (or rejects a typo'd
// ref). Check the restored value (patchedEditable still holds placeholders
// for every rawscript) and tell the model which bodies to fill via
// set_flow_module_code.
return appendEmptyInlineScriptWarning(result, {
...patchedEditable,
modules: newFlowValue.modules,