fix(cli): flow preview hub steps and windmill paths, trigger templates (#12075)

* fix(cli): drop deprecated and unused fields from trigger new templates

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

* fix(cli): flow preview takes the windmill path and runs hub steps with --step

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

* fix(cli): run a deployed script step with its deployed lock in flow preview --step

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

* fix(cli): flow preview --remote honors nonDottedPaths when resolving the folder

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Petric
2026-10-28 19:22:50 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 20def22897
commit 65f3c5ee5c
8 changed files with 255 additions and 48 deletions
+57 -31
View File
@@ -9,7 +9,7 @@ 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 { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
import { buildFolderPath, getMetadataFileName, loadNonDottedPathsSetting } from "../../utils/resource_folders.ts";
import { requireLogin } from "../../core/auth.ts";
@@ -651,6 +651,10 @@ async function preview(
const cwdBeforeConfig = process.cwd();
if (useLocalPathScripts) {
opts = await mergeConfigWithConfigFile(opts);
} else {
// --remote skips the config read, but resolving the flow folder still
// needs the configured suffix (nonDottedPaths).
await loadNonDottedPathsSetting();
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
@@ -660,26 +664,7 @@ async function preview(
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;
}
flowPath = resolveFlowFolderArg(flowPath);
// 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:
@@ -852,20 +837,13 @@ async function previewStep(
// 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,
});
const script = await loadStepScript(workspace.workspaceId, moduleValue);
jobId = await wmill.runScriptPreview({
workspace: workspace.workspaceId,
requestBody: {
content: script.content,
language: script.language as any,
lock: script.lock,
// Anchor to the script's own deployed path so its relative imports
// resolve against the workspace tree (or temp_script_refs).
path: moduleValue.path,
@@ -916,6 +894,54 @@ async function previewStep(
}
}
/**
* Resolve a `flow preview` argument to its local flow folder, with a trailing
* separator. Accepts the folder itself, its flow.yaml, or the flow's Windmill
* path (`f/x/my_flow`), which other commands take without the folder suffix.
*/
export function resolveFlowFolderArg(flowPath: string): string {
const withSep = (p: string) => (p.endsWith(SEP) ? p : p + SEP);
const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP)
|| flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP);
if (isFlowDir) {
return withSep(flowPath);
}
if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) {
// dirname, so a bare "flow.yaml" becomes "." rather than "", which would
// turn into "/" and read from the filesystem root.
return withSep(dirname(flowPath));
}
const bare = flowPath.endsWith(SEP) ? flowPath.slice(0, -SEP.length) : flowPath;
// The configured suffix first, then the other one: a workspace can hold both.
const candidates = [...new Set([buildFolderPath(bare, "flow"), bare + ".flow", bare + "__flow"])];
const found = candidates.find((c) => existsSync(c) && statSync(c).isDirectory());
if (found) {
return withSep(found);
}
throw new Error(
`Flow path must be a .flow/__flow directory, a flow.yaml file, or the Windmill path of a local flow; found none of ${candidates.join(", ")}`
);
}
/**
* Fetch the code and lockfile a `script` step runs, the way a whole-flow run
* resolves them: hub paths (`hub/<id>/...`) from the hub, others from the
* workspace.
*/
export async function loadStepScript(
workspaceId: string,
moduleValue: { path: string; hash?: string },
): Promise<{ content: string; language: string; lock?: string }> {
if (moduleValue.path.startsWith("hub/")) {
const hub = await wmill.getHubScriptByPath({ path: moduleValue.path });
return { content: hub.content, language: hub.language, lock: hub.lockfile };
}
const script = moduleValue.hash
? await wmill.getScriptByHash({ workspace: workspaceId, hash: moduleValue.hash })
: await wmill.getScriptByPath({ workspace: workspaceId, path: moduleValue.path });
return { content: script.content, language: script.language, lock: script.lock };
}
// 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
@@ -1204,7 +1230,7 @@ const command = new Command()
.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)."
"preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. 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(
+13 -13
View File
@@ -338,14 +338,14 @@ export async function pushNativeTrigger(
}
}
const triggerTemplates: Record<TriggerType, Record<string, any>> = {
// Scaffold only fields the backend reads: `mode` supersedes the deprecated `enabled`, and
// `request_type` the deprecated `is_async`.
export const triggerTemplates: Record<TriggerType, Record<string, any>> = {
http: {
script_path: "",
is_flow: false,
route_path: "",
http_method: "get",
is_async: false,
requires_auth: true,
request_type: "sync",
authentication_method: "none",
is_static_website: false,
@@ -360,7 +360,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
filters: [],
can_return_message: false,
can_return_error_result: false,
enabled: false,
mode: "disabled",
},
kafka: {
script_path: "",
@@ -369,7 +369,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
group_id: "",
topics: [],
filters: [],
enabled: false,
mode: "disabled",
},
nats: {
script_path: "",
@@ -377,7 +377,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
nats_resource_path: "",
subjects: [],
use_jetstream: false,
enabled: false,
mode: "disabled",
},
postgres: {
script_path: "",
@@ -385,21 +385,21 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
postgres_resource_path: "",
publication_name: "",
replication_slot_name: "",
enabled: false,
mode: "disabled",
},
mqtt: {
script_path: "",
is_flow: false,
mqtt_resource_path: "",
subscribe_topics: [],
enabled: false,
mode: "disabled",
},
amqp: {
script_path: "",
is_flow: false,
amqp_resource_path: "",
queue_name: "",
enabled: false,
mode: "disabled",
},
sqs: {
script_path: "",
@@ -407,7 +407,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
queue_url: "",
aws_resource_path: "",
aws_auth_resource_type: "credentials",
enabled: false,
mode: "disabled",
},
gcp: {
script_path: "",
@@ -417,7 +417,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
subscription_id: "",
delivery_type: "pull",
subscription_mode: "create_update",
enabled: false,
mode: "disabled",
},
azure: {
script_path: "",
@@ -426,13 +426,13 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
azure_mode: "namespace_pull",
scope_resource_id: "",
subscription_name: "",
enabled: false,
mode: "disabled",
},
email: {
script_path: "",
is_flow: false,
local_part: "",
enabled: false,
mode: "disabled",
},
};
+1 -1
View File
@@ -8718,7 +8718,7 @@ flow related commands
- \`-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.
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
- \`--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).
- \`flow preview <flow_path:string>\` - 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).
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. 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).
- \`-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.
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
+137
View File
@@ -0,0 +1,137 @@
/**
* `wmill flow preview` argument resolution and `--step` script loading.
*/
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { mockServices } from "./mock_services.ts";
const calls: { fn: string; args: any }[] = [];
mockServices({
getHubScriptByPath: async (args: any) => {
calls.push({ fn: "getHubScriptByPath", args });
return {
content: "export async function main() { return 'hub' }",
language: "deno",
lockfile: "hub-lock",
};
},
getScriptByPath: async (args: any) => {
calls.push({ fn: "getScriptByPath", args });
return { content: "workspace code", language: "bun", lock: "ws-lock" };
},
getScriptByHash: async (args: any) => {
calls.push({ fn: "getScriptByHash", args });
return { content: "hashed code", language: "python3", lock: "hash-lock" };
},
});
const { resolveFlowFolderArg, loadStepScript } = await import(
"../src/commands/flow/flow.ts"
);
const { setNonDottedPaths } = await import("../src/utils/resource_folders.ts");
const SEP = path.sep;
describe("resolveFlowFolderArg", () => {
let root: string;
let previousCwd: string;
beforeEach(() => {
previousCwd = process.cwd();
root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "wmill_flow_preview_")),
);
fs.mkdirSync(path.join(root, "f", "t", "dotted.flow"), { recursive: true });
fs.mkdirSync(path.join(root, "f", "t", "nondotted__flow"), { recursive: true });
process.chdir(root);
});
afterEach(() => {
process.chdir(previousCwd);
fs.rmSync(root, { recursive: true, force: true });
});
test("a Windmill path resolves to its local .flow folder", () => {
expect(resolveFlowFolderArg(`f${SEP}t${SEP}dotted`)).toBe(
`f${SEP}t${SEP}dotted.flow${SEP}`,
);
});
test("a Windmill path resolves to a __flow folder when that is the one on disk", () => {
expect(resolveFlowFolderArg(`f${SEP}t${SEP}nondotted`)).toBe(
`f${SEP}t${SEP}nondotted__flow${SEP}`,
);
});
test("when both folders exist, the configured suffix wins", () => {
fs.mkdirSync(path.join(root, "f", "t", "both.flow"), { recursive: true });
fs.mkdirSync(path.join(root, "f", "t", "both__flow"), { recursive: true });
try {
setNonDottedPaths(true);
expect(resolveFlowFolderArg(`f${SEP}t${SEP}both`)).toBe(
`f${SEP}t${SEP}both__flow${SEP}`,
);
setNonDottedPaths(false);
expect(resolveFlowFolderArg(`f${SEP}t${SEP}both`)).toBe(
`f${SEP}t${SEP}both.flow${SEP}`,
);
} finally {
setNonDottedPaths(false);
}
});
test("the folder and its flow.yaml keep resolving to the folder", () => {
const folder = `f${SEP}t${SEP}dotted.flow${SEP}`;
expect(resolveFlowFolderArg(`f${SEP}t${SEP}dotted.flow`)).toBe(folder);
expect(resolveFlowFolderArg(folder)).toBe(folder);
expect(resolveFlowFolderArg(`${folder}flow.yaml`)).toBe(folder);
expect(resolveFlowFolderArg("flow.yaml")).toBe(`.${SEP}`);
});
test("a Windmill path with no local flow folder is refused, naming what was looked for", () => {
expect(() => resolveFlowFolderArg(`f${SEP}t${SEP}missing`)).toThrow(
`found none of f${SEP}t${SEP}missing.flow, f${SEP}t${SEP}missing__flow`,
);
});
});
describe("loadStepScript", () => {
beforeEach(() => {
calls.length = 0;
});
test("a hub path is fetched from the hub, with the hub's lockfile", async () => {
const script = await loadStepScript("ws", {
path: "hub/29624/slack/send_message_to_channel",
});
expect(calls).toEqual([
{
fn: "getHubScriptByPath",
args: { path: "hub/29624/slack/send_message_to_channel" },
},
]);
expect(script).toEqual({
content: "export async function main() { return 'hub' }",
language: "deno",
lock: "hub-lock",
});
});
test("a workspace path is fetched from the workspace, with its deployed lock", async () => {
const script = await loadStepScript("ws", { path: "f/t/helper" });
expect(calls).toEqual([
{ fn: "getScriptByPath", args: { workspace: "ws", path: "f/t/helper" } },
]);
expect(script).toEqual({ content: "workspace code", language: "bun", lock: "ws-lock" });
});
test("a pinned hash is fetched by hash", async () => {
const script = await loadStepScript("ws", { path: "f/t/helper", hash: "abc" });
expect(calls).toEqual([
{ fn: "getScriptByHash", args: { workspace: "ws", hash: "abc" } },
]);
expect(script).toEqual({ content: "hashed code", language: "python3", lock: "hash-lock" });
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* `wmill trigger new` scaffolds only fields the backend reads: `request_type`
* rather than the deprecated `is_async`, no `requires_auth` (its column was
* renamed to `authentication_method`), and `mode` rather than the deprecated
* `enabled`.
*/
import { expect, test, describe } from "bun:test";
import { triggerTemplates } from "../src/commands/trigger/trigger.ts";
describe("trigger new templates", () => {
test("the http template has no is_async or requires_auth", () => {
const http = triggerTemplates.http;
expect(Object.keys(http)).not.toContain("is_async");
expect(Object.keys(http)).not.toContain("requires_auth");
expect(http.request_type).toBe("sync");
expect(http.authentication_method).toBe("none");
});
test("no template scaffolds the deprecated enabled field", () => {
const withEnabled = Object.entries(triggerTemplates)
.filter(([, template]) => "enabled" in template)
.map(([kind]) => kind);
expect(withEnabled).toEqual([]);
});
test("templates that scaffolded a disabled trigger still do, through mode", () => {
const modes = Object.fromEntries(
Object.entries(triggerTemplates).map(([kind, t]) => [kind, t.mode]),
);
expect(modes).toEqual({
http: undefined,
websocket: "disabled",
kafka: "disabled",
nats: "disabled",
postgres: "disabled",
mqtt: "disabled",
amqp: "disabled",
sqs: "disabled",
gcp: "disabled",
azure: "disabled",
email: "disabled",
});
});
});
@@ -177,7 +177,7 @@ flow related commands
- `-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.
- `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.
- `--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).
- `flow preview <flow_path:string>` - 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).
- `flow preview <flow_path:string>` - preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. 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).
- `-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.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.
+1 -1
View File
@@ -4048,7 +4048,7 @@ flow related commands
- \`-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.
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
- \`--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).
- \`flow preview <flow_path:string>\` - 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).
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. 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).
- \`-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.
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
@@ -182,7 +182,7 @@ flow related commands
- `-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.
- `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.
- `--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).
- `flow preview <flow_path:string>` - 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).
- `flow preview <flow_path:string>` - preview a local flow without deploying it. <flow_path> is the flow's Windmill path (f/x/my_flow), its .flow/__flow folder or its flow.yaml. 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).
- `-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.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.