rote-cli 0.12.1__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {rote_cli-0.12.1 → rote_cli-0.13.0}/PKG-INFO +4 -7
  2. {rote_cli-0.12.1 → rote_cli-0.13.0}/README.md +3 -6
  3. {rote_cli-0.12.1 → rote_cli-0.13.0}/pyproject.toml +5 -1
  4. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/__init__.py +1 -1
  5. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/_common.py +76 -1
  6. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/_py_common.py +56 -42
  7. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/_ts_common.py +62 -0
  8. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/cloudflare.py +99 -6
  9. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/dbos.py +5 -1
  10. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/dbos_ts.py +46 -0
  11. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/inngest.py +72 -7
  12. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/python.py +48 -6
  13. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/temporal.py +87 -47
  14. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/cli.py +6 -0
  15. {rote_cli-0.12.1 → rote_cli-0.13.0}/.gitignore +0 -0
  16. {rote_cli-0.12.1 → rote_cli-0.13.0}/LICENSE +0 -0
  17. {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/SKILL.md +0 -0
  18. {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/crystallization-heuristics.md +0 -0
  19. {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/eval-estimates.md +0 -0
  20. {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/implementation.md +0 -0
  21. {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/ir-schema.md +0 -0
  22. {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/llm-judge-extraction.md +0 -0
  23. {rote_cli-0.12.1 → rote_cli-0.13.0}/skills/rote-compile/references/node-kinds.md +0 -0
  24. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/__main__.py +0 -0
  25. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/_dbos.py +0 -0
  26. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/adapters/__init__.py +0 -0
  27. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/app_registry.py +0 -0
  28. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/cloud_auth.py +0 -0
  29. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/cloud_compile.py +0 -0
  30. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/__init__.py +0 -0
  31. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/__init__.py +0 -0
  32. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/_fs_tools.py +0 -0
  33. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/anthropic_api.py +0 -0
  34. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/claude.py +0 -0
  35. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/codex.py +0 -0
  36. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/drivers/openai_api.py +0 -0
  37. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/events.py +0 -0
  38. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/compiler/update.py +0 -0
  39. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/config.py +0 -0
  40. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/contracts.py +0 -0
  41. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/deploy.py +0 -0
  42. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/deploy_rote_cloud.py +0 -0
  43. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/__init__.py +0 -0
  44. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/baseline.py +0 -0
  45. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/empirical.py +0 -0
  46. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/estimate.py +0 -0
  47. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/pricing.py +0 -0
  48. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/priors.py +0 -0
  49. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/scorecard.py +0 -0
  50. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/sidecar.py +0 -0
  51. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/eval/tokens.py +0 -0
  52. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/inference/__init__.py +0 -0
  53. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/inference/_runtime_helper.py +0 -0
  54. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/init_wizard.py +0 -0
  55. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/ir.py +0 -0
  56. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/__init__.py +0 -0
  57. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/_runtime_helper.py +0 -0
  58. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/auth.py +0 -0
  59. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/registry.py +0 -0
  60. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/release.py +0 -0
  61. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/mcp/tokens.py +0 -0
  62. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/probe.py +0 -0
  63. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/py.typed +0 -0
  64. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/__init__.py +0 -0
  65. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/_node.py +0 -0
  66. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/cloudflare.py +0 -0
  67. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/dbos_ts.py +0 -0
  68. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/inngest.py +0 -0
  69. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/runners/temporal.py +0 -0
  70. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/serve/__init__.py +0 -0
  71. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/serve/backends.py +0 -0
  72. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/serve/registry.py +0 -0
  73. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/serve/server.py +0 -0
  74. {rote_cli-0.12.1 → rote_cli-0.13.0}/src/rote/skill_source.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rote-cli
3
- Version: 0.12.1
3
+ Version: 0.13.0
4
4
  Summary: Compile AI agent skills into cheap, fast, deterministic pipelines that run without an LLM in the loop
5
5
  Project-URL: Homepage, https://roteskills.com
6
6
  Project-URL: Repository, https://github.com/trevhud/rote
@@ -434,9 +434,8 @@ plain-Python subprocess); those need a Node toolchain / Docker, so they
434
434
  run locally with `pytest tests/ -m slow`, not in CI.
435
435
 
436
436
  Known gaps: the extracted modules are `NotImplementedError` stubs
437
- you fill in with real API-client code, a Restate adapter is planned, and
438
- `fan_out` nodes currently receive the whole upstream list in one
439
- invocation (per-element dispatch is a planned enhancement). Published on
437
+ you fill in with real API-client code, and a Restate adapter is planned.
438
+ Published on
440
439
  PyPI as [`rote-cli`](https://pypi.org/project/rote-cli/) via tag-driven
441
440
  Trusted Publishing ([docs/releasing.md](docs/releasing.md)).
442
441
 
@@ -517,9 +516,7 @@ In rough priority order:
517
516
  Cloudflare; a separate node makes the short-circuit uniform.
518
517
  3. **More example skills**: BDR is one shape; research-heavy,
519
518
  retrieval-heavy, and code-review skills stress the IR differently.
520
- 4. **`fan_out` per-element dispatch**: currently the whole upstream list
521
- arrives in one invocation.
522
- 5. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
519
+ 4. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
523
520
  pointing `rote compile` at it should crystallize its rubric-grade
524
521
  pieces and leave only the genuinely fuzzy judgments in the loop.
525
522
 
@@ -378,9 +378,8 @@ plain-Python subprocess); those need a Node toolchain / Docker, so they
378
378
  run locally with `pytest tests/ -m slow`, not in CI.
379
379
 
380
380
  Known gaps: the extracted modules are `NotImplementedError` stubs
381
- you fill in with real API-client code, a Restate adapter is planned, and
382
- `fan_out` nodes currently receive the whole upstream list in one
383
- invocation (per-element dispatch is a planned enhancement). Published on
381
+ you fill in with real API-client code, and a Restate adapter is planned.
382
+ Published on
384
383
  PyPI as [`rote-cli`](https://pypi.org/project/rote-cli/) via tag-driven
385
384
  Trusted Publishing ([docs/releasing.md](docs/releasing.md)).
386
385
 
@@ -461,9 +460,7 @@ In rough priority order:
461
460
  Cloudflare; a separate node makes the short-circuit uniform.
462
461
  3. **More example skills**: BDR is one shape; research-heavy,
463
462
  retrieval-heavy, and code-review skills stress the IR differently.
464
- 4. **`fan_out` per-element dispatch**: currently the whole upstream list
465
- arrives in one invocation.
466
- 5. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
463
+ 4. **The compiler compiling itself**: `rote-compile` is a SKILL.md;
467
464
  pointing `rote compile` at it should crystallize its rubric-grade
468
465
  pieces and leave only the genuinely fuzzy judgments in the loop.
469
466
 
@@ -138,7 +138,11 @@ extend-exclude = ["examples"]
138
138
  select = ["E", "F", "I", "UP", "B", "SIM"]
139
139
 
140
140
  [tool.pytest.ini_options]
141
- addopts = "-m 'not slow'"
141
+ # -rs: always print the REASON for every skip. A conditional skip whose
142
+ # condition silently becomes permanent is invisible behind a bare
143
+ # "1 skipped" count — that is how `test_per_tool_override_changes_payload`
144
+ # went from a real test to dead weight without anyone noticing.
145
+ addopts = "-m 'not slow' -rs"
142
146
  markers = [
143
147
  "slow: tests that need an external toolchain (Node/npm/tsc) or take >5s. Run with `pytest -m slow` to include.",
144
148
  ]
@@ -1,3 +1,3 @@
1
1
  """rote — compile fuzzy AI skills into deterministic workflows."""
2
2
 
3
- __version__ = "0.12.1"
3
+ __version__ = "0.13.0"
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import hashlib
15
15
  import json
16
16
  import re
17
+ from collections.abc import Sequence
17
18
  from pathlib import Path
18
19
 
19
20
  from rote.ir import Node, NodeKind, Pipeline, parse_input_ref
@@ -305,12 +306,20 @@ def ir_duration_to_human(s: str) -> str:
305
306
  Cloudflare's step config accepts directly. Strings that don't match
306
307
  the IR shorthand pattern are passed through unchanged (they're
307
308
  assumed to already be in an acceptable form).
309
+
310
+ A value of exactly 1 is singularized ('1 hour', not '1 hours').
311
+ Cloudflare's ``WorkflowDuration`` accepts either spelling, so this
312
+ is legibility rather than correctness — but emitted code is read by
313
+ humans, and '1 hours' in a reviewed artifact looks like a bug.
308
314
  """
309
315
  s = s.strip()
310
316
  m = _IR_DURATION_RE.fullmatch(s)
311
317
  if not m:
312
318
  return s
313
- return f"{m.group(1)} {_UNIT_TO_HUMAN[m.group(2)]}"
319
+ value, unit = m.group(1), _UNIT_TO_HUMAN[m.group(2)]
320
+ if float(value) == 1:
321
+ unit = unit.removesuffix("s")
322
+ return f"{value} {unit}"
314
323
 
315
324
 
316
325
  _UNIT_TO_SECONDS = {
@@ -422,6 +431,72 @@ def check_input_refs_available(node: Node, available: set[str]) -> None:
422
431
  )
423
432
 
424
433
 
434
+ def fan_out_element_param(node: Node, pipeline: Pipeline) -> str:
435
+ """Which ``inputs`` param carries the list a ``fan_out`` node fans over.
436
+
437
+ Every other input is shared verbatim by all invocations. Which param
438
+ is the list, in precedence order:
439
+
440
+ 1. the param bound to the source of an incoming ``fan_out: true``
441
+ edge (the IR's explicit marker);
442
+ 2. else the param bound to a node with any incoming edge — inputs
443
+ may also reference nodes *without* an edge (shared context, e.g.
444
+ BDR's ``intel``), which is what makes "the only node-bound
445
+ param" too naive;
446
+ 3. else the only node-bound param.
447
+
448
+ Ambiguity after all three is an emit-time error, never a guess:
449
+ dispatching over the wrong list would silently judge the wrong
450
+ things.
451
+
452
+ This lives in the language-neutral common module because the answer
453
+ is a property of the IR, not of the target language — every adapter
454
+ must fan over the *same* input or the same pipeline would mean
455
+ different things on different runtimes.
456
+ """
457
+ if not node.inputs:
458
+ raise ValueError(f"fan_out node {node.id!r} has no inputs: nothing to fan over")
459
+ node_bound = {
460
+ param: parsed.node_id
461
+ for param, ref in node.inputs.items()
462
+ if (parsed := parse_input_ref(ref)).node_id is not None
463
+ }
464
+ edge_sources = {e.from_ for e in pipeline.edges if e.to == node.id}
465
+ fan_edge_sources = {e.from_ for e in pipeline.edges if e.to == node.id and e.fan_out}
466
+
467
+ # The tiers are tried narrowest-first, but the ORDER is provably
468
+ # unobservable: fan_edge_sources ⊆ edge_sources by construction, so
469
+ # matching(fan) ⊆ matching(edge), and a tier is accepted only when it
470
+ # singles out exactly one param. If the broader tier singles one out,
471
+ # it is necessarily the same one. (Mutation testing flags swapping
472
+ # these as a surviving mutant — it is equivalent, not a test gap.)
473
+ # The tiers still earn their place: they express which signal the
474
+ # author gave, and tier 2 is what handles a marker pointing at a node
475
+ # no input references.
476
+ for sources in (fan_edge_sources, edge_sources):
477
+ matching = sorted(p for p, src in node_bound.items() if src in sources)
478
+ if len(matching) == 1:
479
+ return matching[0]
480
+ if len(node_bound) != 1:
481
+ raise ValueError(
482
+ f"fan_out node {node.id!r}: cannot identify the element param — "
483
+ f"node-bound inputs {sorted(node_bound)} and incoming edges "
484
+ f"{sorted(edge_sources)} don't single one out; mark the list edge "
485
+ f"with `fan_out: true`"
486
+ )
487
+ return next(iter(node_bound))
488
+
489
+
490
+ def fan_out_nodes(wave: Sequence[Node]) -> tuple[list[Node], list[Node]]:
491
+ """Split a wave into ``(fan_out_nodes, plain_nodes)``, order preserved.
492
+
493
+ A ``fan_out`` node dispatches once per element of its bound list, so
494
+ it never shares the single/parallel payload shapes an adapter emits
495
+ for plain nodes.
496
+ """
497
+ return [n for n in wave if n.fan_out], [n for n in wave if not n.fan_out]
498
+
499
+
425
500
  # ───────── MCP-capability refusal ─────────
426
501
 
427
502
 
@@ -30,6 +30,7 @@ from rote.adapters._common import (
30
30
  DEFAULT_AGENT_MAX_ITERATIONS,
31
31
  EmitWriter,
32
32
  _to_pascal_case,
33
+ fan_out_element_param,
33
34
  safe_docstring_line,
34
35
  )
35
36
  from rote.ir import LLMSignature, Node, NodeKind, Pipeline, parse_input_ref
@@ -93,52 +94,65 @@ def _payload_literal(node: Node, indent: str) -> str:
93
94
  return "\n".join(lines)
94
95
 
95
96
 
96
- def fan_out_binding(node: Node, pipeline: Pipeline) -> tuple[str, str, dict[str, str]]:
97
- """``(element_param, list_expr, scalar_exprs)`` for a ``fan_out`` node.
97
+ _FAN_OUT_LIST_HELPER_PY = '''\
98
+ def _fan_out_list(value: object, node_id: str, ref: str) -> list:
99
+ """The list a fan_out node dispatches over.
98
100
 
99
- The element parameter is the one bound to the upstream *list* the
100
- node fans over (ir-schema.md); every other input is shared verbatim
101
- by all invocations. Which param that is, in precedence order:
102
-
103
- 1. the param bound to the source of an incoming ``fan_out: true``
104
- edge (the IR's explicit marker);
105
- 2. else the param bound to a node with any incoming edge — inputs
106
- may also reference nodes *without* an edge (shared context, e.g.
107
- BDR's ``intel``), which is what makes "the only node-bound
108
- param" too naive;
109
- 3. else the only node-bound param.
110
-
111
- Ambiguity after all three is an emit-time error, never a guess:
112
- dispatching over the wrong list would silently judge the wrong
113
- things.
101
+ Iterating a missing key raises "'NoneType' object is not iterable",
102
+ which names neither the node nor the reference — in generated code
103
+ that is close to undebuggable, and the cause is almost always an
104
+ upstream node that didn't return the key the IR says it does. The
105
+ TypeScript runtimes emit the same guard (`fanOutList`), so a missing
106
+ key reports identically on every target.
114
107
  """
115
- if not node.inputs:
116
- raise ValueError(f"fan_out node {node.id!r} has no inputs: nothing to fan over")
117
- node_bound = {
118
- param: parsed.node_id
119
- for param, ref in node.inputs.items()
120
- if (parsed := parse_input_ref(ref)).node_id is not None
121
- }
122
- edge_sources = {e.from_ for e in pipeline.edges if e.to == node.id}
123
- fan_edge_sources = {e.from_ for e in pipeline.edges if e.to == node.id and e.fan_out}
124
-
125
- for sources in (fan_edge_sources, edge_sources):
126
- matching = sorted(p for p, src in node_bound.items() if src in sources)
127
- if len(matching) == 1:
128
- element_param = matching[0]
129
- break
130
- else:
131
- if len(node_bound) != 1:
132
- raise ValueError(
133
- f"fan_out node {node.id!r}: cannot identify the element param — "
134
- f"node-bound inputs {sorted(node_bound)} and incoming edges "
135
- f"{sorted(edge_sources)} don't single one out; mark the list edge "
136
- f"with `fan_out: true`"
137
- )
138
- element_param = next(iter(node_bound))
108
+ if not isinstance(value, list):
109
+ raise TypeError(
110
+ f"fan_out node {node_id!r} expected a list from {ref!r}, got "
111
+ f"{type(value).__name__}. The upstream node must return that "
112
+ f"key as a list."
113
+ )
114
+ return value
115
+ '''
116
+
117
+
118
+ def fan_out_list_helper() -> str:
119
+ """The ``_fan_out_list`` guard emitted into Python runtimes that fan out.
139
120
 
121
+ Behavioral emitted code, like ``_serialize``: if the runtimes' guards
122
+ drift, the same broken pipeline reports differently per target.
123
+ """
124
+ return _FAN_OUT_LIST_HELPER_PY
125
+
126
+
127
+ def fan_out_binding(
128
+ node: Node, pipeline: Pipeline, indent: str = ""
129
+ ) -> tuple[str, str, dict[str, str]]:
130
+ """``(element_param, list_expr, scalar_exprs)`` for a ``fan_out`` node.
131
+
132
+ The Python rendering of :func:`fan_out_element_param` — which input
133
+ is the list is a language-neutral property of the IR, so it is
134
+ resolved there and only the expressions are built here.
135
+
136
+ ``indent`` is the column the ``_fan_out_list(`` token sits at; the
137
+ guard call wraps against it rather than running long on one line.
138
+ """
139
+ element_param = fan_out_element_param(node, pipeline)
140
+ # Narrowing only: fan_out_element_param raises on a node with no
141
+ # inputs, so reaching here means there is something to fan over.
142
+ assert node.inputs is not None
140
143
  scalars = {p: _ref_to_python_expr(r) for p, r in node.inputs.items() if p != element_param}
141
- return element_param, _ref_to_python_expr(node.inputs[element_param]), scalars
144
+ ref = node.inputs[element_param]
145
+ inner = indent + " "
146
+ # json.dumps, not !r: emitted Python uses double quotes throughout
147
+ # (the repo's ruff-format style), same as _py_literal.
148
+ list_expr = (
149
+ "_fan_out_list(\n"
150
+ f"{inner}{_ref_to_python_expr(ref)},\n"
151
+ f"{inner}{json.dumps(node.id)},\n"
152
+ f"{inner}{json.dumps(ref)},\n"
153
+ f"{indent})"
154
+ )
155
+ return element_param, list_expr, scalars
142
156
 
143
157
 
144
158
  # ───────── JSON Schema → Pydantic source ─────────
@@ -40,6 +40,7 @@ from rote.adapters._common import (
40
40
  DEFAULT_AGENT_MAX_ITERATIONS,
41
41
  _to_camel_case,
42
42
  _to_pascal_case,
43
+ fan_out_element_param,
43
44
  safe_block_comment_line,
44
45
  )
45
46
  from rote.ir import LLMSignature, Node, NodeKind, Pipeline, parse_input_ref
@@ -593,6 +594,67 @@ def payload_ts_literal(node: Node, indent: str) -> str:
593
594
  return "\n".join(lines)
594
595
 
595
596
 
597
+ FAN_OUT_LIST_HELPER_TS = """\
598
+ /** The list a fan_out node dispatches over.
599
+ *
600
+ * A bare `.map()` on a missing key fails with "Cannot read properties
601
+ * of undefined (reading 'map')", which names neither the node nor the
602
+ * reference — in generated code that is close to undebuggable, and the
603
+ * cause is almost always an upstream node that didn't return the key
604
+ * the IR says it does. One call turns that into an actionable message.
605
+ * Found by running it: the first live fan_out run hit exactly this. */
606
+ function fanOutList(value: unknown, nodeId: string, ref: string): unknown[] {
607
+ if (!Array.isArray(value)) {
608
+ const got = value === null ? "null" : typeof value;
609
+ throw new Error(
610
+ `fan_out node '${nodeId}' expected an array from '${ref}', got ${got}. ` +
611
+ `The upstream node must return that key as a list.`,
612
+ );
613
+ }
614
+ return value;
615
+ }
616
+ """
617
+
618
+
619
+ def fan_out_ts_binding(node: Node, pipeline: Pipeline, indent: str = "") -> tuple[str, str]:
620
+ """``(payload_literal, list_expr)`` for a ``fan_out`` node in TypeScript.
621
+
622
+ The payload is a one-line object binding the element param to the
623
+ loop variable ``_item`` and every other input to its shared
624
+ expression — the TS rendering of
625
+ :func:`rote.adapters._common.fan_out_element_param`, which decides
626
+ *which* input is the list.
627
+
628
+ The list expression goes through ``fanOutList``
629
+ (:data:`FAN_OUT_LIST_HELPER_TS`), which both narrows the type — node
630
+ results are ``Record<string, unknown>`` or ``never``, so neither
631
+ ``.map`` nor ``for…of`` compiles against them — and reports a
632
+ missing upstream key by node id instead of as a bare TypeError.
633
+
634
+ ``indent`` is the column the ``fanOutList(`` token sits at. The call
635
+ wraps against it instead of running to ~180 characters on one line —
636
+ emitted code is meant to be read and reviewed.
637
+ """
638
+ element_param = fan_out_element_param(node, pipeline)
639
+ entries = []
640
+ for param, input_ref in node.inputs.items() if node.inputs else []:
641
+ key = param if _TS_IDENT_RE.fullmatch(param) else json.dumps(param)
642
+ value = "_item" if param == element_param else ref_to_ts_expr(input_ref)
643
+ entries.append(f"{key}: {value}")
644
+ payload = "{ " + ", ".join(entries) + " }"
645
+ assert node.inputs is not None
646
+ ref = node.inputs[element_param]
647
+ inner = indent + " "
648
+ list_expr = (
649
+ "fanOutList(\n"
650
+ f"{inner}{ref_to_ts_expr(ref)},\n"
651
+ f"{inner}{json.dumps(node.id)},\n"
652
+ f"{inner}{json.dumps(ref)},\n"
653
+ f"{indent})"
654
+ )
655
+ return payload, list_expr
656
+
657
+
596
658
  # ───────── Pipeline queries ─────────
597
659
 
598
660
 
@@ -42,6 +42,7 @@ from rote.adapters._common import (
42
42
  _pipeline_hash,
43
43
  _to_camel_case,
44
44
  check_input_refs_available,
45
+ fan_out_nodes,
45
46
  pipeline_identity,
46
47
  safe_block_comment_line,
47
48
  workflow_class_name,
@@ -52,6 +53,7 @@ from rote.adapters._common import (
52
53
  from rote.adapters._ts_common import (
53
54
  AI_UTILS_NPM_VERSION,
54
55
  ANTHROPIC_SDK_NPM_VERSION,
56
+ FAN_OUT_LIST_HELPER_TS,
55
57
  MCP_SDK_NPM_VERSION,
56
58
  ROTE_INFERENCE_HELPER_TS,
57
59
  ROTE_MCP_WORKERS_HELPER_TS,
@@ -60,6 +62,7 @@ from rote.adapters._ts_common import (
60
62
  emit_agent_loop_module,
61
63
  emit_ts_signature_module,
62
64
  emit_workers_mcp_call_module,
65
+ fan_out_ts_binding,
63
66
  llm_clients,
64
67
  mcp_backed_nodes,
65
68
  module_imports,
@@ -181,7 +184,12 @@ def auth_event_type(server: str) -> str:
181
184
 
182
185
 
183
186
  def _emit_step_call_mcp_parkable(
184
- node: Node, cfg: CloudflareAdapterConfig, *, pipeline: Pipeline
187
+ node: Node,
188
+ cfg: CloudflareAdapterConfig,
189
+ *,
190
+ pipeline: Pipeline,
191
+ payload: str | None = None,
192
+ name_suffix: str = "",
185
193
  ) -> str:
186
194
  """MCP-backed dispatch: auth failures park the instance durably.
187
195
 
@@ -194,10 +202,15 @@ def _emit_step_call_mcp_parkable(
194
202
  behavior; and events sent before the wait starts are buffered
195
203
  per-instance, so `rote mcp release` can blast every non-terminal
196
204
  instance without a race.
205
+
206
+ ``payload`` and ``name_suffix`` are supplied by the fan_out path:
207
+ one element per call, and a step-name fragment (``[${_index}]``)
208
+ that keeps each element's step name unique within the instance.
197
209
  """
198
210
  fn_name = _to_camel_case(node.id)
199
211
  config = _step_config_literal(node, cfg)
200
- payload = payload_ts_literal(node, indent=" " * 24)
212
+ if payload is None:
213
+ payload = payload_ts_literal(node, indent=" " * 24)
201
214
  nid = node.id
202
215
  if node.mcp is not None:
203
216
  # One binding, one server: the release event is known at emit time.
@@ -220,6 +233,10 @@ def _emit_step_call_mcp_parkable(
220
233
  " // so `rote mcp release <server>` releases the right instances.\n"
221
234
  )
222
235
  label = "agent loop" if node.mcp is None else "MCP-backed"
236
+ # A fan_out element's step names carry its index; every step name
237
+ # must be unique within an instance or the elements collapse onto
238
+ # one cached step.
239
+ first_name = f"`{nid}{name_suffix}`" if name_suffix else json.dumps(nid)
223
240
  return (
224
241
  f" // ─── {nid} ({label}): park on dead credentials ───\n"
225
242
  f"{release_hint}"
@@ -228,8 +245,8 @@ def _emit_step_call_mcp_parkable(
228
245
  f" try {{\n"
229
246
  f" {nid}_result = (await step.do(\n"
230
247
  f" {nid}_attempt === 0\n"
231
- f" ? {json.dumps(nid)}\n"
232
- f" : `{nid} (auth retry ${{{nid}_attempt}})`,\n"
248
+ f" ? {first_name}\n"
249
+ f" : `{nid}{name_suffix} (auth retry ${{{nid}_attempt}})`,\n"
233
250
  f" {config},\n"
234
251
  f" async () => {{\n"
235
252
  f" try {{\n"
@@ -252,7 +269,7 @@ def _emit_step_call_mcp_parkable(
252
269
  f" // consecutive failure. Fresh step names per attempt.\n"
253
270
  f" if ({nid}_attempt % 2 === 1) {{\n"
254
271
  f" await step.waitForEvent<any>(\n"
255
- f" `{nid} auth wait ${{{nid}_attempt}}`,\n"
272
+ f" `{nid}{name_suffix} auth wait ${{{nid}_attempt}}`,\n"
256
273
  f' {{ type: {event_expr}, timeout: "30 days" }},\n'
257
274
  f" );\n"
258
275
  f" }}\n"
@@ -341,6 +358,67 @@ def _emit_parallel_step_calls(nodes: list[Node], cfg: CloudflareAdapterConfig) -
341
358
  return "\n".join(lines) + "\n"
342
359
 
343
360
 
361
+ def _emit_fan_out_parallel(node: Node, cfg: CloudflareAdapterConfig, *, pipeline: Pipeline) -> str:
362
+ """Emit a plain ``fan_out`` node as one ``step.do`` per element.
363
+
364
+ Same ``Promise.all`` reasoning as a parallel wave: it is
365
+ Cloudflare's documented concurrency primitive, each element persists
366
+ under its own step name, and a rejection means that element already
367
+ exhausted its own retries.
368
+
369
+ Step names are index-suffixed. Cloudflare caches a step's result by
370
+ name, so reusing one name for every element would return the first
371
+ element's result for all of them — the failure would look like a
372
+ correct run with suspiciously uniform output.
373
+ """
374
+ payload, list_expr = fan_out_ts_binding(node, pipeline, indent=" " * 12)
375
+ pass_env = node.kind in (NodeKind.LLM_JUDGE, NodeKind.AGENT_LOOP)
376
+ args = f"{payload}, this.env" if pass_env else payload
377
+ return "\n".join(
378
+ [
379
+ f" // fan_out: {node.id} runs once per element of the bound list,",
380
+ " // each as its own durable step (names are index-suffixed —",
381
+ " // Cloudflare caches step results by name).",
382
+ f" const {node.id}_result = await Promise.all(",
383
+ f" {list_expr}.map((_item, _index) =>",
384
+ " step.do(",
385
+ f" `{node.id}[${{_index}}]`,",
386
+ f" {_step_config_literal(node, cfg)},",
387
+ f" async () => {_to_camel_case(node.id)}({args}),",
388
+ " ),",
389
+ " ),",
390
+ " );",
391
+ ]
392
+ )
393
+
394
+
395
+ def _emit_fan_out_parkable(node: Node, cfg: CloudflareAdapterConfig, *, pipeline: Pipeline) -> str:
396
+ """Emit a parkable ``fan_out`` node: one element per iteration.
397
+
398
+ Sequential, for the same reason a parkable step leaves its parallel
399
+ wave — it can suspend on ``waitForEvent``, whose behavior inside a
400
+ promise combinator is undocumented and whose timeout *throws*, which
401
+ would reject every other element.
402
+ """
403
+ payload, list_expr = fan_out_ts_binding(node, pipeline, indent=" " * 8)
404
+ inner = _emit_step_call_mcp_parkable(
405
+ node, cfg, pipeline=pipeline, payload=payload, name_suffix="[${_index}]"
406
+ )
407
+ return "\n".join(
408
+ [
409
+ f" // fan_out: {node.id} runs once per element of the bound list.",
410
+ " // Parkable steps stay sequential — a waitForEvent inside a",
411
+ " // promise combinator would reject every sibling on timeout.",
412
+ f" const {node.id}_results: Record<string, unknown>[] = [];",
413
+ f" for (const [_index, _item] of {list_expr}.entries()) {{",
414
+ textwrap.indent(inner.rstrip("\n"), " "),
415
+ f" {node.id}_results.push({node.id}_result);",
416
+ " }",
417
+ f" const {node.id}_result = {node.id}_results;",
418
+ ]
419
+ )
420
+
421
+
344
422
  def _emit_hitl_gate(node: Node, cfg: CloudflareAdapterConfig) -> str:
345
423
  assert node.signal is not None
346
424
  _validate_signal_name(node.signal, node.id)
@@ -508,7 +586,14 @@ def emit_workflow(pipeline: Pipeline, cfg: CloudflareAdapterConfig | None = None
508
586
  parkable = [n for n in wave if _is_parkable(n)]
509
587
  plain = [n for n in wave if n.kind is not NodeKind.HITL_GATE and not _is_parkable(n)]
510
588
 
511
- for node in plain + parkable:
589
+ # fan_out nodes dispatch once per element of their bound list —
590
+ # they never share the single/parallel payload shapes below, but
591
+ # they keep the parkable/plain split (a parkable fan_out runs its
592
+ # elements sequentially, for the same waitForEvent reason).
593
+ fanned_parkable, parkable = fan_out_nodes(parkable)
594
+ fanned_plain, plain = fan_out_nodes(plain)
595
+
596
+ for node in plain + parkable + fanned_plain + fanned_parkable:
512
597
  check_input_refs_available(node, available)
513
598
 
514
599
  if len(plain) > 1:
@@ -527,6 +612,12 @@ def emit_workflow(pipeline: Pipeline, cfg: CloudflareAdapterConfig | None = None
527
612
  _emit_step_call_mcp_parkable(node, cfg, pipeline=pipeline).rstrip("\n")
528
613
  )
529
614
 
615
+ for node in fanned_plain:
616
+ body_lines.append(_emit_fan_out_parallel(node, cfg, pipeline=pipeline))
617
+
618
+ for node in fanned_parkable:
619
+ body_lines.append(_emit_fan_out_parkable(node, cfg, pipeline=pipeline))
620
+
530
621
  for node in gates:
531
622
  body_lines.append(_emit_hitl_gate(node, cfg).rstrip("\n"))
532
623
 
@@ -552,6 +643,8 @@ def emit_workflow(pipeline: Pipeline, cfg: CloudflareAdapterConfig | None = None
552
643
  )
553
644
 
554
645
  helper_block = f"{_STEP_NEEDS_AUTH_HELPER}\n" if parks_on_auth else ""
646
+ if any(n.fan_out for n in pipeline.nodes):
647
+ helper_block += f"{FAN_OUT_LIST_HELPER_TS}\n"
555
648
 
556
649
  return header + imports + "\n" + env_block + helper_block + class_block
557
650
 
@@ -88,6 +88,7 @@ from rote.adapters._py_common import (
88
88
  _signature_path_parts,
89
89
  agent_loop_call,
90
90
  fan_out_binding,
91
+ fan_out_list_helper,
91
92
  resolve_extracted_source,
92
93
  serialize_helper,
93
94
  write_signature_package,
@@ -521,7 +522,7 @@ def _emit_workflow_body(pipeline: Pipeline, cfg: DbosAdapterConfig) -> str:
521
522
  lines.append(f" {node.id}_result = {node.id}_handle.get_result()")
522
523
 
523
524
  for node in fan_out_nodes:
524
- element_param, list_expr, scalars = fan_out_binding(node, pipeline)
525
+ element_param, list_expr, scalars = fan_out_binding(node, pipeline, indent=" " * 8)
525
526
  payload_items = "".join(
526
527
  f', "{param}": {expr}' for param, expr in sorted(scalars.items())
527
528
  )
@@ -660,6 +661,9 @@ def emit_main(pipeline: Pipeline, cfg: DbosAdapterConfig | None = None) -> str:
660
661
  " portable across the system database."
661
662
  )
662
663
 
664
+ if any(n.fan_out for n in pipeline.nodes):
665
+ header += "\n\n" + fan_out_list_helper()
666
+
663
667
  if mcp_backed:
664
668
  header += "\n\n" + textwrap.dedent(
665
669
  '''\