Files
windmill/frontend/src/lib/components/ModuleTest.svelte
T
hugocasaandClaude Opus 5 7feaf619cf feat: run a linked AI agent's draft when testing a flow, and offer to deploy it (#10993)
* feat(frontend): run a linked agent's draft when testing a flow, and offer to deploy it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): settle an agent's autosave before reading it, and refresh its card on a draft save

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): deploy the agent draft that was validated, and make the draft-tools flag explicit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse a stale agent deploy, and warn when a never-deployed agent is kept as a draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* docs: record what inlining an agent draft puts in a preview job

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): name the draft-changes dialog after what it lists

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse a draft deploy when the draft row is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): apply the missing-draft refusal to raw apps too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): stop reading a deployed resource row as a draft on deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): do not mistake an outage or a vanished draft for a deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse an agent read whose pending draft save failed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): space the trigger badges and right-align the agent actions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): surface a failed agent-draft read instead of dropping it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): give the agent draft delete a baseline so a newer edit survives

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): drop the agent draft cell locally instead of deleting twice

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* test(frontend): pass the withDraft flag the guard tests were missing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* refactor(frontend): deploy agent drafts the way Review & Deploy does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): give the read-only flow graph its own linked-tools bucket

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): base the resource draft delete on the read that promoted it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refresh every step linking an agent when its draft is saved

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): write nothing at all when a resource draft has gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* test(frontend): pin that the resource draft delete follows its baseline seed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:04:38 +02:00

306 lines
9.8 KiB
Svelte

<script lang="ts">
import {
ScriptService,
type AiAgent,
type FlowModule,
type InputTransform,
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'
import {
inlineAgentDraft,
loadLinkedAgentDrafts,
normalizeAgentRef,
type LinkedAgentDraft
} from './flows/linkedAgentDrafts'
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
import { sendUserToast } from '$lib/toast'
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')
let previewBase = $derived($pathStore ?? '')
// 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(
// An empty base stays empty: `'' + '/' + id` is an absolute path, which
// `require_path_read_access_for_preview` rejects outright. A flow with no path yet
// previews unnamed instead.
val.path ?? (previewBase ? previewBase + '/' + mod.id : ''),
val.content,
val.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore?.val?.tag ?? val.tag,
undefined,
undefined,
callbacks,
previewBase,
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,
previewBase,
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)
// A linked step whose agent has an unsaved draft is tested as the draft, the same way the
// whole-flow preview and the agent editor's own test pane run it. `inlineAgentDraft`
// clears `agent` and moves the draft's brain and tools onto the step, so the branches
// below then treat it as a standalone agent.
let draft: LinkedAgentDraft | undefined
if (val.agent) {
const linked = normalizeAgentRef(val.agent)
try {
draft = (await loadLinkedAgentDrafts([linked], opWs)).get(linked)
} catch (err: any) {
// The load refuses when the agent's unsaved changes cannot be read, and this function's
// caller neither awaits nor catches: without this the rejection is unhandled and the
// button appears to do nothing, with the test already marked as started.
sendUserToast(`Could not run test: ${err?.body ?? err}`, true)
// Guarded like every other access to it here: the entry is only created for steps the
// panel is tracking, and this runs on a path where it may never have been.
if (modulesTestStates.states[mod.id]) {
modulesTestStates.states[mod.id].loading = false
}
return
}
}
const agentVal = draft ? inlineAgentDraft(val, draft.args) : val
// `args` is built from the whole AI agent schema whatever the step is, so on a linked step
// it carries every brain key as undefined even though the form renders only the flow-local
// ones (`flowLocalAgentSchema`). Overlaying those would shadow the brain the draft just
// supplied with nothing, so an inlined step takes only the inputs its form actually offers.
const formKeys = draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
// The test form only covers the schema it was given, and for a standalone agent that may be
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
// brain from the module as authored and let the form's own keys win over it, so an edit made
// in the form after the test panel mounted is what runs. A linked agent needs none of this:
// the server reads its brain from the resource.
const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = {
...(agentVal.agent
? {}
: ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>)),
...Object.fromEntries(
formKeys.map((key) => [
key,
{
expr: `flow_input.${key}`,
type: 'javascript'
}
])
)
}
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,
previewBase
)
} 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: ''
}}
/>