feat: run a flow step test through the chat's argument form (#11114)

test_run_step was the last run tool still starting a job on whatever the
model sent. Route it through runThroughForm, as test_run_script,
run_script and test_run_flow already are.

A step's arguments are its own, not the flow's: it is normally fed by its
input transforms, so the form is built from the step's target rather than
the flow's schema. loadSchemaFromModule resolves script and subflow steps
against the deployed version, which would offer the fields of code this
path is not about to run, so the schema comes from the same read the job
uses — inferred from a rawscript body, the draft script's content, or the
subflow's own schema.

The preprocessor's _ENTRYPOINT_OVERRIDE is declared by no schema, so it is
added inside the resolved startJob: proposed into the form instead, the
argument conforming would drop it and the preprocessor would silently run
its main. Its schema is inferred rather than read off the target for the
same reason a stored one cannot describe it: a schema speaks for the one
entrypoint it was inferred from.

executeFlowStepTestRun splits into resolveFlowStepRun plus a thin wrapper,
so the flow editor's own test_run_step keeps its behaviour but for one fix
it inherits: a deployed subflow step now runs with skipPreprocessor. The
flow editor's step test passes it too, and a parent flow pushes a subflow
step the same way (apply_preprocessor: false) — a preprocessor would take
the subflow's own inputs for a trigger event.


Claude-Session: https://claude.ai/code/session_018PBB2gw8FK4YmGPxu5Drbn

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-14 19:28:09 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 94c548cd5d
commit 5d32b61067
3 changed files with 432 additions and 113 deletions
@@ -327,6 +327,7 @@ vi.mock('$lib/infer', async () => ({
inferArgs: vi.fn(async () => {})
}))
import { inferArgs } from '$lib/infer'
import { buildRunsFilterSearchbarSchema } from '$lib/components/runs/runsFilter'
import {
buildOpenPageUrl,
@@ -386,6 +387,16 @@ function getBackendDraft<V = any>(kind: string, path: string, _opts?: unknown):
return backendDrafts.get(`${kind}:${path}`) as V | undefined
}
// inferArgs is stubbed module-wide (no wasm parser here), so a test whose form is built by
// inference has to say what the next call finds. Once, so a test that also calls write_script
// — which infers to fill the draft's schema — queues this after that write, not before.
function stubInferredProperties(properties: Record<string, any>): void {
vi.mocked(inferArgs).mockImplementationOnce(async (_lang, _code, schema) => {
schema.properties = properties
return null
})
}
const toolCallbacks: ToolCallbacks = {
setToolStatus: vi.fn(),
removeToolStatus: vi.fn(),
@@ -5183,6 +5194,9 @@ describe('global AI tools', () => {
it('test_run_step previews rawscript steps from the draft flow', async () => {
const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}'
// The form offers the fields the step's own code declares, so the step needs a schema
// for `name` to survive it.
stubInferredProperties({ name: { type: 'string' } })
await callGlobalTool('write_flow', {
path: 'f/flows/rawscript-step',
summary: 'Flow with rawscript',
@@ -5240,6 +5254,9 @@ describe('global AI tools', () => {
])
})
// A script draft carries no schema, so the form infers from the draft content — the
// version about to run.
stubInferredProperties({ name: { type: 'string' } })
await withCompletedTestJob(() =>
callGlobalTool('test_run_step', {
path: 'f/flows/script-step',
@@ -5265,7 +5282,10 @@ describe('global AI tools', () => {
await callGlobalTool('write_flow', {
path: 'f/flows/nested-draft',
summary: 'Nested draft flow',
modules: JSON.stringify(nestedModules)
modules: JSON.stringify(nestedModules),
// A subflow step's form is the subflow's own inputs, so `name` needs declaring here
// for it to survive the form.
schema: JSON.stringify(FLOW_NAME_SCHEMA)
})
await callGlobalTool('write_flow', {
path: 'f/flows/parent-flow',
@@ -5301,6 +5321,205 @@ describe('global AI tools', () => {
})
})
it('test_run_step runs a deployed subflow step past its preprocessor', async () => {
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
path: 'f/flows/deployed-sub',
summary: 'Deployed subflow',
value: { modules: [{ id: 'sub_start', value: { type: 'identity' } }] },
schema: FLOW_NAME_SCHEMA
} as any)
await callGlobalTool('write_flow', {
path: 'f/flows/parent-of-deployed',
summary: 'Parent flow',
modules: JSON.stringify([
{
id: 'call_deployed',
value: { type: 'flow', path: 'f/flows/deployed-sub', input_transforms: {} }
}
])
})
await withCompletedTestJob(() =>
callGlobalTool('test_run_step', {
path: 'f/flows/parent-of-deployed',
stepId: 'call_deployed',
args: { name: 'Ada' }
})
)
expect(JobService.runFlowPreview).not.toHaveBeenCalled()
expect(JobService.runFlowByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/flows/deployed-sub',
requestBody: { name: 'Ada' },
skipPreprocessor: true
})
})
// A step is fed by its input transforms, so its arguments are its own and the flow's
// schema describes a different set entirely. Opening the form on the flow's would offer
// fields this job ignores and drop the ones it takes.
it('test_run_step opens the form on the step, not on the flow', async () => {
const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}'
await callGlobalTool('write_flow', {
path: 'f/flows/step-form',
summary: 'Step form flow',
// The flow takes `customer`; the step takes `name`. Nothing links the two.
schema: JSON.stringify({ type: 'object', properties: { customer: { type: 'string' } } }),
modules: JSON.stringify([
{
id: 'format_name',
value: { type: 'rawscript', language: 'bun', content, input_transforms: {} }
}
])
})
stubInferredProperties({ name: { type: 'string' } })
let form: any
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{
path: 'f/flows/step-form',
stepId: 'format_name',
args: { name: 'Ada', customer: 'acme' }
},
{
...toolCallbacks,
requestRunArgs: async (_toolId, f) => {
form = f
return { name: 'Grace' }
}
}
)
)
expect(form.schema.properties).toEqual({ name: { type: 'string' } })
expect(form.runnableKind).toBe('script')
expect(form.summary).toBe('step "format_name"')
// `customer` is the flow's argument, so the step's form never offered it.
expect(form.args).toEqual({ name: 'Ada' })
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: { content, language: 'bun', args: { name: 'Grace' } }
})
})
// The step runs the draft script's content, so a form built from the deployed schema
// would offer the arguments of code that is not the code about to run.
it('test_run_step opens a script step on the draft schema, not the deployed one', async () => {
const content = 'export async function main(name: string) {\n\treturn `draft ${name}`\n}'
seedBackendDraft('script', 'f/scripts/drifted', {
path: 'f/scripts/drifted',
summary: 'Drifted',
content,
language: 'bun'
})
await callGlobalTool('write_flow', {
path: 'f/flows/drifted-step',
summary: 'Drifted step flow',
modules: JSON.stringify([
{
id: 'call_script',
value: { type: 'script', path: 'f/scripts/drifted', input_transforms: {} }
}
])
})
// Inferred from the draft's content. Never fetching the deployed script is the point:
// its stored schema describes code this run is not about to execute.
stubInferredProperties({ name: { type: 'string' } })
let form: any
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{ path: 'f/flows/drifted-step', stepId: 'call_script', args: { name: 'Ada' } },
{ ...toolCallbacks, requestRunArgs: async (_toolId, f) => ((form = f), f.args) }
)
)
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
expect(form.schema.properties).toEqual({ name: { type: 'string' } })
})
// No parser emits `password`, so the draft's stored schema is the only thing carrying it.
// Rebuilding the form's fields from the content would offer the secret as a plain text
// box, and the literal typed into it would reach the job's arguments unminted.
it('test_run_step keeps the password marking of a drafted script step', async () => {
seedBackendDraft('script', 'f/scripts/secretful', {
path: 'f/scripts/secretful',
summary: 'Secretful',
content: 'export async function main(token: string) {\n\treturn 1\n}',
language: 'bun',
schema: {
type: 'object',
properties: { token: { type: 'string', password: true } },
required: ['token']
}
})
await callGlobalTool('write_flow', {
path: 'f/flows/secretful-step',
summary: 'Secretful step flow',
modules: JSON.stringify([
{
id: 'call_secretful',
value: { type: 'script', path: 'f/scripts/secretful', input_transforms: {} }
}
])
})
let form: any
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{ path: 'f/flows/secretful-step', stepId: 'call_secretful', args: {} },
{ ...toolCallbacks, requestRunArgs: async (_toolId, f) => ((form = f), f.args) }
)
)
expect(form.schema.properties).toMatchObject({ token: { password: true } })
})
// The entrypoint override is declared by no schema, so it has to be added after the form
// rather than proposed into it — anything that conforms arguments to a schema drops it,
// and the preprocessor then silently runs its `main`.
it('test_run_step keeps the preprocessor entrypoint out of the form and on the job', async () => {
const content = 'export async function preprocessor(event: string) {\n\treturn event\n}'
await callGlobalTool('write_flow', {
path: 'f/flows/preprocessed',
summary: 'Preprocessed flow',
modules: JSON.stringify([{ id: 'start', value: { type: 'identity' } }]),
preprocessor_module: JSON.stringify({
id: 'preprocessor',
value: { type: 'rawscript', language: 'bun', content, input_transforms: {} }
})
})
vi.mocked(inferArgs).mockClear()
stubInferredProperties({ event: { type: 'string' } })
let form: any
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_step',
{ path: 'f/flows/preprocessed', stepId: 'preprocessor', args: { event: 'signup' } },
{ ...toolCallbacks, requestRunArgs: async (_toolId, f) => ((form = f), f.args) }
)
)
// Inferred against the preprocessor entrypoint, not `main`.
expect(vi.mocked(inferArgs).mock.calls[0][3]).toBe('preprocessor')
expect(form.schema.properties).toEqual({ event: { type: 'string' } })
expect(form.args).toEqual({ event: 'signup' })
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
content,
language: 'bun',
args: { _ENTRYPOINT_OVERRIDE: 'preprocessor', event: 'signup' }
}
})
})
// The form IS the consent, so a dismissed one must leave the script unrun.
it('run_script starts no job when the user cancels the form', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
@@ -125,10 +125,11 @@ import {
createToolDef,
droppedOptionKeys,
createSearchHubScriptsTool,
executeFlowStepTestRun,
executeTestRun,
findAndReplace,
isHubPath,
resolveFlowStepRun,
SPECIAL_MODULE_IDS,
type CreatedResourceTriggerKind,
type PreviewCardKind,
type RunFormDisplay,
@@ -961,7 +962,7 @@ const testRunStepSchema = z.object({
const testRunStepToolDef = createToolDef(
testRunStepSchema,
'test_run_step',
'Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists.',
"Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.",
{ strict: false }
)
@@ -1365,7 +1366,7 @@ ${pipelineBullet}
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools use the draft tools and delete_workspace_item instead.
- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat.
- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
- Keep context targeted.${
@@ -3739,9 +3740,10 @@ export const globalTools: Tool<{}>[] = [
const parsed = testRunStepSchema.parse(ctx.args)
return testRunFlowStepByPath(parsed, ctx)
},
requiresConfirmation: true,
confirmationMessage: (args) =>
`Run a test of step "${args?.stepId ?? ''}" in ${pathLeaf(args?.path, 'the flow')}`,
// No requiresConfirmation, for the reason test_run_script carries.
bypassedByAutoAccept: true,
streamingLabel: 'Preparing the test form...',
confirmationMessage: 'Run a test of a flow step',
queuedLabel: (args) => `Test step "${args?.stepId ?? ''}" of ${args?.path ?? 'the flow'}`,
showDetails: true,
autoCollapseDetails: false
@@ -5230,19 +5232,24 @@ async function loadScriptForEdit(
}
/** The fields a test form offers, for code that may never have been deployed. The stored
* schema wins wherever it declares fields a draft's or the deployed script's; only one
* declaring nothing is inferred here, from the content about to run. */
* schema wins wherever it declares fields a draft's or the deployed script's; one that
* declares nothing, or that speaks for an entrypoint other than the one about to run, is
* inferred here from the content instead. */
async function schemaForTestRun(script: {
content: string
language: ScriptLang
schema?: Record<string, any>
/** A preprocessor takes the arguments of its own entrypoint, not of `main`. */
entrypoint?: 'preprocessor'
}): Promise<Record<string, any>> {
// Emptily declared is not declared: a stored `properties: {}` means the schema predates
// the arguments the code now takes, so infer rather than offer a form with no fields.
if (Object.keys(script.schema?.properties ?? {}).length > 0) return script.schema!
// A stored schema speaks for one entrypoint, so it can never answer for an override.
if (!script.entrypoint && Object.keys(script.schema?.properties ?? {}).length > 0)
return script.schema!
const schema = emptySchema()
try {
await inferArgs(script.language, script.content, schema)
await inferArgs(script.language, script.content, schema, script.entrypoint)
} catch (e) {
console.error('Failed to infer script schema for the test run form', e)
}
@@ -5272,18 +5279,21 @@ async function editScript(
async function loadFlowDraftValue(
path: string,
workspace: string
): Promise<{ flow: FlowDraftValue; summary?: string }> {
// `isDraft` says which of the two this came from. Reported here because the draft lookup
// is a request of its own: a caller that needs to know would otherwise repeat it.
): Promise<{ flow: FlowDraftValue; summary?: string; isDraft: boolean }> {
const draft = await getGlobalDraft(workspace, 'flow', path)
if (draft) {
if (draft.value === undefined || typeof draft.value === 'string') {
throw new Error(`Draft flow "${path}" has no value.`)
}
return { flow: draft.value as FlowDraftValue, summary: draft.summary }
return { flow: draft.value as FlowDraftValue, summary: draft.summary, isDraft: true }
}
const flow = await FlowService.getFlowByPath({ workspace, path })
return {
flow: { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null },
summary: flow.summary
summary: flow.summary,
isDraft: false
}
}
@@ -5454,30 +5464,42 @@ function flowDraftValueForPreview(flowDraft: FlowDraftValue): FlowValue {
async function loadScriptForFlowStep(
moduleValue: { path: string; hash?: string },
workspace: string
): Promise<{ content: string; language: ScriptLang }> {
): Promise<{ content: string; language: ScriptLang; schema?: Record<string, any> }> {
const draft = await getGlobalDraft(workspace, 'script', moduleValue.path)
if (draft) {
if (typeof draft.value !== 'string' || !draft.language) {
throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`)
}
return { content: draft.value, language: draft.language }
return {
content: draft.value,
language: draft.language,
// The draft's own: no parser emits `password`, so a schema rebuilt from the content
// would offer a secret argument as a plain field and take the literal into the job.
schema: draft.schema as Record<string, any> | undefined
}
}
const script = moduleValue.hash
? await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash })
: await ScriptService.getScriptByPath({ workspace, path: moduleValue.path })
return { content: script.content, language: script.language }
return {
content: script.content,
language: script.language,
schema: script.schema as Record<string, any> | undefined
}
}
async function loadDraftFlowPreviewValue(
async function loadSubflowForFlowStep(
path: string,
workspace: string
): Promise<FlowValue | undefined> {
if (!(await getGlobalDraft(workspace, 'flow', path))) {
return undefined
}
): Promise<{ previewValue?: FlowValue; schema?: Record<string, any> }> {
const nestedFlow = await loadFlowDraftValue(path, workspace)
return flowDraftValueForPreview(nestedFlow.flow)
return {
// Only a draft is previewed; a deployed subflow is run by path, as its parent flow
// would run it. The schema describes whichever of the two that leaves.
previewValue: nestedFlow.isDraft ? flowDraftValueForPreview(nestedFlow.flow) : undefined,
schema: nestedFlow.flow.schema ?? undefined
}
}
// Leaf of a workspace path (last segment), for human-readable confirmation
@@ -5565,7 +5587,14 @@ type FormRunSpec = {
toolName: string
proposed: Record<string, any> | null | undefined
startMessage: string
/** What runs: the jobs-tray kind, and the noun the card's own prose reads. */
contextName: 'script' | 'flow'
/** What the lines the model reads back call the thing that ran. Defaults to
* `contextName`, which a flow step is not: it runs a script or a subflow but is neither. */
noun?: string
/** What names the run where its path would not: the jobs-tray row, and the two
* background-job sentences the model reads. Those quote it, so it carries none. */
label?: string
/** Whether the bypass posture may answer this form with what it opened with. */
autoAcceptable?: boolean
background?: boolean
@@ -5582,8 +5611,9 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st
const postureAnswers = Boolean(
spec.autoAcceptable && toolCallbacks.shouldAutoAcceptToolConfirmations?.(spec.toolName)
)
const noun = spec.noun ?? spec.contextName
if (!toolCallbacks.requestRunArgs && !postureAnswers) {
return `This chat cannot show a run form, so a ${spec.contextName} cannot be run from here.`
return `This chat cannot show a run form, so a ${noun} cannot be run from here.`
}
// processToolCall gates plan mode once, before the schema fetch, and this form is its own
@@ -5687,7 +5717,7 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st
error: 'Cancelled by user',
declinedByUser: true
})
return runFormCancelled(spec.toolName, spec.contextName)
return runFormCancelled(spec.toolName, noun)
}
const blockedBeforeRun = blockedByPlanMode()
@@ -5743,7 +5773,7 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st
actionNoun: spec.kind === 'test' ? 'test' : 'run',
background: spec.background,
detachAfterMs: spec.detachAfterMs,
label: spec.path
label: spec.label ?? spec.path
})
const schemaNoun = `${spec.schemaNoun} schema`
@@ -5920,21 +5950,61 @@ async function testRunFlowStepByPath(
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const flow = await loadFlowDraftValue(args.path, workspace)
const flowValue = flowDraftValueForPreview(flow.flow)
const testArgs = normalizeTestRunArgs(args.args)
return executeFlowStepTestRun({
flowValue,
const resolved = await resolveFlowStepRun({
flowValue: flowDraftValueForPreview(flow.flow),
stepId: args.stepId,
args: testArgs,
workspace,
toolCallbacks,
toolId,
background: args.background,
detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
loadScript: loadScriptForFlowStep,
loadFlowPreviewValue: loadDraftFlowPreviewValue
loadSubflow: loadSubflowForFlowStep
})
// The module resolution landed on, not the id that was asked for: the job's entrypoint
// override reads the same value, and a form built for the other entrypoint offers fields
// the run will not take.
const isPreprocessor = resolved.module.id === SPECIAL_MODULE_IDS.PREPROCESSOR
// The step's own inputs, never the flow's: a step is fed by its input transforms, so the
// flow's schema names arguments this job would ignore and omits the ones it takes.
const schema =
resolved.code != undefined && resolved.lang
? await schemaForTestRun({
content: resolved.code,
language: resolved.lang,
schema: resolved.schema,
entrypoint: isPreprocessor ? 'preprocessor' : undefined
})
: (resolved.schema ?? {})
const stepSummary = resolved.module.summary
return runThroughForm(
{
// The flow: a step has no path of its own, and this is what the status and cancel
// lines quote back, so it has to name something the reader can go and open. The
// step itself is named by the summary below.
path: args.path,
schema,
summary: stepSummary ? `step "${args.stepId}": ${stepSummary}` : `step "${args.stepId}"`,
kind: 'test',
code: resolved.code ?? schema['x-windmill-dyn-select-code'],
lang: resolved.lang ?? schema['x-windmill-dyn-select-lang'],
// Never "deployed": a step test previews the draft flow, and the step's target may
// itself be a draft.
schemaNoun: 'step',
toolName: 'test_run_step',
proposed: args.args,
startMessage: resolved.startMessage,
contextName: resolved.runnableKind,
noun: 'step',
label: `step ${args.stepId}`,
// The model is told to test and iterate, so the bypass posture answers the form.
autoAcceptable: true,
background: args.background,
detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
startJob: resolved.startJob
},
ctx
)
}
async function initApp(
@@ -2023,23 +2023,50 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
type FlowStepScriptLoader = (
moduleValue: { path: string; hash?: string },
workspace: string
) => Promise<{ content: string; language: ScriptLang }>
) => Promise<{ content: string; language: ScriptLang; schema?: Record<string, any> }>
type FlowStepPreviewLoader = (path: string, workspace: string) => Promise<FlowValue | undefined>
/** A subflow step's target. `previewValue` is set only when a draft exists — that is what
* decides between previewing the draft and running the deployed flow by path — while
* `schema` describes whichever of the two is about to run. */
type FlowStepSubflowLoader = (
path: string,
workspace: string
) => Promise<{ previewValue?: FlowValue; schema?: Record<string, any> } | undefined>
export type FlowStepTestRunConfig = {
type FlowStepRunConfig = {
flowValue: FlowValue
stepId: string
args?: Record<string, any> | null
workspace: string
toolCallbacks: ToolCallbacks
toolId: string
loadScript?: FlowStepScriptLoader
loadSubflow?: FlowStepSubflowLoader
}
export type FlowStepTestRunConfig = FlowStepRunConfig & {
args?: Record<string, any> | null
background?: boolean
/** Inline wait budget (ms) before the step job detaches into the tray; forwarded
* to executeTestRun. Ignored when `background` is set. */
detachAfterMs?: number
loadScript?: FlowStepScriptLoader
loadFlowPreviewValue?: FlowStepPreviewLoader
}
/** One step resolved to the job it would start, short of starting it, so a caller that
* puts an argument form in front of the run can build the form's fields from the same
* read the job uses. Schema inference lives with the caller: this module is kept on a
* shallow import list (see the note at the top of the file). */
export type ResolvedFlowStepRun = {
module: FlowModule
runnableKind: 'script' | 'flow'
/** A subflow step carries `schema` instead, having no code of its own to read. */
code?: string
lang?: ScriptLang
schema?: Record<string, any>
startMessage: string
/** Takes the arguments as submitted. The preprocessor's entrypoint override is added
* here rather than by the caller: it is declared by no schema, so anything that
* conforms arguments to one would drop it. */
startJob: (args: Record<string, any>) => Promise<string>
}
function normalizeFlowStepArgs(args: Record<string, any> | null | undefined): Record<string, any> {
@@ -2065,25 +2092,26 @@ function getAvailableFlowStepIds(flowValue: FlowValue): string {
async function loadDeployedScriptForFlowStep(
moduleValue: { path: string; hash?: string },
workspace: string
): Promise<{ content: string; language: ScriptLang }> {
): Promise<{ content: string; language: ScriptLang; schema?: Record<string, any> }> {
const script = moduleValue.hash
? await ScriptService.getScriptByHash({ workspace, hash: moduleValue.hash })
: await ScriptService.getScriptByPath({ workspace, path: moduleValue.path })
return { content: script.content, language: script.language }
return {
content: script.content,
language: script.language,
schema: script.schema as Record<string, any> | undefined
}
}
export async function executeFlowStepTestRun({
export async function resolveFlowStepRun({
flowValue,
stepId,
args,
workspace,
toolCallbacks,
toolId,
background,
detachAfterMs,
loadScript = loadDeployedScriptForFlowStep,
loadFlowPreviewValue
}: FlowStepTestRunConfig): Promise<string> {
loadSubflow
}: FlowStepRunConfig): Promise<ResolvedFlowStepRun> {
const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined
if (!targetModule) {
@@ -2097,94 +2125,76 @@ export async function executeFlowStepTestRun({
}
const moduleValue = targetModule.value
const stepArgs = normalizeFlowStepArgs(args)
const withEntrypoint = (args: Record<string, any>) => flowStepArgsForModule(targetModule.id, args)
if (moduleValue.type === 'rawscript') {
return executeTestRun({
jobStarter: () =>
return {
module: targetModule,
runnableKind: 'script',
code: moduleValue.content ?? '',
lang: moduleValue.language,
startMessage: `Starting test run of step "${stepId}"...`,
startJob: (args) =>
JobService.runScriptPreview({
workspace,
requestBody: {
content: moduleValue.content ?? '',
language: moduleValue.language,
args: flowStepArgsForModule(targetModule.id, stepArgs)
args: withEntrypoint(args)
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of step "${stepId}"...`,
contextName: 'script',
label: `step ${stepId}`,
background,
detachAfterMs
})
})
}
}
if (moduleValue.type === 'script') {
const script = await loadScript(moduleValue, workspace)
return executeTestRun({
jobStarter: () =>
return {
module: targetModule,
runnableKind: 'script',
code: script.content,
lang: script.language,
schema: script.schema,
startMessage: `Starting test run of script step "${stepId}"...`,
startJob: (args) =>
JobService.runScriptPreview({
workspace,
requestBody: {
path: moduleValue.path,
content: script.content,
language: script.language,
args: flowStepArgsForModule(targetModule.id, stepArgs)
args: withEntrypoint(args)
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of script step "${stepId}"...`,
contextName: 'script',
label: `step ${stepId}`,
background,
detachAfterMs
})
})
}
}
if (moduleValue.type === 'flow') {
const previewValue = await loadFlowPreviewValue?.(moduleValue.path, workspace)
if (previewValue) {
return executeTestRun({
jobStarter: () =>
JobService.runFlowPreview({
workspace,
requestBody: {
const subflow = await loadSubflow?.(moduleValue.path, workspace)
const previewValue = subflow?.previewValue
return {
module: targetModule,
runnableKind: 'flow',
schema: subflow?.schema,
startMessage: previewValue
? `Starting test run of draft flow step "${stepId}"...`
: `Starting test run of flow step "${stepId}"...`,
startJob: (args) =>
previewValue
? JobService.runFlowPreview({
workspace,
requestBody: { path: moduleValue.path, value: previewValue, args }
})
: JobService.runFlowByPath({
workspace,
path: moduleValue.path,
value: previewValue,
args: stepArgs
}
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of draft flow step "${stepId}"...`,
contextName: 'flow',
label: `step ${stepId}`,
background,
detachAfterMs
})
requestBody: args,
// As the flow editor's own step test does: these are the subflow's main input
// schema's arguments, and a preprocessor would take them for a trigger event
// and hand the flow its own output instead. A parent flow runs a subflow step
// the same way (apply_preprocessor: false).
skipPreprocessor: true
})
}
return executeTestRun({
jobStarter: () =>
JobService.runFlowByPath({
workspace,
path: moduleValue.path,
requestBody: stepArgs
}),
workspace,
toolCallbacks,
toolId,
startMessage: `Starting test run of flow step "${stepId}"...`,
contextName: 'flow',
label: `step ${stepId}`,
background,
detachAfterMs
})
}
toolCallbacks.setToolStatus(toolId, {
@@ -2196,6 +2206,26 @@ export async function executeFlowStepTestRun({
)
}
export async function executeFlowStepTestRun({
args,
background,
detachAfterMs,
...config
}: FlowStepTestRunConfig): Promise<string> {
const resolved = await resolveFlowStepRun(config)
return executeTestRun({
jobStarter: () => resolved.startJob(normalizeFlowStepArgs(args)),
workspace: config.workspace,
toolCallbacks: config.toolCallbacks,
toolId: config.toolId,
startMessage: resolved.startMessage,
contextName: resolved.runnableKind,
label: `step ${config.stepId}`,
background,
detachAfterMs
})
}
function formatLogs(logs: string | undefined): undefined | string {
if (logs && logs.trim()) {
if (logs.length <= MAX_LOG_LENGTH) {