From 8fa87d24252556271ff0726cdc8418a813337788 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 23 Jul 2026 15:17:59 -0700 Subject: [PATCH 1/2] fix(workflow): emit an envelope for set-slot --stdout and vary under --json (BE-4215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `comfy workflow set-slot --stdout` wrote the modified workflow bare to stdout and returned WITHOUT calling `renderer.emit(...)`, so under `--json` it produced no `envelope/1` at all. Every machine caller — including the local Comfy MCP's `set_workflow_slot`, whose default is `stdout=True` — failed the whole parameterize step with "comfy-cli returned no JSON". stdout belongs to the envelope in JSON mode (docs/json-output.md), so: - set-slot --stdout: human mode still prints the raw workflow (pipeable); JSON mode emits an envelope whose `data.workflow_json` carries the modified workflow, with `wrote: null` / `out: "stdout"` and `changed: false`. - vary without --out-dir: same split — raw NDJSON in human mode, variants in `data.variants` under --json (previously the variants were dumped onto stdout ahead of the envelope and never appeared in `data` at all). - schemas/workflow.json documents the new keys and widens `wrote` to allow null. --- comfy_cli/command/workflow.py | 42 +++++-- comfy_cli/schemas/workflow.json | 7 +- .../comfy_cli/command/test_workflow_slots.py | 105 ++++++++++++++++-- 3 files changed, 133 insertions(+), 21 deletions(-) diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index 383651d7..814aed93 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -237,7 +237,8 @@ def set_slot_cmd( typer.Option( "--stdout/--in-place", show_default=False, - help="Print the result to stdout instead of writing back to .", + help="Return the result instead of writing back to : `data.workflow_json` in the " + "envelope under --json, or the raw workflow on stdout in human mode (--no-json).", ), ] = False, input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, @@ -268,21 +269,30 @@ def set_slot_cmd( serialized = json.dumps(new_workflow, indent=2) - if stdout: + # `--stdout` in human mode is a pipe target: print the raw workflow so + # `comfy workflow set-slot ... --stdout > new.json` keeps working. In JSON + # mode stdout is reserved for the envelope (see docs/json-output.md), so + # there the modified workflow rides in `data.workflow_json` instead — a + # bare workflow object is not an `envelope/1` and machine callers reject it. + if stdout and renderer.is_pretty(): import sys sys.stdout.write(serialized) sys.stdout.write("\n") return - _atomic_write_text(p, serialized) + if not stdout: + _atomic_write_text(p, serialized) - payload = { + payload: dict[str, Any] = { "workflow": str(p), "applied": list(overrides_dict.keys()), "warnings": warnings, - "wrote": str(p), + "wrote": None if stdout else str(p), } + if stdout: + payload["out"] = "stdout" + payload["workflow_json"] = new_workflow if _stale: payload["stale"] = True payload["warnings"] = list(warnings) + [ @@ -294,7 +304,7 @@ def set_slot_cmd( rprint(f" [dim]·[/dim] {addr}") for w in warnings: rprint(f" [yellow]warning:[/yellow] {w}") - renderer.emit(payload, command="workflow set-slot", changed=True) + renderer.emit(payload, command="workflow set-slot", changed=not stdout) # --------------------------------------------------------------------------- @@ -302,7 +312,10 @@ def set_slot_cmd( # --------------------------------------------------------------------------- -@app.command("vary", help="Produce N workflow variants from a per-slot value list. Emits NDJSON.") +@app.command( + "vary", + help="Produce N workflow variants from a per-slot value list. Emits NDJSON (or `data.variants` under --json).", +) @tracking.track_command("workflow") def vary_cmd( file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], @@ -321,7 +334,9 @@ def vary_cmd( typer.Option( "--out-dir", show_default=False, - help="If set, write each variation to /_.json. Otherwise emit NDJSON to stdout.", + help="If set, write each variation to /_.json. Otherwise return the " + "variants: `data.variants` in the envelope under --json, or NDJSON on stdout in " + "human mode (--no-json).", ), ] = None, ): @@ -367,6 +382,10 @@ def vary_cmd( raise typer.Exit(code=1) from e written: list[str] = [] + # Same envelope contract as set-slot --stdout: raw NDJSON on stdout is the + # human/pipe form; in JSON mode stdout belongs to the envelope, so the + # variants ride in `data.variants` instead. + variants: list[dict[str, Any]] | None = None if out_dir: out = Path(out_dir).expanduser() out.mkdir(parents=True, exist_ok=True) @@ -374,20 +393,23 @@ def vary_cmd( target = out / f"{p.stem}_{i:03d}.json" _atomic_write_text(target, json.dumps(wf, indent=2)) written.append(str(target)) - else: + elif renderer.is_pretty(): import sys for wf in workflows: sys.stdout.write(json.dumps(wf)) sys.stdout.write("\n") sys.stdout.flush() + else: + variants = list(workflows) - payload = { + payload: dict[str, Any] = { "workflow": str(p), "count": len(workflows), "warnings": warnings, "out_dir": str(Path(out_dir).expanduser()) if out_dir else None, "written": written, + "variants": variants, } if _stale: payload["stale"] = True diff --git a/comfy_cli/schemas/workflow.json b/comfy_cli/schemas/workflow.json index ccddd541..d60cda91 100644 --- a/comfy_cli/schemas/workflow.json +++ b/comfy_cli/schemas/workflow.json @@ -9,7 +9,12 @@ "slots": { "type": "array" }, "applied": { "type": "array" }, "warnings": { "type": "array" }, - "wrote": { "type": "string" }, + "wrote": { "type": ["string", "null"], "description": "file written, or null when nothing was written (set-slot --stdout)" }, + "out": { "type": "string", "description": "set-slot: where the result went — \"stdout\" when --stdout returned it instead of writing the file" }, + "workflow_json": { "type": "object", "description": "set-slot --stdout: the modified workflow itself (human mode prints it raw on stdout instead)" }, + "variants": { "type": ["array", "null"], "description": "vary without --out-dir: the produced workflows (human mode prints them as NDJSON on stdout instead); null when they were written to --out-dir" }, + "written": { "type": "array" }, + "out_dir": { "type": ["string", "null"] }, "variations_summary": { "type": "array" }, "item_map": { "type": "object", diff --git a/tests/comfy_cli/command/test_workflow_slots.py b/tests/comfy_cli/command/test_workflow_slots.py index 56e7566a..9701913d 100644 --- a/tests/comfy_cli/command/test_workflow_slots.py +++ b/tests/comfy_cli/command/test_workflow_slots.py @@ -42,6 +42,18 @@ def _force_json_renderer(): return r +def _force_pretty_renderer(): + r = Renderer.resolve( + is_stdout_tty=True, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + no_json_flag=True, + ) + r.mode = OutputMode.PRETTY + set_renderer(r) + return r + + def _object_info(): return { "KSampler": { @@ -266,13 +278,19 @@ def _template_workflow(): } -def _run(args: list[str], capsys) -> dict[str, Any]: - _force_json_renderer() +def _invoke(args: list[str], capsys, renderer_factory=_force_json_renderer) -> tuple[str, Any]: + """Run the command and return (stdout text, CliRunner result).""" + renderer_factory() runner = CliRunner() result = runner.invoke(workflow_cmd.app, args, standalone_mode=False) captured = capsys.readouterr().out if not captured.strip(): captured = result.stdout or "" + return captured, result + + +def _run(args: list[str], capsys) -> dict[str, Any]: + captured, result = _invoke(args, capsys) lines = [ln for ln in captured.strip().splitlines() if ln.strip()] for line in reversed(lines): try: @@ -345,17 +363,53 @@ def test_set_slot_stdout_mode(self, patched_graph, tmp_path, capsys): wf = _direct_workflow() path = _write_workflow(tmp_path, wf) original_text = path.read_text() - # --stdout prints to stdout instead of modifying file - _force_json_renderer() - runner = CliRunner() - runner.invoke( - workflow_cmd.app, - ["set-slot", str(path), '6.text="a dog"', "--stdout"], - standalone_mode=False, - ) + # --stdout returns the result instead of modifying the file + _invoke(["set-slot", str(path), '6.text="a dog"', "--stdout"], capsys) # File should be unchanged assert path.read_text() == original_text + def test_set_slot_stdout_emits_envelope_under_json(self, patched_graph, tmp_path, capsys): + """`--json ... --stdout` must emit an envelope/1 carrying the modified workflow. + + Regression: it used to write the bare workflow object to stdout and skip + `renderer.emit`, so every machine caller (the local MCP's default + `set_workflow_slot`) saw "no JSON" and failed. + """ + path = _write_workflow(tmp_path, _direct_workflow()) + original_text = path.read_text() + captured, _ = _invoke(["set-slot", str(path), '6.text="a dog"', "--stdout"], capsys) + + lines = [ln for ln in captured.strip().splitlines() if ln.strip()] + assert len(lines) == 1, f"JSON mode must put exactly one envelope on stdout, got {lines[:3]}" + env = json.loads(lines[0]) + assert env["schema"] == "envelope/1" + assert env["type"] == "envelope" + assert env["ok"] is True + assert env["changed"] is False, "--stdout writes nothing, so the envelope must not claim a change" + + data = env["data"] + assert data["out"] == "stdout" + assert data["wrote"] is None + assert data["applied"] == ["6.text"] + # data round-trips the applied override… + clip = next(n for n in data["workflow_json"]["nodes"] if n["id"] == 6) + assert clip["widgets_values"][0] == "a dog" + # …and the source file is untouched. + assert path.read_text() == original_text + + def test_set_slot_stdout_prints_raw_workflow_in_human_mode(self, patched_graph, tmp_path, capsys): + """Without --json, --stdout still prints the bare workflow so it stays pipeable.""" + path = _write_workflow(tmp_path, _direct_workflow()) + captured, _ = _invoke( + ["set-slot", str(path), '6.text="a dog"', "--stdout"], + capsys, + _force_pretty_renderer, + ) + wf = json.loads(captured) + assert "nodes" in wf and "type" not in wf, "human mode must print the workflow, not an envelope" + clip = next(n for n in wf["nodes"] if n["id"] == 6) + assert clip["widgets_values"][0] == "a dog" + def test_set_slot_invalid_format(self, patched_graph, tmp_path, capsys): path = _write_workflow(tmp_path, _direct_workflow()) env = _run(["set-slot", str(path), "bad_no_equals"], capsys) @@ -391,9 +445,40 @@ def test_vary_produces_files(self, patched_graph, tmp_path, capsys): ) assert env["ok"] is True assert env["data"]["count"] == 3 + # Variants went to disk, so they are not inlined in the envelope. + assert env["data"]["variants"] is None files = sorted(out_dir.glob("*.json")) assert len(files) == 3 + def test_vary_without_out_dir_puts_variants_in_envelope(self, patched_graph, tmp_path, capsys): + """Same envelope contract as set-slot --stdout: under --json the variants + ride in `data.variants` instead of being dumped raw onto stdout.""" + path = _write_workflow(tmp_path, _direct_workflow()) + captured, _ = _invoke(["vary", str(path), "--slot", "3.seed=[1,2]"], capsys) + + lines = [ln for ln in captured.strip().splitlines() if ln.strip()] + assert len(lines) == 1, f"JSON mode must put exactly one envelope on stdout, got {lines[:3]}" + env = json.loads(lines[0]) + assert env["ok"] is True + assert env["data"]["count"] == 2 + variants = env["data"]["variants"] + assert len(variants) == 2 + seeds = [next(n for n in wf["nodes"] if n["id"] == 3)["widgets_values"][0] for wf in variants] + assert seeds == [1, 2] + + def test_vary_without_out_dir_still_ndjson_in_human_mode(self, patched_graph, tmp_path, capsys): + """Without --json, the variants stay raw NDJSON on stdout for piping.""" + path = _write_workflow(tmp_path, _direct_workflow()) + captured, _ = _invoke( + ["vary", str(path), "--slot", "3.seed=[1,2]"], + capsys, + _force_pretty_renderer, + ) + wfs = [json.loads(ln) for ln in captured.splitlines() if ln.startswith("{")] + assert len(wfs) == 2 + seeds = [next(n for n in wf["nodes"] if n["id"] == 3)["widgets_values"][0] for wf in wfs] + assert seeds == [1, 2] + def test_vary_mismatched_lengths_rejected(self, patched_graph, tmp_path, capsys): path = _write_workflow(tmp_path, _direct_workflow()) env = _run( From dde965650e925c4ca74032e080f0ad058b4070de Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 23 Jul 2026 15:39:37 -0700 Subject: [PATCH 2/2] fix(workflow): keep vary's NDJSON stdout strict; correct --stdout docs (BE-4215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor review follow-ups on the BE-4215 envelope split: - vary (High): in human mode without --out-dir the NDJSON loop fell through to the pretty summary, so `comfy workflow vary ... --no-json > out.ndjson` ended with a non-JSON `✓ produced N variation(s)` line and broke strict line-delimited consumers. The summary/warnings now go to stderr whenever stdout carries the stream. - set-slot/vary help + comment (Medium): a redirect makes stdout a non-TTY, which Renderer.resolve reads as JSON mode, so `--stdout > new.json` yields an envelope, not a raw workflow. Both now say --no-json is required for the raw form. - set-slot (Nit): the workflow was serialized unconditionally but unused on the --stdout + JSON path; it is now serialized only where it is consumed. - Both commands fold the object_info stale-cache note into `warnings` up front so the --stdout early return reports it (on stderr) instead of dropping it. - The human-mode vary test asserted only on lines starting with `{`, which hid the trailing summary; it now requires every stdout line to parse as JSON and checks the summary lands on stderr. Co-Authored-By: Claude Opus 4.8 --- comfy_cli/command/workflow.py | 58 ++++++++++++------- .../comfy_cli/command/test_workflow_slots.py | 42 +++++++++----- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index 814aed93..68eed156 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -238,7 +238,8 @@ def set_slot_cmd( "--stdout/--in-place", show_default=False, help="Return the result instead of writing back to : `data.workflow_json` in the " - "envelope under --json, or the raw workflow on stdout in human mode (--no-json).", + "envelope under --json, or the raw workflow on stdout with --no-json. Redirecting " + "stdout selects JSON mode, so `--stdout > new.json` needs --no-json to get a raw workflow.", ), ] = False, input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, @@ -267,22 +268,34 @@ def set_slot_cmd( ) raise typer.Exit(code=1) from e - serialized = json.dumps(new_workflow, indent=2) + # Fold the stale-cache note into `warnings` up front so every exit path — + # including the `--stdout` early return below — reports it. + if _stale: + warnings = list(warnings) + [ + {"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"} + ] # `--stdout` in human mode is a pipe target: print the raw workflow so - # `comfy workflow set-slot ... --stdout > new.json` keeps working. In JSON - # mode stdout is reserved for the envelope (see docs/json-output.md), so - # there the modified workflow rides in `data.workflow_json` instead — a - # bare workflow object is not an `envelope/1` and machine callers reject it. + # `comfy workflow set-slot ... --stdout --no-json > new.json` keeps working. + # `--no-json` is required there: a redirect makes stdout a non-TTY, which + # `Renderer.resolve` reads as JSON mode. In JSON mode stdout is reserved + # for the envelope (see docs/json-output.md), so the modified workflow + # rides in `data.workflow_json` instead — a bare workflow object is not an + # `envelope/1` and machine callers reject it. if stdout and renderer.is_pretty(): import sys - sys.stdout.write(serialized) + sys.stdout.write(json.dumps(new_workflow, indent=2)) sys.stdout.write("\n") + sys.stdout.flush() + # stdout now holds exactly the workflow; warnings would corrupt it, so + # they go to stderr rather than being dropped. + for w in warnings: + renderer.stderr_console().print(f"[yellow]warning:[/yellow] {w}") return if not stdout: - _atomic_write_text(p, serialized) + _atomic_write_text(p, json.dumps(new_workflow, indent=2)) payload: dict[str, Any] = { "workflow": str(p), @@ -295,9 +308,6 @@ def set_slot_cmd( payload["workflow_json"] = new_workflow if _stale: payload["stale"] = True - payload["warnings"] = list(warnings) + [ - {"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"} - ] if renderer.is_pretty(): rprint(f"[bold green]✓[/bold green] applied {len(overrides_dict)} slot(s) → [dim]{p}[/dim]") for addr in overrides_dict: @@ -335,8 +345,8 @@ def vary_cmd( "--out-dir", show_default=False, help="If set, write each variation to /_.json. Otherwise return the " - "variants: `data.variants` in the envelope under --json, or NDJSON on stdout in " - "human mode (--no-json).", + "variants: `data.variants` in the envelope under --json, or NDJSON on stdout with " + "--no-json. Redirecting stdout selects JSON mode, so `> out.ndjson` needs --no-json.", ), ] = None, ): @@ -381,11 +391,20 @@ def vary_cmd( renderer.error(code="workflow_slot_invalid", message=str(e)) raise typer.Exit(code=1) from e + if _stale: + warnings = list(warnings) + [ + {"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"} + ] + written: list[str] = [] # Same envelope contract as set-slot --stdout: raw NDJSON on stdout is the # human/pipe form; in JSON mode stdout belongs to the envelope, so the # variants ride in `data.variants` instead. variants: list[dict[str, Any]] | None = None + # True once stdout carries the NDJSON stream — the human summary below must + # then go to stderr, or `comfy workflow vary ... --no-json > out.ndjson` + # ends with a non-JSON line and breaks strict line-delimited consumers. + piped_ndjson = False if out_dir: out = Path(out_dir).expanduser() out.mkdir(parents=True, exist_ok=True) @@ -400,6 +419,7 @@ def vary_cmd( sys.stdout.write(json.dumps(wf)) sys.stdout.write("\n") sys.stdout.flush() + piped_ndjson = True else: variants = list(workflows) @@ -413,18 +433,16 @@ def vary_cmd( } if _stale: payload["stale"] = True - payload["warnings"] = list(warnings) + [ - {"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"} - ] if renderer.is_pretty(): - rprint(f"[bold green]✓[/bold green] produced {len(workflows)} variation(s)") + say = renderer.stderr_console().print if piped_ndjson else rprint + say(f"[bold green]✓[/bold green] produced {len(workflows)} variation(s)") if written: for path in written[:5]: - rprint(f" [dim]→[/dim] {path}") + say(f" [dim]→[/dim] {path}") if len(written) > 5: - rprint(f" [dim]… and {len(written) - 5} more[/dim]") + say(f" [dim]… and {len(written) - 5} more[/dim]") for w in warnings: - rprint(f" [yellow]warning:[/yellow] {w}") + say(f" [yellow]warning:[/yellow] {w}") renderer.emit(payload, command="workflow vary", changed=bool(written)) diff --git a/tests/comfy_cli/command/test_workflow_slots.py b/tests/comfy_cli/command/test_workflow_slots.py index 9701913d..fa1aa49e 100644 --- a/tests/comfy_cli/command/test_workflow_slots.py +++ b/tests/comfy_cli/command/test_workflow_slots.py @@ -278,19 +278,20 @@ def _template_workflow(): } -def _invoke(args: list[str], capsys, renderer_factory=_force_json_renderer) -> tuple[str, Any]: - """Run the command and return (stdout text, CliRunner result).""" +def _invoke(args: list[str], capsys, renderer_factory=_force_json_renderer) -> tuple[str, str, Any]: + """Run the command and return (stdout text, stderr text, CliRunner result).""" renderer_factory() runner = CliRunner() result = runner.invoke(workflow_cmd.app, args, standalone_mode=False) - captured = capsys.readouterr().out + streams = capsys.readouterr() + captured = streams.out if not captured.strip(): captured = result.stdout or "" - return captured, result + return captured, streams.err, result def _run(args: list[str], capsys) -> dict[str, Any]: - captured, result = _invoke(args, capsys) + captured, _err, result = _invoke(args, capsys) lines = [ln for ln in captured.strip().splitlines() if ln.strip()] for line in reversed(lines): try: @@ -377,7 +378,7 @@ def test_set_slot_stdout_emits_envelope_under_json(self, patched_graph, tmp_path """ path = _write_workflow(tmp_path, _direct_workflow()) original_text = path.read_text() - captured, _ = _invoke(["set-slot", str(path), '6.text="a dog"', "--stdout"], capsys) + captured, _stderr, _ = _invoke(["set-slot", str(path), '6.text="a dog"', "--stdout"], capsys) lines = [ln for ln in captured.strip().splitlines() if ln.strip()] assert len(lines) == 1, f"JSON mode must put exactly one envelope on stdout, got {lines[:3]}" @@ -400,7 +401,7 @@ def test_set_slot_stdout_emits_envelope_under_json(self, patched_graph, tmp_path def test_set_slot_stdout_prints_raw_workflow_in_human_mode(self, patched_graph, tmp_path, capsys): """Without --json, --stdout still prints the bare workflow so it stays pipeable.""" path = _write_workflow(tmp_path, _direct_workflow()) - captured, _ = _invoke( + captured, _stderr, _ = _invoke( ["set-slot", str(path), '6.text="a dog"', "--stdout"], capsys, _force_pretty_renderer, @@ -454,7 +455,7 @@ def test_vary_without_out_dir_puts_variants_in_envelope(self, patched_graph, tmp """Same envelope contract as set-slot --stdout: under --json the variants ride in `data.variants` instead of being dumped raw onto stdout.""" path = _write_workflow(tmp_path, _direct_workflow()) - captured, _ = _invoke(["vary", str(path), "--slot", "3.seed=[1,2]"], capsys) + captured, _stderr, _ = _invoke(["vary", str(path), "--slot", "3.seed=[1,2]"], capsys) lines = [ln for ln in captured.strip().splitlines() if ln.strip()] assert len(lines) == 1, f"JSON mode must put exactly one envelope on stdout, got {lines[:3]}" @@ -467,17 +468,28 @@ def test_vary_without_out_dir_puts_variants_in_envelope(self, patched_graph, tmp assert seeds == [1, 2] def test_vary_without_out_dir_still_ndjson_in_human_mode(self, patched_graph, tmp_path, capsys): - """Without --json, the variants stay raw NDJSON on stdout for piping.""" + """Without --json, the variants stay raw NDJSON on stdout for piping. + + stdout must be *strictly* line-delimited JSON: the `✓ produced N + variation(s)` summary used to be appended to it, which broke + `comfy workflow vary ... --no-json > out.ndjson` for strict consumers. + It belongs on stderr. + """ path = _write_workflow(tmp_path, _direct_workflow()) - captured, _ = _invoke( - ["vary", str(path), "--slot", "3.seed=[1,2]"], - capsys, - _force_pretty_renderer, - ) - wfs = [json.loads(ln) for ln in captured.splitlines() if ln.startswith("{")] + _force_pretty_renderer() + # Called directly rather than through CliRunner: CliRunner merges + # stderr into stdout, and the stdout/stderr split is what's under test. + workflow_cmd.vary_cmd(str(path), ["3.seed=[1,2]"]) + streams = capsys.readouterr() + captured, stderr = streams.out, streams.err + lines = [ln for ln in captured.splitlines() if ln.strip()] + # Every non-blank stdout line parses as JSON — no summary/footer. + wfs = [json.loads(ln) for ln in lines] assert len(wfs) == 2 seeds = [next(n for n in wf["nodes"] if n["id"] == 3)["widgets_values"][0] for wf in wfs] assert seeds == [1, 2] + # The human summary is still shown, just not on the NDJSON stream. + assert "produced 2 variation(s)" in stderr def test_vary_mismatched_lengths_rejected(self, patched_graph, tmp_path, capsys): path = _write_workflow(tmp_path, _direct_workflow())