mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 00:03:21 +00:00
fix(copilot): resolve inline_script placeholders in global flow writes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4a28fa2c06
commit
cc84330a4f
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user