Files
windmill/cli/src/commands/flow/flow.ts
T
GuilhemandClaude Opus 5 e39dd7eb12 docs: teach agents to pass a resource as $res:<path> in run arguments (#10927)
* docs: teach agents to pass a resource as $res:<path> in run arguments

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

* docs: extend run-argument rule to in-editor chats, fix run-as wording

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

* docs: tighten resource run-argument rule after review

- Drop the false rationale that "$var:" only works inside a resource value
  from the write_variable description and its runtime rejection message; keep
  the rule (a variable cannot reference itself).
- MCP resource-argument description: the title fallback renders "No title",
  so say the title is only a label rather than that it can be empty. Guard the
  real-newline fix with asserts in the existing enrichment test.
- Eval: assert the full "$res:f/evals/global/github_main" value as one prefix
  so a wrong path with a right prefix fails.
- resources.md: narrow "a trigger's payload" to its configured static args.

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

* docs: scope the run-argument rule to global chat, add an exact eval matcher

The ai_evals A/B on the two in-editor modes showed no effect: script mode
sonnet 5/5 both with and without the description, flow mode sonnet 5/5 and
haiku 5/5 on the baseline alone. A flow's input schema already carries
`format: resource-<type>`, so those modes have a signal global mode does not
give. Revert both files to keep the tool schemas free of a description that
buys nothing per iteration; global mode keeps it, where haiku goes 0/5 -> 5/5.

Add `stringEqualsAnyOf` to toolCallArgs and use it for the resource reference:
nothing in the eval resolves the value, so a prefix match accepted a near-miss
path like `$res:f/evals/global/github_main_backup`.

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

* docs: address cubic review — CLI wording, mock resource getter

- `-d --data` help on all four run/preview commands: give $res: and $var:
  their own clauses instead of a parenthetical that read as if a resource
  were a kind of variable.
- Mock backend: `getBenchmarkResource` now resolves AI-provider seeds as well
  as plain ones, so it agrees with `existsResource` and `listResource` — both
  report either kind, and a case that listed a resource and then read it by
  path got a row it could not fetch.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 11:29:54 +02:00

1252 lines
42 KiB
TypeScript

import { GlobalOptions, isSuperset } from "../../types.ts";
import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
import { Table } from "@cliffy/table";
import * as log from "../../core/log.ts";
import { dirname, sep as SEP } from "node:path";
import { stringify as yamlStringify } from "yaml";
import { yamlParseFile } from "../../utils/yaml.ts";
import { readTextFile, validateRequiredArgs } from "../../utils/utils.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { mkdirSync, writeFileSync } from "node:fs";
import { buildFolderPath, getMetadataFileName, loadNonDottedPathsSetting } from "../../utils/resource_folders.ts";
import { requireLogin } from "../../core/auth.ts";
import {
assertRemotePath,
resolveWorkspace,
toSyncRootRelativePath,
validatePath,
} from "../../core/context.ts";
import { resolve, track_job, pollForJobResult } from "../script/script.ts";
import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts";
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts";
import { Flow } from "../../../gen/types.gen.ts";
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import {
collectPathScriptPaths,
replaceInlineScripts,
replaceAllPathScriptsWithLocal,
} from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { generateFlowLockInternal } from "./flow_metadata.ts";
import { exts } from "../script/script.ts";
import type { SyncCodebase } from "../../utils/codebase.ts";
import { listSyncCodebases } from "../../utils/codebase.ts";
import {
createPreviewLocalScriptReader,
resolvePreviewLocalScriptState,
} from "../../utils/local_path_scripts.ts";
export interface FlowFile {
summary: string;
description?: string;
value: any;
schema?: any;
on_behalf_of_email?: string;
has_on_behalf_of?: boolean;
// Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no
// perms are set. The CLI applies diffs through /acls/add and /acls/remove
// (see applyExtraPermsDiff) — never through update_flow — so a perm-only
// change never bumps the flow version.
extra_perms?: Record<string, boolean>;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
return typeof value === "string" && value.trim() === "" ? undefined : value ?? undefined;
}
function normalizeComparableContent(value: string | undefined): string | undefined {
return value?.replaceAll("\r\n", "\n").replace(/\n$/, "");
}
async function findDivergedLocalPathScripts(
workspaceId: string,
scriptPaths: string[],
opts: {
exts: string[];
defaultTs?: "bun" | "deno";
codebases: SyncCodebase[];
}
): Promise<{ changed: string[]; missing: string[] }> {
const changed: string[] = [];
const missing: string[] = [];
for (const scriptPath of scriptPaths) {
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
if (!localScript) {
continue;
}
let remoteScript;
try {
remoteScript = await wmill.getScriptByPath({
workspace: workspaceId,
path: scriptPath,
});
} catch {
missing.push(scriptPath);
continue;
}
const remoteLock = normalizeOptionalString(remoteScript.lock);
const diverged =
normalizeComparableContent(localScript.content) !==
normalizeComparableContent(remoteScript.content) ||
localScript.language !== remoteScript.language ||
(localScript.lock !== undefined &&
normalizeComparableContent(localScript.lock) !==
normalizeComparableContent(remoteLock)) ||
localScript.tag !== normalizeOptionalString(remoteScript.tag) ||
localScript.codebaseDigest !== normalizeOptionalString(remoteScript.codebase);
if (diverged) {
changed.push(scriptPath);
}
}
return { changed, missing };
}
function warnAboutLocalPathScriptDivergence(
divergence: { changed: string[]; missing: string[] }
): void {
if (divergence.changed.length === 0 && divergence.missing.length === 0) {
return;
}
const details: string[] = [];
if (divergence.changed.length > 0) {
details.push(
`These workspace scripts differ from the deployed version:\n${divergence.changed
.map((path) => `- ${path}`)
.join("\n")}`
);
}
if (divergence.missing.length > 0) {
details.push(
`These scripts do not exist in the workspace yet:\n${divergence.missing
.map((path) => `- ${path}`)
.join("\n")}`
);
}
log.warn(
`Using local PathScript files for flow preview.\n${details.join("\n")}\nUse --remote to preview deployed workspace scripts instead.`
);
}
const alreadySynced: string[] = [];
// Collect every script/sub-flow step path in a flow value — recursively through loops,
// branches, and the failure/preprocessor modules — for workspace-path validation. Unlike
// `collectPathScriptPaths` this also includes `type: "flow"` sub-flow steps.
function collectStepPaths(flowValue: any): string[] {
const paths: string[] = [];
const walk = (modules: any[] | undefined) => {
for (const m of modules ?? []) {
const v = m?.value;
if (!v) continue;
if ((v.type === "script" || v.type === "flow") && typeof v.path === "string") {
paths.push(v.path);
}
walk(v.modules);
walk(v.default);
for (const b of v.branches ?? []) walk(b?.modules);
// AI-agent tools are step-like and can carry script paths too.
walk(v.tools);
}
};
walk(flowValue?.modules);
if (flowValue?.failure_module) walk([flowValue.failure_module]);
if (flowValue?.preprocessor_module) walk([flowValue.preprocessor_module]);
return paths;
}
export async function pushFlow(
workspace: string,
remotePath: string,
localPath: string,
message?: string,
permissionedAsContext?: PermissionedAsContext
): Promise<void> {
if (alreadySynced.includes(localPath)) {
return;
}
alreadySynced.push(localPath);
remotePath = remotePath.replaceAll(SEP, "/");
let flow: Flow | undefined = undefined;
try {
flow = await wmill.getFlowByPath({
workspace: workspace,
path: remotePath,
});
} catch {
// flow doesn't exist
}
if (!localPath.endsWith(SEP)) {
localPath += SEP;
}
const localFlow = (await yamlParseFile(localPath + "flow.yaml")) as FlowFile;
const fileReader = async (path: string) => await readTextFile(localPath + path);
const missingFiles: string[] = [];
await replaceInlineScripts(
localFlow.value.modules,
fileReader,
log,
localPath,
SEP,
undefined,
missingFiles
);
if (localFlow.value.failure_module) {
await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, localPath, SEP, undefined, missingFiles);
}
if (localFlow.value.preprocessor_module) {
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP, undefined, missingFiles);
}
if (missingFiles.length > 0) {
// Hard-fail rather than push the literal `!inline path` text as
// rawscript.content. That string would be persisted in flow_version.value
// and round-trip as the script body on the next pull, overwriting the
// user's local handler with the directive — see GIT-871 / #9140.
throw new Error(
`Cannot push flow ${remotePath}: missing inline script file(s): ${missingFiles.join(", ")}. ` +
`Either restore the file(s) or remove the !inline reference(s) from flow.yaml before pushing.`
);
}
// Reject script/sub-flow steps whose path is not a workspace path (u/, f/, g/ or hub/).
// A flow.yaml generated from a feature-branch checkout can carry absolute local paths
// (e.g. /tmp/.../ops/scripts/...); pushed, they silently mis-resolve at runtime (#9751).
// The backend re-validates the same rule for every step type, so this is a fail-fast.
const badStepPaths = collectStepPaths(localFlow.value).filter(
(p) => p !== "" && !/^(u|f|g|hub)\//.test(p)
);
if (badStepPaths.length > 0) {
throw new Error(
`Cannot push flow ${remotePath}: step(s) reference non-workspace path(s): ${badStepPaths.join(", ")}. ` +
`Flow step paths must be workspace paths (u/, f/, g/ or hub/), not absolute or local filesystem paths. ` +
`This usually means flow.yaml was generated with paths from a checkout directory.`
);
}
const hasOnBehalfOf = (localFlow as any).has_on_behalf_of ?? !!localFlow.on_behalf_of_email;
delete (localFlow as any).has_on_behalf_of;
// The authorization half of the identity is never exported to the repo (the
// workspace tarball strips it); it only ever travels back from the remote row.
delete (localFlow as any).on_behalf_of;
const preserveFields: {
on_behalf_of_email?: string;
on_behalf_of?: string;
preserve_on_behalf_of?: boolean;
} = {};
if (permissionedAsContext?.userIsAdminOrDeployer && hasOnBehalfOf) {
if (flow && flow.on_behalf_of_email) {
preserveFields.on_behalf_of_email = flow.on_behalf_of_email;
preserveFields.on_behalf_of = (flow as any).on_behalf_of;
preserveFields.preserve_on_behalf_of = true;
log.info(`Preserving ${flow.on_behalf_of_email} as on_behalf_of for flow ${remotePath}`);
}
// On create: backend applies folder defaults — no client-side resolution needed
}
// extra_perms is synced independently via /acls/* (see applyExtraPermsDiff)
// so a perm-only edit never bumps the flow version. Strip the field from the
// body that goes to update_flow / create_flow and treat it as a separate
// step both for the up-to-date short-circuit and after the deploy.
const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & {
extra_perms?: Record<string, boolean>;
};
if (flow) {
if (isSuperset(localFlowBody, flow)) {
log.info(colors.green(`Flow ${remotePath} is up to date`));
} else {
log.info(colors.bold.yellow(`Updating flow ${remotePath}...`));
await wmill.updateFlow({
workspace: workspace,
path: remotePath.replaceAll(SEP, "/"),
requestBody: {
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlowBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
} else {
log.info(colors.bold.yellow("Creating new flow..."));
try {
await wmill.createFlow({
workspace: workspace,
requestBody: {
path: remotePath.replaceAll(SEP, "/"),
deployment_message: message,
...localFlowBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
} catch (e) {
throw new Error(
//@ts-ignore
`Failed to create flow ${remotePath}: ${e.body ?? e.message}`
);
}
}
// Independent of whether the flow body changed, sync extra_perms via /acls/*.
// Self-contained log line + non-fatal failures.
//
// No refetch is needed: extra_perms is item-specific and additive on top of
// folder perms — folder perms are never merged onto item.extra_perms. And
// since the request body sent to update_flow / create_flow doesn't carry
// extra_perms, the value we read in the initial getFlowByPath above is
// also the post-write value (a no-op deploy can't drift it).
await applyExtraPermsDiff(
workspace,
"flow",
remotePath.replaceAll(SEP, "/"),
localPerms,
(flow as any)?.extra_perms,
);
}
type Options = GlobalOptions;
async function push(opts: Options & { message?: string }, filePath: string, remotePath: string) {
if (!validatePath(remotePath)) {
return;
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
await pushFlow(workspace.workspaceId, remotePath, filePath, opts.message);
log.info(colors.bold.underline.green("Flow pushed"));
}
async function list(
opts: GlobalOptions & { showArchived?: boolean; includeDraftOnly?: boolean; json?: boolean }
) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
let page = 0;
const perPage = 10;
const total: Flow[] = [];
while (true) {
const res = await wmill.listFlows({
workspace: workspace.workspaceId,
page,
perPage,
showArchived: opts.showArchived ?? false,
includeDraftOnly: opts.includeDraftOnly ?? false,
});
page += 1;
total.push(...res);
if (res.length < perPage) {
break;
}
}
if (opts.json) {
console.log(JSON.stringify(total));
} else {
new Table()
.header(["path", "summary", "edited by"])
.padding(2)
.border(true)
.body(total.map((x) => [x.path, x.summary, x.edited_by]))
.render();
}
}
async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const f = await wmill.getFlowByPath({
workspace: workspace.workspaceId,
path,
});
if (opts.json) {
console.log(JSON.stringify(f));
} else {
console.log(colors.bold("Path:") + " " + f.path);
console.log(colors.bold("Summary:") + " " + (f.summary ?? ""));
console.log(colors.bold("Description:") + " " + (f.description ?? ""));
console.log(colors.bold("Edited by:") + " " + (f.edited_by ?? ""));
console.log(colors.bold("Edited at:") + " " + (f.edited_at ?? ""));
// API response type doesn't include flow value/modules — cast needed to access them
const modules = (f as any).value?.modules;
if (modules && Array.isArray(modules) && modules.length > 0) {
console.log(colors.bold("Steps:"));
function printModules(mods: any[], indent: string = " ") {
for (const mod of mods) {
const type = mod.value?.type ?? "unknown";
const detail = mod.value?.language ?? mod.value?.path ?? "";
console.log(`${indent}${mod.id}: ${type}${detail ? " (" + detail + ")" : ""}`);
if (type === "branchall" || type === "branchone") {
for (const branch of mod.value?.branches ?? []) {
console.log(`${indent} Branch: ${branch.summary || "(default)"}`);
if (branch.modules) printModules(branch.modules, indent + " ");
}
if (type === "branchone" && mod.value?.default) {
console.log(`${indent} Default:`);
printModules(mod.value.default, indent + " ");
}
} else if (type === "forloopflow" || type === "whileloopflow") {
if (mod.value?.modules) printModules(mod.value.modules, indent + " ");
}
}
}
printModules(modules);
}
}
}
async function run(
opts: GlobalOptions & {
data?: string;
silent: boolean;
tag?: string;
},
path: string
) {
if (opts.silent) {
log.setSilent(true);
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const input = opts.data ? await resolve(opts.data) : {};
// Validate required args against schema when no data provided
if (!opts.data) {
try {
const flow = await wmill.getFlowByPath({
workspace: workspace.workspaceId,
path,
});
validateRequiredArgs(flow.schema as Record<string, unknown>);
} catch (e: any) {
if (e.message?.startsWith("Missing required")) throw e;
log.warn(`Could not fetch schema to validate args: ${e.message}`);
}
}
const id = await wmill.runFlowByPath({
workspace: workspace.workspaceId,
path,
tag: opts.tag,
requestBody: input,
});
// Build step label map from raw_flow if available
const stepLabels = new Map<string, string>();
try {
const initialJob = await wmill.getJob({
workspace: workspace.workspaceId,
id,
});
const rawFlow = (initialJob as any).raw_flow;
if (rawFlow?.modules) {
for (const mod of rawFlow.modules) {
if (mod.id) {
const label = mod.summary ? `${mod.id}: ${mod.summary}` : mod.id;
stepLabels.set(mod.id, label);
}
}
}
} catch {
// Best-effort — fall back to module IDs
}
let i = 0;
let lastStatus = "";
while (true) {
const jobInfo = await wmill.getJob({
workspace: workspace.workspaceId,
id,
});
// Check if flow has completed (success or failure)
const isCompleted = (jobInfo as any).type === "CompletedJob";
const flowStatus = jobInfo.flow_status!;
if (flowStatus.modules.length <= i) {
break;
}
const module = flowStatus.modules[i];
// If a module has failed, track its job (to show error logs), then break
if (module.type === "Failure") {
if (module.job && !opts.silent) {
const label = stepLabels.get(module.id!) ?? `Step ${i + 1}`;
log.info("====== " + label + " ======");
await track_job(workspace.workspaceId, module.job);
}
break;
}
if (module.job) {
const label = stepLabels.get(module.id!) ?? `Step ${i + 1}`;
const isForLoop = (module as any).flow_jobs !== undefined;
if (isForLoop) {
// For-loop: track iterations as they appear, re-polling until module completes
let trackedIterations = 0;
let forLoopFailed = false;
while (true) {
const refreshed = await wmill.getJob({
workspace: workspace.workspaceId,
id,
});
const refreshedModule = refreshed.flow_status!.modules[i];
const flowJobs = ((refreshedModule as any).flow_jobs as string[] | undefined) ?? [];
// Track any new iterations
while (trackedIterations < flowJobs.length) {
if (!opts.silent) {
log.info(`====== ${label} (iteration ${trackedIterations}) ======`);
await track_job(workspace.workspaceId, flowJobs[trackedIterations]);
}
trackedIterations++;
}
if (refreshedModule.type === "Success" || refreshedModule.type === "Failure") {
forLoopFailed = refreshedModule.type === "Failure";
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (forLoopFailed) break;
} else {
if (!opts.silent) {
log.info("====== " + label + " ======");
await track_job(workspace.workspaceId, module.job);
}
}
} else {
// Module not started yet — deduplicate status messages
const status = String(module.type);
if (!opts.silent && status !== lastStatus) {
log.info(colors.dim(status));
lastStatus = status;
}
await new Promise((resolve, _) =>
setTimeout(() => resolve(undefined), 100)
);
// If flow already completed while we were waiting, break out
if (isCompleted) break;
continue;
}
lastStatus = "";
i++;
}
// Wait for flow completion with retry (handles race when --silent skips module tracking)
const MAX_RETRIES = 600; // ~60 seconds at 100ms intervals
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const jobInfo = await wmill.getCompletedJob({
workspace: workspace.workspaceId,
id,
});
if (!opts.silent) {
if (jobInfo.success === false) {
log.info(colors.red.underline.bold("Flow failed"));
} else {
log.info(colors.green.underline.bold("Flow ran to completion"));
}
log.info("\n");
}
if (jobInfo.success === false) {
process.exitCode = 1;
}
if (opts.silent) {
console.log(JSON.stringify(jobInfo.result ?? {}));
} else {
log.info(JSON.stringify(jobInfo.result ?? {}, null, 2));
}
break;
} catch {
retries++;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (retries >= MAX_RETRIES) {
throw new Error(`Timed out waiting for flow ${id} to complete`);
}
}
async function preview(
opts: GlobalOptions & {
data?: string;
silent: boolean;
remote?: boolean;
step?: string;
tag?: string;
} & SyncOptions,
flowPath: string
) {
if (opts.silent) {
log.setSilent(true);
}
const useLocalPathScripts = !opts.remote;
// Captured before the config read, which chdirs to the wmill.yaml root.
const cwdBeforeConfig = process.cwd();
if (useLocalPathScripts) {
opts = await mergeConfigWithConfigFile(opts);
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const codebases = useLocalPathScripts ? listSyncCodebases(opts) : [];
const argPath = flowPath;
flowPath = toSyncRootRelativePath(flowPath, cwdBeforeConfig);
// Normalize path - ensure it's a directory path to a .flow or __flow folder
const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP)
|| flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP);
if (!isFlowDir) {
// Check if it's a flow.yaml file
if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) {
// Use dirname so a bare "flow.yaml" (no parent dir) becomes "."
// instead of "" — the latter, after appending SEP below, becomes "/"
// and silently reads from filesystem root.
flowPath = dirname(flowPath);
} else {
throw new Error(
"Flow path must be a .flow/__flow directory or a flow.yaml file"
);
}
}
if (!flowPath.endsWith(SEP)) {
flowPath += SEP;
}
// The flow's windmill path (e.g. "f/cli_smoke/myrelflow"). It is what the
// preview job runs under, and the anchor for relative-import resolution:
// inline scripts in this flow are treated as living at
// "<flow_wm_path>/<step_id>", so "./util" resolves to
// "<flow_wm_path_parent>/util" — matching the keys in temp_script_refs.
const flowWmPath = stripFlowSuffix(flowPath).replaceAll(SEP, "/");
assertRemotePath(flowWmPath, argPath);
// Read and parse the flow definition
const localFlow = (await yamlParseFile(flowPath + "flow.yaml")) as FlowFile;
// Replace inline scripts with their actual content
const fileReader = async (path: string) => await readTextFile(flowPath + path);
await replaceInlineScripts(
localFlow.value.modules,
fileReader,
log,
flowPath,
SEP
);
if (localFlow.value.failure_module) {
await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, flowPath, SEP);
}
if (localFlow.value.preprocessor_module) {
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP);
}
if (useLocalPathScripts) {
const scriptPaths = collectPathScriptPaths(localFlow.value);
if (scriptPaths.length > 0) {
const divergence = await findDivergedLocalPathScripts(
workspace.workspaceId,
scriptPaths,
{
exts,
defaultTs: opts.defaultTs,
codebases,
}
);
if (!opts.silent) {
warnAboutLocalPathScriptDivergence(divergence);
}
}
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
}
// Resolve relative imports in inline scripts from local (not-yet-deployed)
// content so previewing a flow uses locally-edited dependency scripts.
let tempScriptRefs: Record<string, string> | undefined = undefined;
if (useLocalPathScripts) {
const { buildPreviewTempScriptRefs } = await import(
"../generate-metadata/generate-metadata.ts"
);
const resolvedCodebases = (await Promise.resolve(codebases)) as SyncCodebase[];
tempScriptRefs = await buildPreviewTempScriptRefs(
workspace,
opts,
resolvedCodebases,
{ kind: "flow", folder: flowPath }
);
}
const input = opts.data ? await resolve(opts.data) : {};
log.debug(`Flow value: ${JSON.stringify(localFlow.value, null, 2)}`);
// Single-step mode: run only the named module's runnable.
// The full-flow prep above (inline-script replacement, local PathScript
// substitution, tempScriptRefs build) is exactly what the single step needs
// too — PathScript modules have already been rewritten to inline rawscript
// when `useLocalPathScripts` is set, and tempScriptRefs covers relative
// imports in inline scripts.
if (opts.step) {
await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent, opts.tag);
return;
}
if (!opts.silent) {
log.info(colors.yellow(`Running flow preview for ${flowPath}...`));
}
// Run the flow preview — start the job, then poll for completion
const jobId = await wmill.runFlowPreview({
workspace: workspace.workspaceId,
requestBody: {
value: localFlow.value,
path: flowWmPath,
args: input,
tag: opts.tag,
temp_script_refs: tempScriptRefs,
},
});
const { result, success } = await pollForJobResult(workspace.workspaceId, jobId);
if (!success) {
if (opts.silent) {
console.log(JSON.stringify(result));
} else {
log.info(colors.yellow.bold("Flow failed, error handler result:"));
log.info(JSON.stringify(result, null, 2));
}
process.exitCode = 1;
return;
}
if (opts.silent) {
console.log(JSON.stringify(result));
} else {
log.info(colors.bold.underline.green("Flow preview completed"));
log.info(JSON.stringify(result, null, 2));
}
}
async function previewStep(
stepId: string,
localFlow: FlowFile,
flowWmPath: string,
workspace: { workspaceId: string },
baseArgs: Record<string, unknown>,
tempScriptRefs: Record<string, string> | undefined,
silent: boolean,
tag: string | undefined,
) {
const module = findStepInFlowValue(localFlow.value, stepId);
if (!module) {
const available = collectStepIds(localFlow.value).join(", ") || "(none)";
throw new Error(`Step '${stepId}' not found in flow. Available steps: ${available}`);
}
// The preprocessor module receives args via _ENTRYPOINT_OVERRIDE so the
// runner picks the preprocessor entrypoint (matches frontend behavior in
// copilot/chat/flow/core.ts).
const args =
stepId === "preprocessor"
? { _ENTRYPOINT_OVERRIDE: "preprocessor", ...baseArgs }
: baseArgs;
const moduleValue = module.value;
let jobId: string;
if (moduleValue?.type === "rawscript") {
log.info(colors.yellow(`Previewing step '${stepId}' (rawscript, ${moduleValue.language})...`));
jobId = await wmill.runScriptPreview({
workspace: workspace.workspaceId,
requestBody: {
content: moduleValue.content ?? "",
language: moduleValue.language,
// Anchor relative imports to "<flow_wm_path>/<step_id>" so
// temp_script_refs (keyed by Windmill paths) resolve correctly.
// Without `path`, the worker defaults to "tmp/main" and "../foo"
// resolves to "tmp/foo", missing every entry in temp_script_refs.
path: `${flowWmPath}/${stepId}`,
flow_path: flowWmPath,
args,
tag,
temp_script_refs: tempScriptRefs,
},
});
} else if (moduleValue?.type === "script") {
// Falls through here only when the deployed PathScript is what we want —
// either --remote was passed, or no local file exists for this path.
log.info(colors.yellow(`Previewing step '${stepId}' (script ${moduleValue.path})...`));
const script = moduleValue.hash
? await wmill.getScriptByHash({
workspace: workspace.workspaceId,
hash: moduleValue.hash,
})
: await wmill.getScriptByPath({
workspace: workspace.workspaceId,
path: moduleValue.path,
});
jobId = await wmill.runScriptPreview({
workspace: workspace.workspaceId,
requestBody: {
content: script.content,
language: script.language as any,
// Anchor to the script's own deployed path so its relative imports
// resolve against the workspace tree (or temp_script_refs).
path: moduleValue.path,
flow_path: flowWmPath,
args,
tag,
temp_script_refs: tempScriptRefs,
},
});
} else if (moduleValue?.type === "flow") {
log.info(colors.yellow(`Previewing step '${stepId}' (flow ${moduleValue.path})...`));
jobId = await wmill.runFlowByPath({
workspace: workspace.workspaceId,
path: moduleValue.path,
tag,
requestBody: args,
});
} else {
throw new Error(
`Cannot preview step of type '${moduleValue?.type ?? "unknown"}'. Supported types: rawscript, script, flow.`
);
}
const { result, success } = await pollForJobResult(workspace.workspaceId, jobId);
if (!success) {
if (silent) {
console.log(JSON.stringify(result));
} else {
log.info(colors.red.bold(`Step '${stepId}' failed:`));
log.info(JSON.stringify(result, null, 2));
}
process.exitCode = 1;
return;
}
if (silent) {
console.log(JSON.stringify(result));
} else {
log.info(colors.bold.underline.green(`Step '${stepId}' completed`));
log.info(JSON.stringify(result, null, 2));
}
}
// Strip the `.flow`/`__flow` directory suffix to recover the flow's logical
// Windmill path. Workspaces with nonDottedPaths use `__flow`; the default
// uses `.flow`. A previous version used `indexOf(".flow")` which returned -1
// (and thus `substring(0, -1) === ""`) for `__flow` folders and for the
// `dirname("flow.yaml") === "."` fallback — producing an empty path that
// broke relative-import resolution downstream.
function stripFlowSuffix(flowPath: string): string {
const stripped = flowPath.endsWith(SEP) ? flowPath.slice(0, -SEP.length) : flowPath;
if (stripped.endsWith(".flow")) return stripped.slice(0, -".flow".length);
if (stripped.endsWith("__flow")) return stripped.slice(0, -"__flow".length);
return stripped;
}
function findStepInFlowValue(flowValue: any, stepId: string): any | undefined {
if (!flowValue) return undefined;
if (flowValue.failure_module?.id === stepId) return flowValue.failure_module;
if (flowValue.preprocessor_module?.id === stepId) return flowValue.preprocessor_module;
return findStepInModules(flowValue.modules ?? [], stepId);
}
function findStepInModules(modules: any[], stepId: string): any | undefined {
for (const m of modules) {
if (m?.id === stepId) return m;
const v = m?.value;
if (!v) continue;
if (v.type === "forloopflow" || v.type === "whileloopflow") {
const found = findStepInModules(v.modules ?? [], stepId);
if (found) return found;
} else if (v.type === "branchone") {
for (const b of v.branches ?? []) {
const found = findStepInModules(b.modules ?? [], stepId);
if (found) return found;
}
const found = findStepInModules(v.default ?? [], stepId);
if (found) return found;
} else if (v.type === "branchall") {
for (const b of v.branches ?? []) {
const found = findStepInModules(b.modules ?? [], stepId);
if (found) return found;
}
}
}
return undefined;
}
function collectStepIds(flowValue: any): string[] {
const ids: string[] = [];
const walkModules = (modules: any[]) => {
for (const m of modules) {
if (m?.id) ids.push(m.id);
const v = m?.value;
if (!v) continue;
if (v.type === "forloopflow" || v.type === "whileloopflow") {
walkModules(v.modules ?? []);
} else if (v.type === "branchone") {
for (const b of v.branches ?? []) walkModules(b.modules ?? []);
walkModules(v.default ?? []);
} else if (v.type === "branchall") {
for (const b of v.branches ?? []) walkModules(b.modules ?? []);
}
}
};
if (flowValue?.preprocessor_module?.id) ids.push(flowValue.preprocessor_module.id);
if (flowValue?.failure_module?.id) ids.push(flowValue.failure_module.id);
walkModules(flowValue?.modules ?? []);
return ids;
}
export async function generateLocks(
opts: GlobalOptions & {
yes?: boolean;
dryRun?: boolean;
} & SyncOptions,
folder: string | undefined
) {
log.warn(
colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.')
);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
opts = await mergeConfigWithConfigFile(opts);
if (folder) {
// read script metadata file
await generateFlowLockInternal(
folder,
false,
workspace,
opts
);
} else {
const ignore = await ignoreF(opts);
const elems = Object.keys(
await elementsToMap(
await FSFSElement(process.cwd(), [], true),
(p, isD) => {
return (
ignore(p, isD) ||
(!isD &&
!p.endsWith(SEP + "flow.yaml") &&
!p.endsWith(SEP + "flow.json"))
);
},
false,
{}
)
).map((x) => x.substring(0, x.lastIndexOf(SEP)));
let hasAny = false;
for (const folder of elems) {
const candidate = await generateFlowLockInternal(
folder,
true,
workspace,
opts
);
if (candidate) {
hasAny = true;
log.info(colors.yellow.bold(`~ ${candidate}`));
}
}
if (hasAny) {
if (opts.dryRun) {
log.info(colors.gray("Dry run complete."));
return;
}
if (
!opts.yes &&
!(await Confirm.prompt({
message: "Update the locks of the inline scripts of the above flows?",
default: true,
}))
) {
return;
}
} else {
log.info(colors.green.bold("No locks to update"));
return;
}
for (const folder of elems) {
await generateFlowLockInternal(
folder,
false,
workspace,
opts
);
}
}
}
export async function bootstrap(
opts: GlobalOptions & { summary: string; description: string },
flowPath: string
) {
if (!validatePath(flowPath)) {
return;
}
await loadNonDottedPathsSetting();
const flowDirFullPath = buildFolderPath(flowPath, "flow");
mkdirSync(flowDirFullPath, { recursive: true });
const newFlowDefinition = defaultFlowDefinition();
if (opts.summary !== undefined) {
newFlowDefinition.summary = opts.summary;
}
if (opts.description !== undefined) {
newFlowDefinition.description = opts.description;
}
const newFlowDefinitionYaml = yamlStringify(
newFlowDefinition as Record<string, any>
);
const metadataFile = getMetadataFileName("flow", "yaml");
const flowYamlPath = `${flowDirFullPath}/${metadataFile}`;
writeFileSync(flowYamlPath, newFlowDefinitionYaml, { flag: "wx", encoding: "utf-8" });
log.info(colors.green(`Created flow at ${flowDirFullPath}`));
log.info("");
log.info(colors.bold("To preview this flow:"));
log.info(colors.gray(` wmill dev --path ${flowPath}`));
}
async function history(
opts: GlobalOptions & { json?: boolean },
flowPath: string
) {
if (opts.json) log.setSilent(true);
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const versions = await wmill.getFlowHistory({
workspace: workspace.workspaceId,
path: flowPath,
});
if (opts.json) {
console.log(JSON.stringify(versions));
} else {
if (versions.length === 0) {
log.info("No version history found for " + flowPath);
return;
}
new Table()
.header(["Version", "Created At", "Deployment Message"])
.padding(2)
.border(true)
.body(
versions.map((v) => [
String(v.id),
new Date(v.created_at).toISOString().replace("T", " ").substring(0, 19),
v.deployment_msg ?? "-",
])
)
.render();
}
}
async function showVersion(
opts: GlobalOptions & { json?: boolean },
flowPath: string,
version: string
) {
if (opts.json) log.setSilent(true);
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const flow = await wmill.getFlowVersion({
workspace: workspace.workspaceId,
path: flowPath,
version: parseInt(version, 10),
});
if (opts.json) {
console.log(JSON.stringify(flow));
} else {
console.log(colors.bold("Path:") + " " + flow.path);
console.log(colors.bold("Summary:") + " " + (flow.summary ?? "-"));
console.log(colors.bold("Description:") + " " + (flow.description ?? "-"));
console.log(colors.bold("Schema:"));
console.log(JSON.stringify(flow.schema, null, 2));
console.log(colors.bold("Value:"));
console.log(JSON.stringify(flow.value, null, 2));
}
}
const command = new Command()
.description("flow related commands")
.option("--show-archived", "Enable archived flows in output")
.option("--json", "Output as JSON (for piping to jq)")
.action(list as any)
.command("list", "list all flows")
.option("--show-archived", "Enable archived flows in output")
.option("--json", "Output as JSON (for piping to jq)")
.action(list as any)
.command("get", "get a flow's details")
.arguments("<path:string>")
.option("--json", "Output as JSON (for piping to jq)")
.action(get as any)
.command(
"push",
"push a local flow spec. This overrides any remote versions."
)
.arguments("<file_path:string> <remote_path:string>")
.option("--message <message:string>", "Deployment message")
.action(push as any)
.command("run", "run a flow by path.")
.arguments("<path:string>")
.option(
"-d --data <data:string>",
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path."
)
.option(
"-s --silent",
"Do not ouput anything other then the final output. Useful for scripting."
)
.option(
"--tag <tag:string>",
"Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag)."
)
.action(run as any)
.command(
"preview",
"preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step <id> to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow)."
)
.arguments("<flow_path:string>")
.option(
"-d --data <data:string>",
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path."
)
.option(
"-s --silent",
"Do not output anything other then the final output. Useful for scripting."
)
.option(
"--remote",
"Use deployed workspace scripts for PathScript steps instead of local files."
)
.option(
"--step <step_id:string>",
"Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does."
)
.option(
"--tag <tag:string>",
"Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag)."
)
.action(preview as any)
.command(
"generate-locks",
'DEPRECATED: re-generate flow lock files. Use "wmill generate-metadata" instead.'
)
// Deprecated compatibility command. Keep it working for older repos, but
// exclude it from generated system prompt docs.
// @deprecated use `wmill generate-metadata`
.arguments("[flow:file]")
.option("--yes", "Skip confirmation prompt")
.option("--dry-run", "Perform a dry run without making changes")
.option(
"-i --includes <patterns:file[]>",
"Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)"
)
.option(
"-e --excludes <patterns:file[]>",
"Comma separated patterns to specify which file to NOT take into account."
)
.action(generateLocks as any)
.command("new", "create a new empty flow")
.arguments("<flow_path:string>")
.option("--summary <summary:string>", "flow summary")
.option("--description <description:string>", "flow description")
.action(bootstrap as any)
.command("bootstrap", "create a new empty flow (alias for new)")
.arguments("<flow_path:string>")
.option("--summary <summary:string>", "flow summary")
.option("--description <description:string>", "flow description")
.action(bootstrap as any)
.command("history", "Show version history for a flow")
.arguments("<path:string>")
.option("--json", "Output as JSON (for piping to jq)")
.action(history as any)
.command("show-version", "Show a specific version of a flow")
.arguments("<path:string> <version:string>")
.option("--json", "Output as JSON (for piping to jq)")
.action(showVersion as any)
.command(
"set-permissioned-as",
"Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)"
)
.arguments("<path:string> <email:string>")
.action((async (opts: any, flowPath: string, email: string) => {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const remote = await wmill.getFlowByPath({
workspace: workspace.workspaceId,
path: flowPath,
});
if (!remote) throw new Error(`Flow ${flowPath} not found`);
await wmill.updateFlow({
workspace: workspace.workspaceId,
path: flowPath,
requestBody: {
...remote,
path: flowPath,
on_behalf_of_email: email,
// Derived server-side; see the script command for why.
on_behalf_of: undefined,
preserve_on_behalf_of: true,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
} as any,
});
log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`));
}) as any);
export default command;