Files
windmill/frontend/src/lib/components/ModuleTest.svelte
T
hugocasaandClaude Opus 5 8f349c032a fix: nested template literals in step inputs, and unresolvable $args tags (#10856)
* fix(frontend): keep nested template literals intact in template inputs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: fail a flow step with an unresolvable $args tag instead of hanging

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): surface input expression errors when running a step test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): treat an escaped \${ as literal text when escaping backticks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: accept the string "null" as a tag component, reject only JSON null

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: leave a same_worker step's inert tag alone, log an unresolved flow tag

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): escape every backtick when the template walk desynchronizes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: leave a dedicated runnable's inert step tag alone

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reroute a step only when its own tag is what failed to resolve

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: name the inert-tag guard step_is_pulled_by_tag

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reject a tag only when it interpolates to nothing at all

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): validate the template walk instead of trusting a balanced stack

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: describe what an unresolvable tag actually interpolates to

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(frontend): decide template escaping with a real parser, not a hand-rolled scan

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: name is_flow_step on push now that it is load-bearing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): heal an expression escaped before nested templates were handled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reroute a step whose tag reads args that failed to evaluate

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: never hand a job that failed before running to a dedicated runner

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reroute only a step whose args failed, leave other tags untouched

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: drop the post-preprocessor tag fallback, leaving tag resolution untouched

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: leave interpolate_args exactly as it was

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: use a generic example in the template literal tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): show an expression escaped by the old rule as it was authored

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): surface input expression errors from every step-run entry point

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: state what is_dedicated_worker actually reads

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): heal only text whose backticks were all escaped by the old rule

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): match the old rule textually so an authored backslash still heals

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): heal only expressions the old rule broke, never ones that parse

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:27:54 +02:00

254 lines
7.0 KiB
Svelte

<script lang="ts">
import {
ScriptService,
type AiAgent,
type FlowModule,
type JavascriptTransform,
type Job
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getScriptByPath } from '$lib/scripts'
import { getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import JobLoader, { type Callbacks } from './JobLoader.svelte'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
import { loadSchemaFromModule } from './flows/flowInfers'
interface Props {
mod: FlowModule
testJob?: Job | undefined
testIsLoading?: boolean
noEditor?: boolean
scriptProgress?: any
onJobDone?: () => void
}
let {
mod,
testJob = $bindable(undefined),
testIsLoading = $bindable(false),
noEditor = false,
scriptProgress = $bindable(undefined),
onJobDone
}: Props = $props()
const {
flowStore,
flowStateStore,
pathStore,
stepsInputArgs,
previewArgs,
modulesTestStates,
devTempScriptRefs,
opWorkspace
} = getContext<FlowEditorContext>('FlowEditorContext')
// Acting workspace when the flow editor runs in an AI session; else the nav workspace.
let opWs = $derived(opWorkspace?.() ?? $workspaceStore)
let jobLoader: JobLoader | undefined = $state(undefined)
let jobProgressReset: () => void = () => {}
let stepHistoryLoader = getStepHistoryLoaderContext()
// Every explicit run re-evaluates the args with errors surfaced. The reactive evaluations
// that follow each flow edit stay quiet, so without this a failing expression is silently
// `undefined` in what the run is built from. Manually edited args are preserved across the
// refresh by `initializeFromSchema`.
export function runTestWithStepArgs() {
stepsInputArgs?.updateStepArgs(
mod.id,
flowStateStore.val,
flowStore?.val,
previewArgs?.val,
true
)
runTest(stepsInputArgs.getStepArgs(mod.id))
}
// A step's timeout is an InputTransform. Only a static numeric value can be applied
// to a single-step preview; dynamic expressions are evaluated server-side and only
// take effect when running the full flow.
function staticTimeout(timeout: FlowModule['timeout']): number | undefined {
if (timeout?.type === 'static' && typeof timeout.value === 'number') {
return timeout.value
}
return undefined
}
export async function runTest(args: any) {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset()
if (modulesTestStates.states[mod.id]) {
modulesTestStates.states[mod.id].cancel = async () => {
await jobLoader?.cancelJob()
modulesTestStates.states[mod.id].testJob = undefined
}
modulesTestStates.runTestCb?.(mod.id)
}
const val = mod.value
const timeout = staticTimeout(mod.timeout)
// let jobId: string | undefined = undefined
let callbacks: Callbacks = {
done: (x) => {
jobDone(x)
}
}
if (val.type == 'rawscript') {
await jobLoader?.runPreview(
val.path ?? ($pathStore ?? '') + '/' + mod.id,
val.content,
val.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore?.val?.tag ?? val.tag,
undefined,
undefined,
callbacks,
$pathStore,
undefined,
devTempScriptRefs?.(),
timeout
)
} else if (val.type == 'script') {
const script = val.hash
? await ScriptService.getScriptByHash({ workspace: opWs!, hash: val.hash })
: await getScriptByPath(val.path, opWs)
await jobLoader?.runPreview(
val.path,
script.content,
script.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore?.val?.tag ?? (val.tag_override ? val.tag_override : script.tag),
script.lock,
val.hash ?? script.hash,
callbacks,
$pathStore,
undefined,
undefined,
timeout
)
} else if (val.type == 'flow') {
await jobLoader?.runFlowByPath(val.path, args, callbacks)
} else if (val.type == 'aiagent') {
const { schema } = await loadSchemaFromModule(mod, opWs)
const inputTransforms: { [key: string]: JavascriptTransform } = Object.fromEntries(
Object.keys(args).map((key) => [
key,
{
expr: `flow_input.${key}`,
type: 'javascript'
}
])
)
const agentVal = val
await jobLoader?.runFlowPreview(
args,
{
value: {
modules: [
{
id: mod.id,
// A linked step has no tools of its own: the resource's tools are resolved
// server-side from `agent`. `tool_inputs` goes in either way — a step forked
// for editing has no `agent` yet still carries the flow's bindings, which the
// runtime overlays, so the preview must test against them too.
value: {
type: 'aiagent',
...(agentVal.agent ? { agent: agentVal.agent } : { tools: agentVal.tools ?? [] }),
tool_inputs: agentVal.tool_inputs,
input_transforms: inputTransforms as AiAgent['input_transforms']
} as Extract<FlowModule['value'], { type: 'aiagent' }>
}
]
},
summary: '',
schema
},
callbacks,
$pathStore
)
} else {
throw Error('Not supported module type')
}
}
function jobDone(testJob: Job & { result?: any }) {
if (testJob && !testJob.canceled && testJob.type == 'CompletedJob') {
if (flowStateStore.val[mod.id]) {
flowStateStore.val[mod.id] = {
...flowStateStore.val[mod.id],
previewResult: testJob.result,
previewSuccess: testJob.success,
previewJobId: testJob.id,
previewLogs: testJob['logs']
}
}
stepHistoryLoader?.resetInitial(mod.id)
}
if (modulesTestStates.states[mod.id]) {
modulesTestStates.states[mod.id].testJob = testJob
}
onJobDone?.()
}
export function cancelJob() {
modulesTestStates.states[mod.id]?.cancel?.()
}
$effect(() => {
// Update testIsLoading to read the state from parent components
testIsLoading = modulesTestStates.states?.[mod.id]?.loading ?? false
})
$effect(() => {
// Update testJob to read the state from parent components
testJob = modulesTestStates.states?.[mod.id]?.testJob
})
modulesTestStates.states[untrack(() => mod).id] = {
...(modulesTestStates.states?.[untrack(() => mod).id] ?? { loading: false }),
loading: testIsLoading,
testJob: testJob
}
</script>
<JobLoader
noCode={true}
toastError={noEditor}
workspaceOverride={opWs}
bind:scriptProgress
bind:this={jobLoader}
bind:isLoading={
() => modulesTestStates.states[mod.id]?.loading ?? false,
(v) => {
let newLoading = v ?? false
if (modulesTestStates.states && modulesTestStates.states?.[mod.id]?.loading !== newLoading) {
modulesTestStates.states[mod.id] = {
...(modulesTestStates.states?.[mod.id] ?? {}),
loading: newLoading,
hiddenInGraph: false
}
}
}
}
bind:job={
() => modulesTestStates.states[mod.id]?.testJob,
(v) => modulesTestStates.states[mod.id] && (modulesTestStates.states[mod.id].testJob = v)
}
loadPlaceholderJobOnStart={{
type: 'QueuedJob',
id: '',
running: false,
canceled: false,
job_kind: 'preview',
permissioned_as: '',
is_flow_step: false,
email: '',
visible_to_owner: true,
tag: ''
}}
/>