rote-cli 0.1.0__py3-none-any.whl
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.
- rote/__init__.py +3 -0
- rote/adapters/__init__.py +70 -0
- rote/adapters/_common.py +149 -0
- rote/adapters/cloudflare.py +978 -0
- rote/adapters/dbos.py +1235 -0
- rote/adapters/temporal.py +568 -0
- rote/cli.py +520 -0
- rote/graduator/__init__.py +259 -0
- rote/graduator/drivers/__init__.py +226 -0
- rote/graduator/drivers/anthropic_api.py +423 -0
- rote/graduator/drivers/claude.py +312 -0
- rote/graduator/drivers/codex.py +83 -0
- rote/ir.py +495 -0
- rote/serve/__init__.py +14 -0
- rote/serve/backends.py +162 -0
- rote/serve/registry.py +201 -0
- rote/serve/server.py +249 -0
- rote/skills/rote-graduate/SKILL.md +230 -0
- rote/skills/rote-graduate/references/crystallization-heuristics.md +260 -0
- rote/skills/rote-graduate/references/ir-schema.md +420 -0
- rote/skills/rote-graduate/references/llm-judge-extraction.md +341 -0
- rote/skills/rote-graduate/references/node-kinds.md +223 -0
- rote_cli-0.1.0.dist-info/METADATA +546 -0
- rote_cli-0.1.0.dist-info/RECORD +27 -0
- rote_cli-0.1.0.dist-info/WHEEL +4 -0
- rote_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rote_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
rote/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Runtime adapters for emitted pipeline code.
|
|
2
|
+
|
|
3
|
+
Each adapter takes a :class:`rote.ir.Pipeline` and emits runnable code
|
|
4
|
+
for a specific durable execution engine. The IR is the source of truth;
|
|
5
|
+
adapters are template substitution.
|
|
6
|
+
|
|
7
|
+
The "two adapters minimum" rule applies: until at least two adapters
|
|
8
|
+
work end-to-end, assume the IR shape is secretly leaking the first
|
|
9
|
+
runtime's mental model. Inngest is the planned second target after
|
|
10
|
+
Temporal.
|
|
11
|
+
|
|
12
|
+
Adapters are registered in :data:`ADAPTERS` and dispatched by name from
|
|
13
|
+
the CLI (``rote emit --runtime <name>``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
from rote.ir import Pipeline
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Adapter(Protocol):
|
|
26
|
+
"""Structural protocol every adapter must satisfy."""
|
|
27
|
+
|
|
28
|
+
def emit(self, pipeline: Pipeline, output_dir: str | Path) -> dict[str, Path]: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _temporal_adapter_factory() -> Adapter:
|
|
32
|
+
# Lazy import so users who don't use Temporal don't pay the
|
|
33
|
+
# temporalio import cost just for launching the CLI.
|
|
34
|
+
from rote.adapters.temporal import TemporalAdapter
|
|
35
|
+
|
|
36
|
+
return TemporalAdapter()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cloudflare_adapter_factory() -> Adapter:
|
|
40
|
+
from rote.adapters.cloudflare import CloudflareAdapter
|
|
41
|
+
|
|
42
|
+
return CloudflareAdapter()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _dbos_adapter_factory() -> Adapter:
|
|
46
|
+
from rote.adapters.dbos import DbosAdapter
|
|
47
|
+
|
|
48
|
+
return DbosAdapter()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
#: Name → factory. Keep the values as zero-arg callables so adapters can
|
|
52
|
+
#: lazy-import their heavy dependencies.
|
|
53
|
+
ADAPTERS: dict[str, Callable[[], Adapter]] = {
|
|
54
|
+
"temporal": _temporal_adapter_factory,
|
|
55
|
+
"cloudflare": _cloudflare_adapter_factory,
|
|
56
|
+
"dbos": _dbos_adapter_factory,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_adapter(name: str) -> Adapter:
|
|
61
|
+
"""Return an adapter instance for the given runtime name.
|
|
62
|
+
|
|
63
|
+
Raises ``KeyError`` with a helpful message if the runtime is unknown.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
factory = ADAPTERS[name]
|
|
67
|
+
except KeyError:
|
|
68
|
+
available = ", ".join(sorted(ADAPTERS))
|
|
69
|
+
raise KeyError(f"Unknown runtime {name!r}. Available: {available}") from None
|
|
70
|
+
return factory()
|
rote/adapters/_common.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Helpers shared by every runtime adapter.
|
|
2
|
+
|
|
3
|
+
These functions are runtime-agnostic: they operate purely on the
|
|
4
|
+
:class:`rote.ir.Pipeline` IR and on plain strings. Anything that encodes
|
|
5
|
+
a *runtime's* semantics (Temporal retry policies, Cloudflare step
|
|
6
|
+
configs, …) stays in the adapter that owns it.
|
|
7
|
+
|
|
8
|
+
Extracted from ``rote.adapters.temporal`` / ``rote.adapters.cloudflare``
|
|
9
|
+
once the second adapter proved the helpers were genuinely shared.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from rote.ir import Node, Pipeline, parse_input_ref
|
|
18
|
+
|
|
19
|
+
# ───────── Case conversion ─────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _to_pascal_case(s: str) -> str:
|
|
23
|
+
"""Convert kebab-case or snake_case to PascalCase."""
|
|
24
|
+
parts = s.replace("-", "_").split("_")
|
|
25
|
+
return "".join(p.capitalize() for p in parts if p)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _to_camel_case(s: str) -> str:
|
|
29
|
+
pascal = _to_pascal_case(s)
|
|
30
|
+
if not pascal:
|
|
31
|
+
return ""
|
|
32
|
+
return pascal[0].lower() + pascal[1:]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ───────── Pipeline identity ─────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pipeline_hash(pipeline: Pipeline) -> str:
|
|
39
|
+
"""Stable 8-char hash of the pipeline's identity.
|
|
40
|
+
|
|
41
|
+
Used to version emitted artifacts (e.g. the Temporal workflow type
|
|
42
|
+
name) so a regenerated pipeline becomes a new type. Old in-flight
|
|
43
|
+
workflows continue on the old code; new workflows use the new code.
|
|
44
|
+
"""
|
|
45
|
+
payload = f"{pipeline.name}|{pipeline.version}|{len(pipeline.nodes)}|{len(pipeline.edges)}"
|
|
46
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:8]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ───────── Duration strings ─────────
|
|
50
|
+
|
|
51
|
+
_IR_DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)(ms|s|m|h|d)$")
|
|
52
|
+
|
|
53
|
+
_UNIT_TO_HUMAN = {
|
|
54
|
+
"ms": "milliseconds",
|
|
55
|
+
"s": "seconds",
|
|
56
|
+
"m": "minutes",
|
|
57
|
+
"h": "hours",
|
|
58
|
+
"d": "days",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ir_duration_to_human(s: str) -> str:
|
|
63
|
+
"""Convert an IR duration string ('5m', '30s', '7d') to a human form.
|
|
64
|
+
|
|
65
|
+
Returns e.g. '5 minutes', '30 seconds', '7 days' — the format
|
|
66
|
+
Cloudflare's step config accepts directly. Strings that don't match
|
|
67
|
+
the IR shorthand pattern are passed through unchanged (they're
|
|
68
|
+
assumed to already be in an acceptable form).
|
|
69
|
+
"""
|
|
70
|
+
s = s.strip()
|
|
71
|
+
m = _IR_DURATION_RE.fullmatch(s)
|
|
72
|
+
if not m:
|
|
73
|
+
return s
|
|
74
|
+
return f"{m.group(1)} {_UNIT_TO_HUMAN[m.group(2)]}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ───────── Topological waves ─────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _execution_waves(pipeline: Pipeline) -> list[list[Node]]:
|
|
81
|
+
"""Topologically sort the pipeline into parallel-execution waves.
|
|
82
|
+
|
|
83
|
+
A "wave" is a set of nodes whose dependencies have all been satisfied
|
|
84
|
+
by the time the wave starts; nodes within a wave can run in parallel.
|
|
85
|
+
|
|
86
|
+
Nodes that appear inside another node's ``loop_body`` are excluded
|
|
87
|
+
from the top-level waves — they're orchestrated from inside the
|
|
88
|
+
parent loop activity, not by the workflow.
|
|
89
|
+
"""
|
|
90
|
+
nested_ids: set[str] = set()
|
|
91
|
+
for n in pipeline.nodes:
|
|
92
|
+
if n.loop_body:
|
|
93
|
+
nested_ids.update(n.loop_body)
|
|
94
|
+
|
|
95
|
+
eligible = [n for n in pipeline.nodes if n.id not in nested_ids]
|
|
96
|
+
eligible_ids = {n.id for n in eligible}
|
|
97
|
+
|
|
98
|
+
in_degree: dict[str, int] = {n.id: 0 for n in eligible}
|
|
99
|
+
for edge in pipeline.edges:
|
|
100
|
+
if edge.from_ in eligible_ids and edge.to in eligible_ids:
|
|
101
|
+
in_degree[edge.to] += 1
|
|
102
|
+
|
|
103
|
+
waves: list[list[Node]] = []
|
|
104
|
+
remaining: set[str] = set(in_degree.keys())
|
|
105
|
+
|
|
106
|
+
while remaining:
|
|
107
|
+
# Sort by id for stable, deterministic emission order.
|
|
108
|
+
wave_ids = sorted(nid for nid in remaining if in_degree[nid] == 0)
|
|
109
|
+
if not wave_ids:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Cycle detected in pipeline {pipeline.name!r}; "
|
|
112
|
+
f"remaining nodes: {sorted(remaining)}"
|
|
113
|
+
)
|
|
114
|
+
waves.append([pipeline.node_by_id(nid) for nid in wave_ids])
|
|
115
|
+
for nid in wave_ids:
|
|
116
|
+
remaining.remove(nid)
|
|
117
|
+
for edge in pipeline.edges:
|
|
118
|
+
if edge.from_ == nid and edge.to in eligible_ids:
|
|
119
|
+
in_degree[edge.to] -= 1
|
|
120
|
+
|
|
121
|
+
return waves
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ───────── Data-flow threading ─────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_input_refs_available(node: Node, available: set[str]) -> None:
|
|
128
|
+
"""Emit-time guard: every node-output reference must already be bound.
|
|
129
|
+
|
|
130
|
+
Adapters emit nodes wave-by-wave, binding each node's result to a
|
|
131
|
+
local as they go. A node whose ``inputs`` reference a node in a
|
|
132
|
+
*later* wave (or a loop-body sub-node, which never binds a top-level
|
|
133
|
+
result) would produce code that references an undefined variable —
|
|
134
|
+
fail loudly at emit time instead.
|
|
135
|
+
|
|
136
|
+
IR validation already guarantees the referenced node *exists*; this
|
|
137
|
+
check is about emission ordering, which is an adapter concern.
|
|
138
|
+
"""
|
|
139
|
+
if not node.inputs:
|
|
140
|
+
return
|
|
141
|
+
for param, ref in node.inputs.items():
|
|
142
|
+
parsed = parse_input_ref(ref)
|
|
143
|
+
if parsed.node_id is not None and parsed.node_id not in available:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Node {node.id!r} input {param!r} references {ref!r}, but node "
|
|
146
|
+
f"{parsed.node_id!r} has no result available at this point in the "
|
|
147
|
+
f"workflow (it runs in a later wave, or is a loop-body sub-node "
|
|
148
|
+
f"with no top-level result)."
|
|
149
|
+
)
|