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
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Temporal adapter — emits Workflow + Activities Python from a Pipeline IR.
|
|
2
|
+
|
|
3
|
+
The adapter consumes a :class:`rote.ir.Pipeline` and produces two source
|
|
4
|
+
files:
|
|
5
|
+
|
|
6
|
+
* ``activities.py`` — one ``@activity.defn`` per non-HITL node, each a
|
|
7
|
+
thin wrapper around the corresponding ``extracted/`` function or
|
|
8
|
+
``signatures/`` class. **No MCP runtime ever appears in this file.**
|
|
9
|
+
External calls are direct API calls (deterministic Python), and
|
|
10
|
+
LLM judge calls are typed signatures. The graduation of an MCP tool
|
|
11
|
+
call into a deterministic activity happens *here*.
|
|
12
|
+
* ``workflow.py`` — one ``@workflow.defn`` class with the orchestration:
|
|
13
|
+
topologically-sorted activity calls grouped into parallel waves,
|
|
14
|
+
signal handlers and ``wait_condition`` calls for HITL gates, and
|
|
15
|
+
the workflow class name versioned by a hash of the pipeline so
|
|
16
|
+
regenerated workflows become new types (avoiding determinism errors
|
|
17
|
+
on in-flight workflows).
|
|
18
|
+
|
|
19
|
+
The adapter is intentionally template-driven and not clever. The interesting
|
|
20
|
+
work is the IR design and the topological sort; the code emission itself
|
|
21
|
+
is plain string templates.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import textwrap
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from rote.adapters._common import (
|
|
31
|
+
_execution_waves,
|
|
32
|
+
_pipeline_hash,
|
|
33
|
+
_to_pascal_case,
|
|
34
|
+
check_input_refs_available,
|
|
35
|
+
)
|
|
36
|
+
from rote.ir import Node, NodeKind, Pipeline, parse_input_ref
|
|
37
|
+
|
|
38
|
+
# ───────── Adapter configuration ─────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class TemporalAdapterConfig:
|
|
43
|
+
"""Per-emission configuration for the Temporal adapter.
|
|
44
|
+
|
|
45
|
+
These knobs are project-specific and should be set by the caller (or
|
|
46
|
+
eventually by an adapter config file in the source skill bundle).
|
|
47
|
+
Defaults match the BDR example so the adapter is usable out of the
|
|
48
|
+
box.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
# Python import paths used by the emitted code.
|
|
52
|
+
types_module: str = "expected.types"
|
|
53
|
+
extracted_module: str = "expected.extracted"
|
|
54
|
+
signatures_module: str = "expected.signatures"
|
|
55
|
+
|
|
56
|
+
# Default activity timeouts when the IR doesn't specify one.
|
|
57
|
+
default_activity_timeout: str = "5m"
|
|
58
|
+
default_hitl_timeout: str = "7d"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ───────── Helpers ─────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _impl_path_parts(impl: str) -> tuple[str, str]:
|
|
65
|
+
"""Parse 'extracted/foo.py:bar' into ('foo', 'bar')."""
|
|
66
|
+
if ":" not in impl:
|
|
67
|
+
raise ValueError(f"impl path missing ':function_name': {impl!r}")
|
|
68
|
+
file_part, func = impl.split(":", 1)
|
|
69
|
+
# Strip leading directories and .py suffix
|
|
70
|
+
module_name = file_part.rsplit("/", 1)[-1].removesuffix(".py")
|
|
71
|
+
return module_name, func
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _signature_path_parts(signature: str) -> tuple[str, str]:
|
|
75
|
+
"""Parse 'signatures/foo.py:Foo' into ('foo', 'Foo')."""
|
|
76
|
+
return _impl_path_parts(signature)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _activity_timeout(node: Node, default: str) -> str:
|
|
80
|
+
return node.timeout or default
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _retry_policy_args(node: Node) -> str:
|
|
84
|
+
"""Render a Temporal RetryPolicy from the node's retry config.
|
|
85
|
+
|
|
86
|
+
Returns a string like ``RetryPolicy(maximum_attempts=5, ...)`` or an
|
|
87
|
+
empty string if no retry policy is specified.
|
|
88
|
+
"""
|
|
89
|
+
if not node.retry:
|
|
90
|
+
return ""
|
|
91
|
+
parts = [f"maximum_attempts={node.retry.max + 1}"] # Temporal counts initial + retries
|
|
92
|
+
backoff = node.retry.backoff
|
|
93
|
+
if backoff == "exponential":
|
|
94
|
+
parts.append("backoff_coefficient=2.0")
|
|
95
|
+
elif backoff == "linear":
|
|
96
|
+
parts.append("backoff_coefficient=1.0")
|
|
97
|
+
return f"RetryPolicy({', '.join(parts)})"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ───────── Activities emission ─────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _emit_activity_for_pure_or_external(node: Node, cfg: TemporalAdapterConfig) -> str:
|
|
104
|
+
assert node.impl is not None
|
|
105
|
+
module_name, func_name = _impl_path_parts(node.impl)
|
|
106
|
+
|
|
107
|
+
constants_block = ""
|
|
108
|
+
if node.constants:
|
|
109
|
+
constants_block = (
|
|
110
|
+
" # Constants from the IR (extracted from the source skill prose):\n"
|
|
111
|
+
+ "".join(f" # {k} = {v!r}\n" for k, v in node.constants.items())
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
mandatory_marker = ""
|
|
115
|
+
if node.mandatory:
|
|
116
|
+
mandatory_marker = (
|
|
117
|
+
" # MANDATORY: this node was marked mandatory in the source\n"
|
|
118
|
+
" # skill. The workflow always calls it; do not make it conditional.\n"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return textwrap.dedent(
|
|
122
|
+
f'''
|
|
123
|
+
@activity.defn(name="{node.id}")
|
|
124
|
+
async def {node.id}(payload: dict) -> dict:
|
|
125
|
+
"""{node.description.strip().splitlines()[0]}
|
|
126
|
+
|
|
127
|
+
Graduated from MCP tool call → deterministic API call. See
|
|
128
|
+
``{cfg.extracted_module}.{module_name}`` for implementation.
|
|
129
|
+
"""
|
|
130
|
+
'''
|
|
131
|
+
).lstrip("\n") + (
|
|
132
|
+
mandatory_marker
|
|
133
|
+
+ constants_block
|
|
134
|
+
+ f" from {cfg.extracted_module}.{module_name} import {func_name}\n"
|
|
135
|
+
+ f" result = await {func_name}(**payload)\n"
|
|
136
|
+
+ " return _serialize(result)\n"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _emit_activity_for_llm_judge(node: Node, cfg: TemporalAdapterConfig) -> str:
|
|
141
|
+
assert node.signature is not None
|
|
142
|
+
module_name, class_name = _signature_path_parts(node.signature)
|
|
143
|
+
input_class = f"{class_name}Input"
|
|
144
|
+
|
|
145
|
+
return textwrap.dedent(
|
|
146
|
+
f'''
|
|
147
|
+
@activity.defn(name="{node.id}")
|
|
148
|
+
async def {node.id}(payload: dict) -> dict:
|
|
149
|
+
"""{node.description.strip().splitlines()[0]}
|
|
150
|
+
|
|
151
|
+
LLM judge — typed input/output, bounded decision space. The
|
|
152
|
+
non-determinism lives inside this activity, not the workflow.
|
|
153
|
+
"""
|
|
154
|
+
'''
|
|
155
|
+
).lstrip("\n") + (
|
|
156
|
+
f" from {cfg.signatures_module}.{module_name} import (\n"
|
|
157
|
+
f" {class_name},\n"
|
|
158
|
+
f" {input_class},\n"
|
|
159
|
+
f" )\n"
|
|
160
|
+
f" judge = {class_name}()\n"
|
|
161
|
+
f" result = await judge.forward({input_class}(**payload))\n"
|
|
162
|
+
f" return result.model_dump()\n"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _emit_activity_for_agent_loop(node: Node, cfg: TemporalAdapterConfig) -> str:
|
|
167
|
+
"""Emit a stub activity for an agent_loop node.
|
|
168
|
+
|
|
169
|
+
Agent loops require an LLM agent runtime (e.g., the Anthropic SDK
|
|
170
|
+
with bounded iterations). The v0 adapter emits a stub that raises
|
|
171
|
+
NotImplementedError so the workflow at least imports cleanly. Real
|
|
172
|
+
implementations are a per-project decision and should call out to
|
|
173
|
+
whatever agent harness the project already uses.
|
|
174
|
+
"""
|
|
175
|
+
tools_doc = "\n".join(f" # - {t}" for t in (node.tools or []))
|
|
176
|
+
sub_nodes_doc = ""
|
|
177
|
+
if node.loop_body:
|
|
178
|
+
sub_nodes_doc = (
|
|
179
|
+
" # Loop body sub-nodes (call these for each iteration):\n"
|
|
180
|
+
+ "\n".join(f" # - {sn}" for sn in node.loop_body)
|
|
181
|
+
+ "\n"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
textwrap.dedent(
|
|
186
|
+
f'''
|
|
187
|
+
@activity.defn(name="{node.id}")
|
|
188
|
+
async def {node.id}(payload: dict) -> dict:
|
|
189
|
+
"""{node.description.strip().splitlines()[0]}
|
|
190
|
+
|
|
191
|
+
STUB — agent loops require an LLM agent runtime. Implement
|
|
192
|
+
this against the project's preferred agent harness (Anthropic
|
|
193
|
+
Agent SDK, OpenAI Agents SDK, LangGraph, etc.).
|
|
194
|
+
"""
|
|
195
|
+
# Tools the agent should be allowed to call inside the loop:
|
|
196
|
+
'''
|
|
197
|
+
).lstrip("\n")
|
|
198
|
+
+ (tools_doc + "\n" if tools_doc else "")
|
|
199
|
+
+ sub_nodes_doc
|
|
200
|
+
+ (
|
|
201
|
+
f' raise NotImplementedError("agent_loop activity {node.id!r}: '
|
|
202
|
+
'requires an agent runtime")\n'
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def emit_activities(pipeline: Pipeline, cfg: TemporalAdapterConfig | None = None) -> str:
|
|
208
|
+
"""Render the activities.py source for a pipeline."""
|
|
209
|
+
cfg = cfg or TemporalAdapterConfig()
|
|
210
|
+
|
|
211
|
+
parts: list[str] = []
|
|
212
|
+
|
|
213
|
+
parts.append(
|
|
214
|
+
textwrap.dedent(
|
|
215
|
+
f'''
|
|
216
|
+
"""Auto-generated by rote.adapters.temporal.
|
|
217
|
+
|
|
218
|
+
Pipeline: {pipeline.name} v{pipeline.version}
|
|
219
|
+
Source skill: {pipeline.source_skill or "unknown"}
|
|
220
|
+
|
|
221
|
+
DO NOT EDIT BY HAND. Re-run ``rote emit`` to regenerate.
|
|
222
|
+
|
|
223
|
+
Architecture note: every external_call activity in this file
|
|
224
|
+
wraps a deterministic Python function from the ``extracted/``
|
|
225
|
+
package. None of them call MCP tools at runtime — the MCP
|
|
226
|
+
tool calls from the source skill have been graduated into
|
|
227
|
+
direct API calls during the rote emission step.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
from __future__ import annotations
|
|
231
|
+
|
|
232
|
+
from typing import Any
|
|
233
|
+
|
|
234
|
+
from temporalio import activity
|
|
235
|
+
'''
|
|
236
|
+
).lstrip("\n")
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
parts.append(
|
|
240
|
+
textwrap.dedent(
|
|
241
|
+
'''
|
|
242
|
+
|
|
243
|
+
def _serialize(obj: Any) -> Any:
|
|
244
|
+
"""Convert pydantic models / dataclasses / lists to plain dicts.
|
|
245
|
+
|
|
246
|
+
Activities return JSON-serializable payloads so the workflow
|
|
247
|
+
doesn't carry typed objects through Temporal's history.
|
|
248
|
+
"""
|
|
249
|
+
if hasattr(obj, "model_dump"):
|
|
250
|
+
return obj.model_dump()
|
|
251
|
+
if isinstance(obj, list):
|
|
252
|
+
return [_serialize(x) for x in obj]
|
|
253
|
+
if isinstance(obj, tuple):
|
|
254
|
+
return [_serialize(x) for x in obj]
|
|
255
|
+
if isinstance(obj, dict):
|
|
256
|
+
return {k: _serialize(v) for k, v in obj.items()}
|
|
257
|
+
return obj
|
|
258
|
+
'''
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Emit one activity per non-HITL node (including loop_body sub-nodes
|
|
263
|
+
# so they can be tested in isolation).
|
|
264
|
+
for node in pipeline.nodes:
|
|
265
|
+
if node.kind is NodeKind.HITL_GATE:
|
|
266
|
+
continue
|
|
267
|
+
parts.append("\n")
|
|
268
|
+
if node.kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
|
|
269
|
+
parts.append(_emit_activity_for_pure_or_external(node, cfg))
|
|
270
|
+
elif node.kind is NodeKind.LLM_JUDGE:
|
|
271
|
+
parts.append(_emit_activity_for_llm_judge(node, cfg))
|
|
272
|
+
elif node.kind is NodeKind.AGENT_LOOP:
|
|
273
|
+
parts.append(_emit_activity_for_agent_loop(node, cfg))
|
|
274
|
+
|
|
275
|
+
return "".join(parts)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ───────── Workflow emission ─────────
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _emit_signal_handlers(pipeline: Pipeline) -> str:
|
|
282
|
+
"""Emit __init__ + signal handlers for every HITL gate.
|
|
283
|
+
|
|
284
|
+
The output is indented for class-body inclusion (4-space prefix on
|
|
285
|
+
every line). The caller concatenates this directly under the class
|
|
286
|
+
header.
|
|
287
|
+
"""
|
|
288
|
+
gates = [n for n in pipeline.nodes if n.kind is NodeKind.HITL_GATE]
|
|
289
|
+
if not gates:
|
|
290
|
+
return " def __init__(self) -> None:\n pass\n\n"
|
|
291
|
+
|
|
292
|
+
init_lines = [" def __init__(self) -> None:"]
|
|
293
|
+
for gate in gates:
|
|
294
|
+
init_lines.append(f" self._{gate.signal}_payload: dict | None = None")
|
|
295
|
+
init_block = "\n".join(init_lines) + "\n\n"
|
|
296
|
+
|
|
297
|
+
handler_blocks: list[str] = []
|
|
298
|
+
for gate in gates:
|
|
299
|
+
# First describe at module-level indent, then re-indent everything
|
|
300
|
+
# by 4 spaces so the handlers land inside the class body.
|
|
301
|
+
raw = textwrap.dedent(
|
|
302
|
+
f'''
|
|
303
|
+
@workflow.signal(name="{gate.signal}")
|
|
304
|
+
def {gate.signal}(self, payload: dict) -> None:
|
|
305
|
+
"""{gate.description.strip().splitlines()[0]}"""
|
|
306
|
+
self._{gate.signal}_payload = payload
|
|
307
|
+
'''
|
|
308
|
+
).lstrip("\n")
|
|
309
|
+
handler_blocks.append(textwrap.indent(raw, prefix=" "))
|
|
310
|
+
return init_block + "\n".join(handler_blocks)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _ref_to_python_expr(ref: str) -> str:
|
|
314
|
+
"""Render an ``inputs:`` source reference as a Python expression.
|
|
315
|
+
|
|
316
|
+
The workflow binds the pipeline input to ``pipeline_input`` and each
|
|
317
|
+
node's result to ``<node_id>_result``, so references map directly:
|
|
318
|
+
|
|
319
|
+
| Reference | Expression |
|
|
320
|
+
|----------------------------|---------------------------------|
|
|
321
|
+
| ``pipeline.input`` | ``pipeline_input`` |
|
|
322
|
+
| ``pipeline.input.f`` | ``pipeline_input["f"]`` |
|
|
323
|
+
| ``foo.output`` | ``foo_result`` |
|
|
324
|
+
| ``foo.output.f`` | ``foo_result["f"]`` |
|
|
325
|
+
"""
|
|
326
|
+
parsed = parse_input_ref(ref)
|
|
327
|
+
base = "pipeline_input" if parsed.node_id is None else f"{parsed.node_id}_result"
|
|
328
|
+
if parsed.field is None:
|
|
329
|
+
return base
|
|
330
|
+
return f'{base}["{parsed.field}"]'
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _payload_literal(node: Node, indent: str) -> str:
|
|
334
|
+
"""Render the activity payload dict for a node's data-flow bindings.
|
|
335
|
+
|
|
336
|
+
Nodes without ``inputs`` keep the empty payload (back-compat).
|
|
337
|
+
``indent`` is the indentation of the line the literal starts on;
|
|
338
|
+
continuation lines are indented one level deeper.
|
|
339
|
+
"""
|
|
340
|
+
if not node.inputs:
|
|
341
|
+
return "{}"
|
|
342
|
+
inner = indent + " "
|
|
343
|
+
lines = ["{"]
|
|
344
|
+
for param, ref in node.inputs.items():
|
|
345
|
+
lines.append(f'{inner}"{param}": {_ref_to_python_expr(ref)},')
|
|
346
|
+
lines.append(indent + "}")
|
|
347
|
+
return "\n".join(lines)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _emit_wave_call(node: Node, cfg: TemporalAdapterConfig) -> str:
|
|
351
|
+
"""Emit a multi-line call to ``workflow.execute_activity`` for one node.
|
|
352
|
+
|
|
353
|
+
Produces something like::
|
|
354
|
+
|
|
355
|
+
foo_result = await workflow.execute_activity(
|
|
356
|
+
"foo",
|
|
357
|
+
{
|
|
358
|
+
"brief": pipeline_input,
|
|
359
|
+
"intel": target_research_result,
|
|
360
|
+
},
|
|
361
|
+
start_to_close_timeout=timedelta(minutes=...),
|
|
362
|
+
retry_policy=RetryPolicy(...),
|
|
363
|
+
)
|
|
364
|
+
"""
|
|
365
|
+
if node.kind is NodeKind.HITL_GATE:
|
|
366
|
+
# HITL gates are not activities — they're handled separately.
|
|
367
|
+
return ""
|
|
368
|
+
|
|
369
|
+
timeout = _activity_timeout(node, cfg.default_activity_timeout)
|
|
370
|
+
retry = _retry_policy_args(node)
|
|
371
|
+
payload = _payload_literal(node, indent=" " * 12)
|
|
372
|
+
|
|
373
|
+
lines = [
|
|
374
|
+
f" {node.id}_result = await workflow.execute_activity(",
|
|
375
|
+
f' "{node.id}",',
|
|
376
|
+
f" {payload},",
|
|
377
|
+
f' start_to_close_timeout=timedelta(minutes=_parse_minutes("{timeout}")),',
|
|
378
|
+
]
|
|
379
|
+
if retry:
|
|
380
|
+
lines.append(f" retry_policy={retry},")
|
|
381
|
+
lines.append(" )")
|
|
382
|
+
return "\n".join(lines) + "\n"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _emit_hitl_block(node: Node) -> str:
|
|
386
|
+
assert node.kind is NodeKind.HITL_GATE
|
|
387
|
+
assert node.signal is not None
|
|
388
|
+
return (
|
|
389
|
+
f" # ─── HITL gate: {node.id} ───\n"
|
|
390
|
+
f" # Workflow suspends here until the {node.signal!r} signal\n"
|
|
391
|
+
f" # arrives. Survives worker restarts; resumes immediately\n"
|
|
392
|
+
f" # when the signal fires.\n"
|
|
393
|
+
f" await workflow.wait_condition(\n"
|
|
394
|
+
f" lambda: self._{node.signal}_payload is not None\n"
|
|
395
|
+
f" )\n"
|
|
396
|
+
f" {node.id}_result = self._{node.signal}_payload\n"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _emit_workflow_run(pipeline: Pipeline, cfg: TemporalAdapterConfig) -> str:
|
|
401
|
+
waves = _execution_waves(pipeline)
|
|
402
|
+
|
|
403
|
+
body_lines: list[str] = [
|
|
404
|
+
" @workflow.run",
|
|
405
|
+
" async def run(self, pipeline_input: dict) -> dict:",
|
|
406
|
+
]
|
|
407
|
+
desc = pipeline.description.strip().splitlines()[0] if pipeline.description else pipeline.name
|
|
408
|
+
body_lines.append(f' """{desc}"""')
|
|
409
|
+
|
|
410
|
+
# Node ids whose results are bound by the time each wave starts —
|
|
411
|
+
# used to reject inputs that reference a later wave at emit time.
|
|
412
|
+
available: set[str] = set()
|
|
413
|
+
|
|
414
|
+
for wave_idx, wave in enumerate(waves, start=1):
|
|
415
|
+
non_hitl = [n for n in wave if n.kind is not NodeKind.HITL_GATE]
|
|
416
|
+
hitl = [n for n in wave if n.kind is NodeKind.HITL_GATE]
|
|
417
|
+
|
|
418
|
+
for n in non_hitl:
|
|
419
|
+
check_input_refs_available(n, available)
|
|
420
|
+
|
|
421
|
+
body_lines.append("")
|
|
422
|
+
body_lines.append(f" # ─── Wave {wave_idx} ───")
|
|
423
|
+
|
|
424
|
+
if len(non_hitl) == 1:
|
|
425
|
+
body_lines.append(_emit_wave_call(non_hitl[0], cfg).rstrip("\n"))
|
|
426
|
+
elif len(non_hitl) > 1:
|
|
427
|
+
# Parallel via asyncio.gather
|
|
428
|
+
body_lines.append(" (")
|
|
429
|
+
for n in non_hitl:
|
|
430
|
+
body_lines.append(f" {n.id}_result,")
|
|
431
|
+
body_lines.append(" ) = await asyncio.gather(")
|
|
432
|
+
for n in non_hitl:
|
|
433
|
+
timeout = _activity_timeout(n, cfg.default_activity_timeout)
|
|
434
|
+
payload = _payload_literal(n, indent=" " * 16)
|
|
435
|
+
timeout_line = (
|
|
436
|
+
" start_to_close_timeout="
|
|
437
|
+
f'timedelta(minutes=_parse_minutes("{timeout}")),\n'
|
|
438
|
+
)
|
|
439
|
+
body_lines.append(
|
|
440
|
+
f" workflow.execute_activity(\n"
|
|
441
|
+
f' "{n.id}",\n'
|
|
442
|
+
f" {payload},\n" + timeout_line + " ),"
|
|
443
|
+
)
|
|
444
|
+
body_lines.append(" )")
|
|
445
|
+
|
|
446
|
+
for h in hitl:
|
|
447
|
+
body_lines.append(_emit_hitl_block(h).rstrip("\n"))
|
|
448
|
+
|
|
449
|
+
available.update(n.id for n in wave)
|
|
450
|
+
|
|
451
|
+
body_lines.append("")
|
|
452
|
+
body_lines.append(" return {")
|
|
453
|
+
for exit_id in pipeline.exit_nodes:
|
|
454
|
+
body_lines.append(f' "{exit_id}": {exit_id}_result,')
|
|
455
|
+
body_lines.append(" }")
|
|
456
|
+
|
|
457
|
+
return "\n".join(body_lines) + "\n"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def emit_workflow(pipeline: Pipeline, cfg: TemporalAdapterConfig | None = None) -> str:
|
|
461
|
+
"""Render the workflow.py source for a pipeline."""
|
|
462
|
+
cfg = cfg or TemporalAdapterConfig()
|
|
463
|
+
|
|
464
|
+
pascal_name = _to_pascal_case(pipeline.name)
|
|
465
|
+
workflow_hash = _pipeline_hash(pipeline)
|
|
466
|
+
versioned_workflow_name = f"{pascal_name}_{workflow_hash}"
|
|
467
|
+
class_name = f"{pascal_name}Workflow"
|
|
468
|
+
|
|
469
|
+
parts: list[str] = []
|
|
470
|
+
|
|
471
|
+
parts.append(
|
|
472
|
+
textwrap.dedent(
|
|
473
|
+
f'''
|
|
474
|
+
"""Auto-generated by rote.adapters.temporal.
|
|
475
|
+
|
|
476
|
+
Pipeline: {pipeline.name} v{pipeline.version}
|
|
477
|
+
Source skill: {pipeline.source_skill or "unknown"}
|
|
478
|
+
Pipeline hash: {workflow_hash}
|
|
479
|
+
|
|
480
|
+
DO NOT EDIT BY HAND. Re-run ``rote emit`` to regenerate.
|
|
481
|
+
|
|
482
|
+
Workflow versioning: the registered workflow type name
|
|
483
|
+
includes a hash of the pipeline so regenerated workflows
|
|
484
|
+
become a new type. In-flight workflows on the old type
|
|
485
|
+
continue running the old code; new workflows use the new
|
|
486
|
+
code. This is the simplest way to avoid Temporal's
|
|
487
|
+
determinism errors when regenerating from a changed skill.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
from __future__ import annotations
|
|
491
|
+
|
|
492
|
+
import asyncio
|
|
493
|
+
from datetime import timedelta
|
|
494
|
+
|
|
495
|
+
from temporalio import workflow
|
|
496
|
+
from temporalio.common import RetryPolicy
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _parse_minutes(s: str) -> float:
|
|
500
|
+
"""Parse a duration string like '5m', '30s', '7d' into minutes."""
|
|
501
|
+
s = s.strip()
|
|
502
|
+
if s.endswith("ms"):
|
|
503
|
+
return float(s[:-2]) / 60_000
|
|
504
|
+
if s.endswith("s"):
|
|
505
|
+
return float(s[:-1]) / 60
|
|
506
|
+
if s.endswith("m"):
|
|
507
|
+
return float(s[:-1])
|
|
508
|
+
if s.endswith("h"):
|
|
509
|
+
return float(s[:-1]) * 60
|
|
510
|
+
if s.endswith("d"):
|
|
511
|
+
return float(s[:-1]) * 60 * 24
|
|
512
|
+
return float(s)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@workflow.defn(name="{versioned_workflow_name}")
|
|
516
|
+
class {class_name}:
|
|
517
|
+
"""Graduated workflow for {pipeline.name}."""
|
|
518
|
+
|
|
519
|
+
'''
|
|
520
|
+
).lstrip("\n")
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
parts.append(_emit_signal_handlers(pipeline))
|
|
524
|
+
parts.append("\n")
|
|
525
|
+
parts.append(_emit_workflow_run(pipeline, cfg))
|
|
526
|
+
|
|
527
|
+
# Re-indent the workflow run + signal handlers under the class
|
|
528
|
+
# (they were emitted at module level above for readability).
|
|
529
|
+
return "".join(parts)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ───────── TemporalAdapter facade ─────────
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class TemporalAdapter:
|
|
536
|
+
"""Facade that emits a Temporal workflow + activities pair from a Pipeline."""
|
|
537
|
+
|
|
538
|
+
def __init__(self, config: TemporalAdapterConfig | None = None) -> None:
|
|
539
|
+
self.config = config or TemporalAdapterConfig()
|
|
540
|
+
|
|
541
|
+
def emit_activities(self, pipeline: Pipeline) -> str:
|
|
542
|
+
return emit_activities(pipeline, self.config)
|
|
543
|
+
|
|
544
|
+
def emit_workflow(self, pipeline: Pipeline) -> str:
|
|
545
|
+
return emit_workflow(pipeline, self.config)
|
|
546
|
+
|
|
547
|
+
def emit(self, pipeline: Pipeline, output_dir: str | Path) -> dict[str, Path]:
|
|
548
|
+
"""Write activities.py + workflow.py + __init__.py into output_dir."""
|
|
549
|
+
out = Path(output_dir)
|
|
550
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
551
|
+
|
|
552
|
+
activities_path = out / "activities.py"
|
|
553
|
+
workflow_path = out / "workflow.py"
|
|
554
|
+
init_path = out / "__init__.py"
|
|
555
|
+
|
|
556
|
+
activities_path.write_text(self.emit_activities(pipeline), encoding="utf-8")
|
|
557
|
+
workflow_path.write_text(self.emit_workflow(pipeline), encoding="utf-8")
|
|
558
|
+
if not init_path.exists():
|
|
559
|
+
init_path.write_text(
|
|
560
|
+
f'"""Emitted Temporal artifacts for {pipeline.name}."""\n',
|
|
561
|
+
encoding="utf-8",
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
"activities": activities_path,
|
|
566
|
+
"workflow": workflow_path,
|
|
567
|
+
"__init__": init_path,
|
|
568
|
+
}
|