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,978 @@
|
|
|
1
|
+
"""Cloudflare Workflows adapter — emits a TypeScript ``WorkflowEntrypoint``.
|
|
2
|
+
|
|
3
|
+
Layer 3 of rote: takes a validated :class:`rote.ir.Pipeline` and emits a
|
|
4
|
+
deployable Cloudflare Workflow as TypeScript. The output directory is
|
|
5
|
+
``wrangler deploy``-ready: a workflow class file, a fetch handler, typed
|
|
6
|
+
LLM signatures backed by Zod + the Anthropic SDK, stubs for deterministic
|
|
7
|
+
extracted modules, and the supporting ``wrangler.jsonc`` / ``package.json``
|
|
8
|
+
/ ``tsconfig.json``.
|
|
9
|
+
|
|
10
|
+
Two key design choices vs. the Temporal adapter:
|
|
11
|
+
|
|
12
|
+
* **Single workflow class, not workflow + activities.** Cloudflare's
|
|
13
|
+
programming model is a class extending ``WorkflowEntrypoint`` whose
|
|
14
|
+
``run(event, step)`` calls ``step.do(...)`` for each unit of work. There
|
|
15
|
+
is no separate "activity" registration.
|
|
16
|
+
|
|
17
|
+
* **No BAML runtime in emitted code.** BAML's TS client requires a Rust
|
|
18
|
+
native binary that does not run on Workers (V8 isolates). Signatures
|
|
19
|
+
are emitted as Zod schemas + direct Anthropic SDK calls with
|
|
20
|
+
structured-output tool use. The IR's ``signature_spec`` (JSON Schema +
|
|
21
|
+
prompt) is the cross-language source of truth.
|
|
22
|
+
|
|
23
|
+
The emitted code never imports MCP runtime — same architectural invariant
|
|
24
|
+
as the Temporal adapter, enforced by AST tests.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import textwrap
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from rote.adapters._common import (
|
|
37
|
+
_execution_waves,
|
|
38
|
+
_pipeline_hash,
|
|
39
|
+
_to_camel_case,
|
|
40
|
+
_to_pascal_case,
|
|
41
|
+
check_input_refs_available,
|
|
42
|
+
)
|
|
43
|
+
from rote.adapters._common import (
|
|
44
|
+
ir_duration_to_human as _ir_duration_to_cf,
|
|
45
|
+
)
|
|
46
|
+
from rote.ir import LLMSignature, Node, NodeKind, Pipeline, parse_input_ref
|
|
47
|
+
|
|
48
|
+
# ───────── Adapter configuration ─────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class CloudflareAdapterConfig:
|
|
53
|
+
"""Per-emission knobs for the Cloudflare adapter.
|
|
54
|
+
|
|
55
|
+
Defaults work out-of-the-box for the BDR example. Production users
|
|
56
|
+
will typically override ``workflow_binding`` and the model defaults.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
workflow_binding: str = "PIPELINE"
|
|
60
|
+
compatibility_date: str = "2026-04-25"
|
|
61
|
+
anthropic_default_model: str = "claude-sonnet-4-6"
|
|
62
|
+
openai_default_model: str = "gpt-4.1"
|
|
63
|
+
# Defaults use IR shorthand (5m / 7d) so they round-trip through
|
|
64
|
+
# ``_ir_duration_to_cf`` without re-conversion.
|
|
65
|
+
default_step_timeout: str = "10m"
|
|
66
|
+
default_hitl_timeout: str = "7d"
|
|
67
|
+
default_step_retry_delay: str = "5s"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ───────── Misc helpers ─────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SIGNAL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
74
|
+
_VALID_BACKOFFS = {"constant", "linear", "exponential"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_signal_name(name: str, node_id: str) -> None:
|
|
78
|
+
"""Cloudflare's waitForEvent ``type`` field accepts only [A-Za-z0-9_-].
|
|
79
|
+
|
|
80
|
+
The IR allows arbitrary signal strings; the adapter enforces the
|
|
81
|
+
Cloudflare constraint at emit time so the user gets a clear error
|
|
82
|
+
instead of a runtime ``workflow.invalid_event_type`` from Cloudflare.
|
|
83
|
+
"""
|
|
84
|
+
if not _SIGNAL_NAME_RE.fullmatch(name):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Cloudflare adapter: hitl_gate {node_id!r} signal {name!r} "
|
|
87
|
+
f"contains invalid characters. Cloudflare waitForEvent types "
|
|
88
|
+
f"must match {_SIGNAL_NAME_RE.pattern!r} (no dots, spaces, etc.)."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _step_config_literal(node: Node, cfg: CloudflareAdapterConfig) -> str:
|
|
93
|
+
"""Render a TS object literal for a step.do(...) config arg.
|
|
94
|
+
|
|
95
|
+
Maps the IR's ``RetryPolicy`` onto Cloudflare's ``WorkflowStepConfig``:
|
|
96
|
+
|
|
97
|
+
| IR field | Cloudflare field |
|
|
98
|
+
|----------------|-------------------------|
|
|
99
|
+
| ``max`` | ``retries.limit`` |
|
|
100
|
+
| ``backoff`` | ``retries.backoff`` |
|
|
101
|
+
| (none) | ``retries.delay`` (default from cfg) |
|
|
102
|
+
| ``timeout`` | ``timeout`` |
|
|
103
|
+
|
|
104
|
+
The IR ``backoff`` enum values match Cloudflare's exactly, so the
|
|
105
|
+
mapping is lossless — unlike Temporal where ``backoff_coefficient``
|
|
106
|
+
is numeric.
|
|
107
|
+
"""
|
|
108
|
+
timeout = node.timeout or cfg.default_step_timeout
|
|
109
|
+
parts = [f"timeout: {json.dumps(_ir_duration_to_cf(timeout))}"]
|
|
110
|
+
if node.retry:
|
|
111
|
+
backoff = node.retry.backoff if node.retry.backoff in _VALID_BACKOFFS else "exponential"
|
|
112
|
+
retry_delay = _ir_duration_to_cf(cfg.default_step_retry_delay)
|
|
113
|
+
retry_obj = (
|
|
114
|
+
"retries: { "
|
|
115
|
+
f"limit: {node.retry.max}, "
|
|
116
|
+
f"delay: {json.dumps(retry_delay)}, "
|
|
117
|
+
f"backoff: {json.dumps(backoff)}"
|
|
118
|
+
" }"
|
|
119
|
+
)
|
|
120
|
+
parts.insert(0, retry_obj)
|
|
121
|
+
return "{ " + ", ".join(parts) + " }"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ───────── JSON Schema → Zod ─────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_refs(schema: Any, defs: dict[str, Any]) -> Any:
|
|
128
|
+
"""Recursively inline ``$ref`` references using a pre-extracted ``$defs`` map.
|
|
129
|
+
|
|
130
|
+
Pydantic emits nested types as ``$ref: "#/$defs/Name"``. Zod schemas
|
|
131
|
+
are constructed inline so we resolve refs eagerly.
|
|
132
|
+
"""
|
|
133
|
+
if isinstance(schema, dict):
|
|
134
|
+
if "$ref" in schema:
|
|
135
|
+
ref = schema["$ref"]
|
|
136
|
+
if not ref.startswith("#/$defs/"):
|
|
137
|
+
raise ValueError(f"Unsupported $ref form: {ref!r}")
|
|
138
|
+
name = ref[len("#/$defs/") :]
|
|
139
|
+
if name not in defs:
|
|
140
|
+
raise ValueError(f"Unknown $ref target: {name!r}")
|
|
141
|
+
return _resolve_refs(defs[name], defs)
|
|
142
|
+
return {k: _resolve_refs(v, defs) for k, v in schema.items() if k != "$defs"}
|
|
143
|
+
if isinstance(schema, list):
|
|
144
|
+
return [_resolve_refs(x, defs) for x in schema]
|
|
145
|
+
return schema
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _convert_zod(schema: Any, indent: int = 0) -> str:
|
|
149
|
+
"""Convert a (resolved, ref-free) JSON Schema fragment to a Zod expression."""
|
|
150
|
+
if not isinstance(schema, dict):
|
|
151
|
+
raise ValueError(f"Expected schema dict, got {type(schema).__name__}: {schema!r}")
|
|
152
|
+
|
|
153
|
+
# Nullable / unions via anyOf or oneOf
|
|
154
|
+
for union_key in ("anyOf", "oneOf"):
|
|
155
|
+
if union_key in schema:
|
|
156
|
+
variants = schema[union_key]
|
|
157
|
+
non_null = [
|
|
158
|
+
v for v in variants if not (isinstance(v, dict) and v.get("type") == "null")
|
|
159
|
+
]
|
|
160
|
+
has_null = len(non_null) < len(variants)
|
|
161
|
+
if len(non_null) == 1:
|
|
162
|
+
inner = _convert_zod(non_null[0], indent)
|
|
163
|
+
return f"{inner}.nullable()" if has_null else inner
|
|
164
|
+
parts = [_convert_zod(v, indent) for v in non_null]
|
|
165
|
+
union = "z.union([" + ", ".join(parts) + "])"
|
|
166
|
+
return f"{union}.nullable()" if has_null else union
|
|
167
|
+
|
|
168
|
+
if "enum" in schema:
|
|
169
|
+
values = schema["enum"]
|
|
170
|
+
if all(isinstance(v, str) for v in values):
|
|
171
|
+
return "z.enum([" + ", ".join(json.dumps(v) for v in values) + "])"
|
|
172
|
+
# Mixed-type enum
|
|
173
|
+
literals = [f"z.literal({json.dumps(v)})" for v in values]
|
|
174
|
+
return "z.union([" + ", ".join(literals) + "])"
|
|
175
|
+
|
|
176
|
+
schema_type = schema.get("type")
|
|
177
|
+
|
|
178
|
+
if schema_type == "object":
|
|
179
|
+
props = schema.get("properties", {})
|
|
180
|
+
required = set(schema.get("required", []))
|
|
181
|
+
if not props:
|
|
182
|
+
return "z.object({}).strict()"
|
|
183
|
+
pad = " " * (indent + 1)
|
|
184
|
+
outer = " " * indent
|
|
185
|
+
lines = []
|
|
186
|
+
for name, prop_schema in props.items():
|
|
187
|
+
inner = _convert_zod(prop_schema, indent + 1)
|
|
188
|
+
if name not in required:
|
|
189
|
+
inner = f"{inner}.optional()"
|
|
190
|
+
lines.append(f"{pad}{json.dumps(name)}: {inner},")
|
|
191
|
+
body = "\n".join(lines)
|
|
192
|
+
return f"z.object({{\n{body}\n{outer}}}).strict()"
|
|
193
|
+
|
|
194
|
+
if schema_type == "array":
|
|
195
|
+
items = schema.get("items", {})
|
|
196
|
+
return f"z.array({_convert_zod(items, indent)})"
|
|
197
|
+
|
|
198
|
+
if schema_type == "string":
|
|
199
|
+
return "z.string()"
|
|
200
|
+
if schema_type == "integer":
|
|
201
|
+
return "z.number().int()"
|
|
202
|
+
if schema_type == "number":
|
|
203
|
+
return "z.number()"
|
|
204
|
+
if schema_type == "boolean":
|
|
205
|
+
return "z.boolean()"
|
|
206
|
+
if schema_type == "null":
|
|
207
|
+
return "z.null()"
|
|
208
|
+
|
|
209
|
+
# Permissive fallback for under-specified schemas.
|
|
210
|
+
return "z.unknown()"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def json_schema_to_zod(schema: dict[str, Any], indent: int = 0) -> str:
|
|
214
|
+
"""Public entry point: convert a (possibly ref-laden) JSON Schema to Zod source."""
|
|
215
|
+
defs = schema.get("$defs", {})
|
|
216
|
+
resolved = _resolve_refs(schema, defs)
|
|
217
|
+
return _convert_zod(resolved, indent)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ───────── Workflow.ts emission ─────────
|
|
221
|
+
|
|
222
|
+
_TS_IDENT_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _ref_to_ts_expr(ref: str) -> str:
|
|
226
|
+
"""Render an ``inputs:`` source reference as a TypeScript expression.
|
|
227
|
+
|
|
228
|
+
The workflow binds the instance params to ``pipelineInput`` and each
|
|
229
|
+
node's result to ``<node_id>_result``:
|
|
230
|
+
|
|
231
|
+
| Reference | Expression |
|
|
232
|
+
|----------------------------|-----------------------------------------------------|
|
|
233
|
+
| ``pipeline.input`` | ``pipelineInput`` |
|
|
234
|
+
| ``pipeline.input.f`` | ``pipelineInput["f"]`` |
|
|
235
|
+
| ``foo.output`` | ``foo_result`` |
|
|
236
|
+
| ``foo.output.f`` | ``(foo_result as Record<string, unknown>)["f"]`` |
|
|
237
|
+
|
|
238
|
+
Node-output field access goes through a Record cast because
|
|
239
|
+
``step.do``'s ``Rpc.Serializable`` constraint widens inferred result
|
|
240
|
+
types to its constraint union (verified against
|
|
241
|
+
@cloudflare/workers-types via the tsc e2e test) — direct indexing
|
|
242
|
+
doesn't compile. The cast also stays valid for whatever concrete
|
|
243
|
+
return type the user gives a stub later.
|
|
244
|
+
"""
|
|
245
|
+
parsed = parse_input_ref(ref)
|
|
246
|
+
if parsed.node_id is None:
|
|
247
|
+
if parsed.field is None:
|
|
248
|
+
return "pipelineInput"
|
|
249
|
+
return f"pipelineInput[{json.dumps(parsed.field)}]"
|
|
250
|
+
base = f"{parsed.node_id}_result"
|
|
251
|
+
if parsed.field is None:
|
|
252
|
+
return base
|
|
253
|
+
return f"({base} as Record<string, unknown>)[{json.dumps(parsed.field)}]"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _payload_ts_literal(node: Node, indent: str) -> str:
|
|
257
|
+
"""Render the step payload object for a node's data-flow bindings.
|
|
258
|
+
|
|
259
|
+
Nodes without ``inputs`` keep the empty payload (back-compat).
|
|
260
|
+
``indent`` is the indentation of the line the literal starts on;
|
|
261
|
+
entries are indented one level deeper.
|
|
262
|
+
"""
|
|
263
|
+
if not node.inputs:
|
|
264
|
+
return "{}"
|
|
265
|
+
inner = indent + " "
|
|
266
|
+
lines = ["{"]
|
|
267
|
+
for param, ref in node.inputs.items():
|
|
268
|
+
key = param if _TS_IDENT_RE.fullmatch(param) else json.dumps(param)
|
|
269
|
+
lines.append(f"{inner}{key}: {_ref_to_ts_expr(ref)},")
|
|
270
|
+
lines.append(indent + "}")
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _emit_step_call(node: Node, cfg: CloudflareAdapterConfig, *, pass_env: bool) -> str:
|
|
275
|
+
fn_name = _to_camel_case(node.id)
|
|
276
|
+
config = _step_config_literal(node, cfg)
|
|
277
|
+
payload = _payload_ts_literal(node, indent=" " * 12)
|
|
278
|
+
args = f"{payload}, this.env" if pass_env else payload
|
|
279
|
+
return (
|
|
280
|
+
f" const {node.id}_result = await step.do(\n"
|
|
281
|
+
f" {json.dumps(node.id)},\n"
|
|
282
|
+
f" {config},\n"
|
|
283
|
+
f" async () => {fn_name}({args}),\n"
|
|
284
|
+
f" );\n"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _emit_step_call_pure_or_external(node: Node, cfg: CloudflareAdapterConfig) -> str:
|
|
289
|
+
return _emit_step_call(node, cfg, pass_env=False)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _emit_step_call_llm_judge(node: Node, cfg: CloudflareAdapterConfig) -> str:
|
|
293
|
+
return _emit_step_call(node, cfg, pass_env=True)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _emit_step_call_agent_loop(node: Node, cfg: CloudflareAdapterConfig) -> str:
|
|
297
|
+
return _emit_step_call(node, cfg, pass_env=True)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _emit_hitl_gate(node: Node, cfg: CloudflareAdapterConfig) -> str:
|
|
301
|
+
assert node.signal is not None
|
|
302
|
+
_validate_signal_name(node.signal, node.id)
|
|
303
|
+
timeout = node.timeout or cfg.default_hitl_timeout
|
|
304
|
+
timeout_cf = _ir_duration_to_cf(timeout)
|
|
305
|
+
return (
|
|
306
|
+
f" // ─── HITL gate: {node.id} ───\n"
|
|
307
|
+
f" // Workflow suspends here until an event of type {node.signal!r}\n"
|
|
308
|
+
f" // arrives. Survives hibernation; events that arrive before this\n"
|
|
309
|
+
f" // line is reached are buffered and delivered when reached.\n"
|
|
310
|
+
f" const {node.id}_event = await step.waitForEvent<any>(\n"
|
|
311
|
+
f" {json.dumps(node.id)},\n"
|
|
312
|
+
f" {{ type: {json.dumps(node.signal)}, timeout: {json.dumps(timeout_cf)} }},\n"
|
|
313
|
+
f" );\n"
|
|
314
|
+
f" const {node.id}_result = {node.id}_event.payload;\n"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _module_imports(pipeline: Pipeline) -> str:
|
|
319
|
+
"""Build the static-import block for the workflow file.
|
|
320
|
+
|
|
321
|
+
One import per non-HITL node, including loop_body sub-nodes. The
|
|
322
|
+
function name is camelCase of the node id; the path is
|
|
323
|
+
``./signatures/<id>`` for llm_judge and ``./extracted/<id>`` otherwise.
|
|
324
|
+
"""
|
|
325
|
+
lines: list[str] = []
|
|
326
|
+
for node in pipeline.nodes:
|
|
327
|
+
if node.kind is NodeKind.HITL_GATE:
|
|
328
|
+
continue
|
|
329
|
+
fn = _to_camel_case(node.id)
|
|
330
|
+
if node.kind is NodeKind.LLM_JUDGE:
|
|
331
|
+
lines.append(f'import {{ {fn} }} from "./signatures/{node.id}";')
|
|
332
|
+
else:
|
|
333
|
+
lines.append(f'import {{ {fn} }} from "./extracted/{node.id}";')
|
|
334
|
+
return "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def emit_workflow(pipeline: Pipeline, cfg: CloudflareAdapterConfig | None = None) -> str:
|
|
338
|
+
"""Render the workflow.ts source for a pipeline."""
|
|
339
|
+
cfg = cfg or CloudflareAdapterConfig()
|
|
340
|
+
pascal = _to_pascal_case(pipeline.name)
|
|
341
|
+
class_name = f"{pascal}Workflow"
|
|
342
|
+
pipeline_h = _pipeline_hash(pipeline)
|
|
343
|
+
waves = _execution_waves(pipeline)
|
|
344
|
+
|
|
345
|
+
header = textwrap.dedent(
|
|
346
|
+
f"""\
|
|
347
|
+
/**
|
|
348
|
+
* Auto-generated by rote.adapters.cloudflare.
|
|
349
|
+
*
|
|
350
|
+
* Pipeline: {pipeline.name} v{pipeline.version}
|
|
351
|
+
* Source skill: {pipeline.source_skill or "unknown"}
|
|
352
|
+
* Pipeline hash: {pipeline_h}
|
|
353
|
+
*
|
|
354
|
+
* DO NOT EDIT BY HAND. Re-run `rote emit --runtime cloudflare` to regenerate.
|
|
355
|
+
*
|
|
356
|
+
* Architecture note: every external_call step in this workflow wraps a
|
|
357
|
+
* deterministic API call from the `extracted/` modules. None of them call
|
|
358
|
+
* MCP tools at runtime — those calls were graduated into direct API calls
|
|
359
|
+
* during the rote emission step.
|
|
360
|
+
*/
|
|
361
|
+
|
|
362
|
+
import {{
|
|
363
|
+
WorkflowEntrypoint,
|
|
364
|
+
WorkflowEvent,
|
|
365
|
+
WorkflowStep,
|
|
366
|
+
}} from "cloudflare:workers";
|
|
367
|
+
|
|
368
|
+
"""
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
imports = _module_imports(pipeline)
|
|
372
|
+
|
|
373
|
+
env_block = textwrap.dedent(
|
|
374
|
+
f"""\
|
|
375
|
+
|
|
376
|
+
export interface Env {{
|
|
377
|
+
ANTHROPIC_API_KEY: string;
|
|
378
|
+
OPENAI_API_KEY?: string;
|
|
379
|
+
{cfg.workflow_binding}: Workflow<Params>;
|
|
380
|
+
}}
|
|
381
|
+
|
|
382
|
+
export type Params = Record<string, unknown>;
|
|
383
|
+
|
|
384
|
+
"""
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
body_lines: list[str] = []
|
|
388
|
+
|
|
389
|
+
# Bind the instance params once when any top-level node's inputs
|
|
390
|
+
# reference the pipeline input.
|
|
391
|
+
wave_nodes = [n for wave in waves for n in wave]
|
|
392
|
+
needs_pipeline_input = any(
|
|
393
|
+
parse_input_ref(ref).node_id is None
|
|
394
|
+
for n in wave_nodes
|
|
395
|
+
if n.inputs
|
|
396
|
+
for ref in n.inputs.values()
|
|
397
|
+
)
|
|
398
|
+
if needs_pipeline_input:
|
|
399
|
+
body_lines.append(" const pipelineInput = event.payload;")
|
|
400
|
+
body_lines.append("")
|
|
401
|
+
|
|
402
|
+
# Node ids whose results are bound by the time each wave starts —
|
|
403
|
+
# used to reject inputs that reference a later wave at emit time.
|
|
404
|
+
available: set[str] = set()
|
|
405
|
+
|
|
406
|
+
for wave_idx, wave in enumerate(waves, start=1):
|
|
407
|
+
body_lines.append(f" // ─── Wave {wave_idx} ───")
|
|
408
|
+
for node in wave:
|
|
409
|
+
if node.kind is NodeKind.HITL_GATE:
|
|
410
|
+
body_lines.append(_emit_hitl_gate(node, cfg).rstrip("\n"))
|
|
411
|
+
else:
|
|
412
|
+
check_input_refs_available(node, available)
|
|
413
|
+
if node.kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
|
|
414
|
+
body_lines.append(_emit_step_call_pure_or_external(node, cfg).rstrip("\n"))
|
|
415
|
+
elif node.kind is NodeKind.LLM_JUDGE:
|
|
416
|
+
body_lines.append(_emit_step_call_llm_judge(node, cfg).rstrip("\n"))
|
|
417
|
+
elif node.kind is NodeKind.AGENT_LOOP:
|
|
418
|
+
body_lines.append(_emit_step_call_agent_loop(node, cfg).rstrip("\n"))
|
|
419
|
+
body_lines.append("")
|
|
420
|
+
available.update(n.id for n in wave)
|
|
421
|
+
|
|
422
|
+
# Build return object. Cast `unknown` step results so the workflow's
|
|
423
|
+
# declared return type stays serializable.
|
|
424
|
+
body_lines.append(" return {")
|
|
425
|
+
for exit_id in pipeline.exit_nodes:
|
|
426
|
+
cast = "as Record<string, unknown>"
|
|
427
|
+
body_lines.append(f" {json.dumps(exit_id)}: {exit_id}_result {cast},")
|
|
428
|
+
body_lines.append(" };")
|
|
429
|
+
|
|
430
|
+
body = "\n".join(body_lines)
|
|
431
|
+
|
|
432
|
+
class_block = (
|
|
433
|
+
f"export class {class_name} extends WorkflowEntrypoint<Env, Params> {{\n"
|
|
434
|
+
f" async run(event: WorkflowEvent<Params>, step: WorkflowStep) {{\n"
|
|
435
|
+
f"{body}\n"
|
|
436
|
+
f" }}\n"
|
|
437
|
+
f"}}\n"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
return header + imports + "\n" + env_block + class_block
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ───────── index.ts emission ─────────
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def emit_index(pipeline: Pipeline, cfg: CloudflareAdapterConfig) -> str:
|
|
447
|
+
pascal = _to_pascal_case(pipeline.name)
|
|
448
|
+
class_name = f"{pascal}Workflow"
|
|
449
|
+
return textwrap.dedent(
|
|
450
|
+
f"""\
|
|
451
|
+
/**
|
|
452
|
+
* Auto-generated by rote.adapters.cloudflare.
|
|
453
|
+
*
|
|
454
|
+
* Default fetch handler. Creates a workflow instance from the request
|
|
455
|
+
* body and returns the instance id + status. Replace with your own
|
|
456
|
+
* trigger surface (cron, queue consumer, etc.) as needed.
|
|
457
|
+
*/
|
|
458
|
+
|
|
459
|
+
import {{ {class_name}, type Env, type Params }} from "./workflow";
|
|
460
|
+
|
|
461
|
+
export {{ {class_name} }};
|
|
462
|
+
|
|
463
|
+
export default {{
|
|
464
|
+
async fetch(req: Request, env: Env): Promise<Response> {{
|
|
465
|
+
const raw = await req.json().catch(() => ({{}}));
|
|
466
|
+
const params = (raw ?? {{}}) as Params;
|
|
467
|
+
const instance = await env.{cfg.workflow_binding}.create({{ params }});
|
|
468
|
+
return Response.json({{
|
|
469
|
+
id: instance.id,
|
|
470
|
+
status: await instance.status(),
|
|
471
|
+
}});
|
|
472
|
+
}},
|
|
473
|
+
}} satisfies ExportedHandler<Env>;
|
|
474
|
+
"""
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ───────── signatures/<id>.ts emission ─────────
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def emit_signature_module(node: Node, cfg: CloudflareAdapterConfig) -> str:
|
|
482
|
+
"""Emit src/signatures/<node_id>.ts for an llm_judge node.
|
|
483
|
+
|
|
484
|
+
The emitted module:
|
|
485
|
+
1. Declares Zod schemas for input + output, derived from the IR's
|
|
486
|
+
``signature_spec`` JSON Schemas.
|
|
487
|
+
2. Exports a typed function that calls the Anthropic Messages API with
|
|
488
|
+
structured-output tool use, validates the response with Zod, and
|
|
489
|
+
returns the typed output.
|
|
490
|
+
|
|
491
|
+
The IR's prompt template is interpolated with the input via a
|
|
492
|
+
minimal ``{{ key }}`` substitution at runtime — kept simple so the
|
|
493
|
+
emitted code has no extra dependencies beyond Zod and the vendor SDK.
|
|
494
|
+
"""
|
|
495
|
+
if node.signature_spec is None:
|
|
496
|
+
raise ValueError(
|
|
497
|
+
f"Cloudflare adapter: llm_judge {node.id!r} requires signature_spec "
|
|
498
|
+
f"(structured form). Path-only signature: {node.signature!r} is "
|
|
499
|
+
f"Temporal-specific and cannot be emitted to TypeScript."
|
|
500
|
+
)
|
|
501
|
+
spec = node.signature_spec
|
|
502
|
+
fn_name = _to_camel_case(node.id)
|
|
503
|
+
pascal = _to_pascal_case(node.id)
|
|
504
|
+
input_schema_zod = json_schema_to_zod(spec.input_schema, indent=0)
|
|
505
|
+
output_schema_zod = json_schema_to_zod(spec.output_schema, indent=0)
|
|
506
|
+
model = spec.model or _default_model_for(spec, cfg)
|
|
507
|
+
temperature = spec.temperature
|
|
508
|
+
|
|
509
|
+
if spec.client == "anthropic":
|
|
510
|
+
return _emit_signature_anthropic(
|
|
511
|
+
node_id=node.id,
|
|
512
|
+
fn_name=fn_name,
|
|
513
|
+
pascal=pascal,
|
|
514
|
+
description=node.description,
|
|
515
|
+
input_zod=input_schema_zod,
|
|
516
|
+
output_zod=output_schema_zod,
|
|
517
|
+
output_schema_json=json.dumps(spec.output_schema, indent=2),
|
|
518
|
+
prompt_template=spec.prompt,
|
|
519
|
+
model=model,
|
|
520
|
+
temperature=temperature,
|
|
521
|
+
)
|
|
522
|
+
if spec.client == "openai":
|
|
523
|
+
return _emit_signature_openai(
|
|
524
|
+
node_id=node.id,
|
|
525
|
+
fn_name=fn_name,
|
|
526
|
+
pascal=pascal,
|
|
527
|
+
description=node.description,
|
|
528
|
+
input_zod=input_schema_zod,
|
|
529
|
+
output_zod=output_schema_zod,
|
|
530
|
+
output_schema_json=json.dumps(spec.output_schema, indent=2),
|
|
531
|
+
prompt_template=spec.prompt,
|
|
532
|
+
model=model,
|
|
533
|
+
temperature=temperature,
|
|
534
|
+
)
|
|
535
|
+
raise ValueError(f"Unsupported LLM client: {spec.client!r}")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _default_model_for(spec: LLMSignature, cfg: CloudflareAdapterConfig) -> str:
|
|
539
|
+
if spec.client == "openai":
|
|
540
|
+
return cfg.openai_default_model
|
|
541
|
+
return cfg.anthropic_default_model
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
_INTERPOLATE_HELPER = """\
|
|
545
|
+
function interpolate(template: string, vars: Record<string, unknown>): string {
|
|
546
|
+
return template.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_match, key: string) => {
|
|
547
|
+
const value = key.split(".").reduce<unknown>(
|
|
548
|
+
(acc, part) =>
|
|
549
|
+
acc != null && typeof acc === "object"
|
|
550
|
+
? (acc as Record<string, unknown>)[part]
|
|
551
|
+
: undefined,
|
|
552
|
+
vars,
|
|
553
|
+
);
|
|
554
|
+
if (value === undefined) return "";
|
|
555
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _emit_signature_anthropic(
|
|
562
|
+
*,
|
|
563
|
+
node_id: str,
|
|
564
|
+
fn_name: str,
|
|
565
|
+
pascal: str,
|
|
566
|
+
description: str,
|
|
567
|
+
input_zod: str,
|
|
568
|
+
output_zod: str,
|
|
569
|
+
output_schema_json: str,
|
|
570
|
+
prompt_template: str,
|
|
571
|
+
model: str,
|
|
572
|
+
temperature: float | None,
|
|
573
|
+
) -> str:
|
|
574
|
+
desc_first = description.strip().splitlines()[0] if description else node_id
|
|
575
|
+
temp_line = f" temperature: {temperature},\n" if temperature is not None else ""
|
|
576
|
+
quoted_id = json.dumps(node_id)
|
|
577
|
+
quoted_desc = json.dumps(desc_first)
|
|
578
|
+
quoted_model = json.dumps(model)
|
|
579
|
+
quoted_prompt = json.dumps(prompt_template)
|
|
580
|
+
parts = [
|
|
581
|
+
"/**",
|
|
582
|
+
f" * Typed LLM signature: {node_id}",
|
|
583
|
+
" *",
|
|
584
|
+
f" * {desc_first}",
|
|
585
|
+
" *",
|
|
586
|
+
" * Auto-generated by rote.adapters.cloudflare from the IR's",
|
|
587
|
+
" * `signature_spec`. The non-determinism lives inside this module;",
|
|
588
|
+
" * the workflow that calls it stays deterministic.",
|
|
589
|
+
" */",
|
|
590
|
+
"",
|
|
591
|
+
'import Anthropic from "@anthropic-ai/sdk";',
|
|
592
|
+
'import { z } from "zod";',
|
|
593
|
+
"",
|
|
594
|
+
f"export const {pascal}Input = {input_zod};",
|
|
595
|
+
f"export type {pascal}Input = z.infer<typeof {pascal}Input>;",
|
|
596
|
+
"",
|
|
597
|
+
f"export const {pascal}Output = {output_zod};",
|
|
598
|
+
f"export type {pascal}Output = z.infer<typeof {pascal}Output>;",
|
|
599
|
+
"",
|
|
600
|
+
f"const PROMPT = {quoted_prompt};",
|
|
601
|
+
"",
|
|
602
|
+
f"const OUTPUT_JSON_SCHEMA = {output_schema_json};",
|
|
603
|
+
"",
|
|
604
|
+
_INTERPOLATE_HELPER.rstrip("\n"),
|
|
605
|
+
"",
|
|
606
|
+
f"export async function {fn_name}(",
|
|
607
|
+
" rawInput: unknown,",
|
|
608
|
+
" env: { ANTHROPIC_API_KEY: string },",
|
|
609
|
+
f"): Promise<{pascal}Output> {{",
|
|
610
|
+
f" const input = {pascal}Input.parse(rawInput);",
|
|
611
|
+
" const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });",
|
|
612
|
+
"",
|
|
613
|
+
" const response = await client.messages.create({",
|
|
614
|
+
f" model: {quoted_model},",
|
|
615
|
+
" max_tokens: 4096,",
|
|
616
|
+
]
|
|
617
|
+
if temp_line:
|
|
618
|
+
parts.append(temp_line.rstrip("\n"))
|
|
619
|
+
schema_cast = 'as { type: "object"; [k: string]: unknown }'
|
|
620
|
+
msg_line = (
|
|
621
|
+
' { role: "user", '
|
|
622
|
+
"content: interpolate(PROMPT, input as Record<string, unknown>) },"
|
|
623
|
+
)
|
|
624
|
+
parts.extend(
|
|
625
|
+
[
|
|
626
|
+
" tools: [",
|
|
627
|
+
" {",
|
|
628
|
+
f" name: {quoted_id},",
|
|
629
|
+
f" description: {quoted_desc},",
|
|
630
|
+
f" input_schema: OUTPUT_JSON_SCHEMA {schema_cast},",
|
|
631
|
+
" },",
|
|
632
|
+
" ],",
|
|
633
|
+
f' tool_choice: {{ type: "tool", name: {quoted_id} }},',
|
|
634
|
+
" messages: [",
|
|
635
|
+
msg_line,
|
|
636
|
+
" ],",
|
|
637
|
+
" });",
|
|
638
|
+
"",
|
|
639
|
+
' const toolUse = response.content.find((b) => b.type === "tool_use");',
|
|
640
|
+
' if (!toolUse || toolUse.type !== "tool_use") {',
|
|
641
|
+
f' throw new Error("LLM did not return a tool_use block for {node_id}");',
|
|
642
|
+
" }",
|
|
643
|
+
f" return {pascal}Output.parse(toolUse.input);",
|
|
644
|
+
"}",
|
|
645
|
+
"",
|
|
646
|
+
]
|
|
647
|
+
)
|
|
648
|
+
return "\n".join(parts)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _emit_signature_openai(
|
|
652
|
+
*,
|
|
653
|
+
node_id: str,
|
|
654
|
+
fn_name: str,
|
|
655
|
+
pascal: str,
|
|
656
|
+
description: str,
|
|
657
|
+
input_zod: str,
|
|
658
|
+
output_zod: str,
|
|
659
|
+
output_schema_json: str,
|
|
660
|
+
prompt_template: str,
|
|
661
|
+
model: str,
|
|
662
|
+
temperature: float | None,
|
|
663
|
+
) -> str:
|
|
664
|
+
desc_first = description.strip().splitlines()[0] if description else node_id
|
|
665
|
+
temp_line = f" temperature: {temperature},\n" if temperature is not None else ""
|
|
666
|
+
quoted_id = json.dumps(node_id)
|
|
667
|
+
quoted_model = json.dumps(model)
|
|
668
|
+
quoted_prompt = json.dumps(prompt_template)
|
|
669
|
+
parts = [
|
|
670
|
+
"/**",
|
|
671
|
+
f" * Typed LLM signature: {node_id}",
|
|
672
|
+
" *",
|
|
673
|
+
f" * {desc_first}",
|
|
674
|
+
" *",
|
|
675
|
+
" * Auto-generated by rote.adapters.cloudflare from the IR's",
|
|
676
|
+
" * `signature_spec`. Uses OpenAI structured outputs with JSON Schema.",
|
|
677
|
+
" */",
|
|
678
|
+
"",
|
|
679
|
+
'import OpenAI from "openai";',
|
|
680
|
+
'import { z } from "zod";',
|
|
681
|
+
"",
|
|
682
|
+
f"export const {pascal}Input = {input_zod};",
|
|
683
|
+
f"export type {pascal}Input = z.infer<typeof {pascal}Input>;",
|
|
684
|
+
"",
|
|
685
|
+
f"export const {pascal}Output = {output_zod};",
|
|
686
|
+
f"export type {pascal}Output = z.infer<typeof {pascal}Output>;",
|
|
687
|
+
"",
|
|
688
|
+
f"const PROMPT = {quoted_prompt};",
|
|
689
|
+
f"const OUTPUT_JSON_SCHEMA = {output_schema_json};",
|
|
690
|
+
"",
|
|
691
|
+
_INTERPOLATE_HELPER.rstrip("\n"),
|
|
692
|
+
"",
|
|
693
|
+
f"export async function {fn_name}(",
|
|
694
|
+
" rawInput: unknown,",
|
|
695
|
+
" env: { OPENAI_API_KEY: string },",
|
|
696
|
+
f"): Promise<{pascal}Output> {{",
|
|
697
|
+
f" const input = {pascal}Input.parse(rawInput);",
|
|
698
|
+
" const client = new OpenAI({ apiKey: env.OPENAI_API_KEY });",
|
|
699
|
+
"",
|
|
700
|
+
" const response = await client.chat.completions.create({",
|
|
701
|
+
f" model: {quoted_model},",
|
|
702
|
+
]
|
|
703
|
+
if temp_line:
|
|
704
|
+
parts.append(temp_line.rstrip("\n"))
|
|
705
|
+
schema_inline = (
|
|
706
|
+
f" json_schema: {{ name: {quoted_id}, "
|
|
707
|
+
"schema: OUTPUT_JSON_SCHEMA, strict: true },"
|
|
708
|
+
)
|
|
709
|
+
msg_line = (
|
|
710
|
+
' { role: "user", '
|
|
711
|
+
"content: interpolate(PROMPT, input as Record<string, unknown>) },"
|
|
712
|
+
)
|
|
713
|
+
parts.extend(
|
|
714
|
+
[
|
|
715
|
+
" response_format: {",
|
|
716
|
+
' type: "json_schema",',
|
|
717
|
+
schema_inline,
|
|
718
|
+
" },",
|
|
719
|
+
" messages: [",
|
|
720
|
+
msg_line,
|
|
721
|
+
" ],",
|
|
722
|
+
" });",
|
|
723
|
+
" const content = response.choices[0]?.message?.content;",
|
|
724
|
+
" if (!content) {",
|
|
725
|
+
f' throw new Error("OpenAI returned no content for {node_id}");',
|
|
726
|
+
" }",
|
|
727
|
+
f" return {pascal}Output.parse(JSON.parse(content));",
|
|
728
|
+
"}",
|
|
729
|
+
"",
|
|
730
|
+
]
|
|
731
|
+
)
|
|
732
|
+
return "\n".join(parts)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# ───────── extracted/<id>.ts emission ─────────
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def emit_extracted_module(node: Node) -> str:
|
|
739
|
+
"""Emit src/extracted/<node_id>.ts — a stub for deterministic Python equivalents.
|
|
740
|
+
|
|
741
|
+
Cloudflare workers don't share Python modules with the Temporal
|
|
742
|
+
runtime; users implement these stubs in TypeScript directly (or call
|
|
743
|
+
out to a separately-deployed Python worker via service binding).
|
|
744
|
+
"""
|
|
745
|
+
fn_name = _to_camel_case(node.id)
|
|
746
|
+
desc_first = node.description.strip().splitlines()[0] if node.description else node.id
|
|
747
|
+
|
|
748
|
+
doc: list[str] = ["/**"]
|
|
749
|
+
if node.kind is NodeKind.AGENT_LOOP:
|
|
750
|
+
doc.append(f" * Stub for agent_loop node: {node.id}")
|
|
751
|
+
else:
|
|
752
|
+
doc.append(f" * Stub for {node.kind.value} node: {node.id}")
|
|
753
|
+
doc.extend([" *", f" * {desc_first}"])
|
|
754
|
+
|
|
755
|
+
if node.kind is NodeKind.AGENT_LOOP:
|
|
756
|
+
doc.extend(
|
|
757
|
+
[
|
|
758
|
+
" *",
|
|
759
|
+
" * Agent loops require an LLM agent runtime (e.g. the Anthropic Agent SDK",
|
|
760
|
+
" * with bounded iterations). Implement this against the agent harness your",
|
|
761
|
+
" * project already uses — the workflow only cares that the function",
|
|
762
|
+
" * resolves with the loop's terminal output.",
|
|
763
|
+
]
|
|
764
|
+
)
|
|
765
|
+
if node.tools:
|
|
766
|
+
doc.extend([" *", " * Tools the agent should be allowed to call:"])
|
|
767
|
+
doc.extend(f" * - {t}" for t in node.tools)
|
|
768
|
+
if node.loop_body:
|
|
769
|
+
doc.extend([" *", " * Loop body sub-nodes (call once per iteration):"])
|
|
770
|
+
doc.extend(f" * - {sn}" for sn in node.loop_body)
|
|
771
|
+
else:
|
|
772
|
+
doc.extend(
|
|
773
|
+
[
|
|
774
|
+
" *",
|
|
775
|
+
" * Replace this stub with the deterministic API call. Direct vendor SDKs",
|
|
776
|
+
" * are preferred over MCP wrappers — the rote graduator removes the MCP",
|
|
777
|
+
" * layer at emit time, so production code calls Salesforce / HubSpot /",
|
|
778
|
+
" * ZoomInfo / etc. directly.",
|
|
779
|
+
]
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
if node.mandatory:
|
|
783
|
+
doc.extend(
|
|
784
|
+
[
|
|
785
|
+
" *",
|
|
786
|
+
" * MANDATORY: this node was marked mandatory in the source skill.",
|
|
787
|
+
" * The workflow always calls it; do not make it conditional.",
|
|
788
|
+
]
|
|
789
|
+
)
|
|
790
|
+
if node.constants:
|
|
791
|
+
doc.extend([" *", " * Constants from the source skill (lifted into the IR):"])
|
|
792
|
+
doc.extend(f" * {k} = {json.dumps(v)}" for k, v in node.constants.items())
|
|
793
|
+
|
|
794
|
+
doc.append(" */")
|
|
795
|
+
|
|
796
|
+
body: list[str]
|
|
797
|
+
# Stubs declare Promise<never> — honest for a function that always
|
|
798
|
+
# throws, and `never` is the one type that both satisfies step.do's
|
|
799
|
+
# `Rpc.Serializable<T>` constraint and stays castable at the
|
|
800
|
+
# workflow's data-flow reference sites (see `_ref_to_ts_expr`).
|
|
801
|
+
# Note: `Promise<Record<string, unknown>>` would NOT work here —
|
|
802
|
+
# `unknown` values aren't structurally serializable, which breaks
|
|
803
|
+
# step.do overload resolution (verified via the tsc e2e test).
|
|
804
|
+
# Replace the annotation with your concrete output type when you
|
|
805
|
+
# fill in the implementation.
|
|
806
|
+
if node.kind is NodeKind.AGENT_LOOP:
|
|
807
|
+
msg = f'"agent_loop {node.id}: requires an agent runtime — implement me"'
|
|
808
|
+
body = [
|
|
809
|
+
"",
|
|
810
|
+
'import { type Env } from "../workflow";',
|
|
811
|
+
"",
|
|
812
|
+
f"export async function {fn_name}(",
|
|
813
|
+
" _input: unknown,",
|
|
814
|
+
" _env: Env,",
|
|
815
|
+
"): Promise<never> {",
|
|
816
|
+
f" throw new Error({msg});",
|
|
817
|
+
"}",
|
|
818
|
+
"",
|
|
819
|
+
]
|
|
820
|
+
else:
|
|
821
|
+
msg = f'"{node.kind.value} {node.id}: stub not implemented"'
|
|
822
|
+
body = [
|
|
823
|
+
"",
|
|
824
|
+
f"export async function {fn_name}(_input: unknown): Promise<never> {{",
|
|
825
|
+
f" throw new Error({msg});",
|
|
826
|
+
"}",
|
|
827
|
+
"",
|
|
828
|
+
]
|
|
829
|
+
return "\n".join(doc + body)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# ───────── wrangler / package / tsconfig emission ─────────
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def emit_wrangler(pipeline: Pipeline, cfg: CloudflareAdapterConfig) -> str:
|
|
836
|
+
pascal = _to_pascal_case(pipeline.name)
|
|
837
|
+
class_name = f"{pascal}Workflow"
|
|
838
|
+
obj = {
|
|
839
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
840
|
+
"name": pipeline.name,
|
|
841
|
+
"main": "src/index.ts",
|
|
842
|
+
"compatibility_date": cfg.compatibility_date,
|
|
843
|
+
"observability": {"enabled": True},
|
|
844
|
+
"workflows": [
|
|
845
|
+
{
|
|
846
|
+
"name": pipeline.name,
|
|
847
|
+
"binding": cfg.workflow_binding,
|
|
848
|
+
"class_name": class_name,
|
|
849
|
+
}
|
|
850
|
+
],
|
|
851
|
+
}
|
|
852
|
+
body = json.dumps(obj, indent=2)
|
|
853
|
+
return (
|
|
854
|
+
"// Auto-generated by rote.adapters.cloudflare. Hand-edit at your own risk\n"
|
|
855
|
+
"// (re-running `rote emit --runtime cloudflare` will overwrite).\n"
|
|
856
|
+
f"{body}\n"
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def emit_package_json(pipeline: Pipeline) -> str:
|
|
861
|
+
obj = {
|
|
862
|
+
"name": pipeline.name,
|
|
863
|
+
"version": pipeline.version,
|
|
864
|
+
"private": True,
|
|
865
|
+
"type": "module",
|
|
866
|
+
"scripts": {
|
|
867
|
+
"deploy": "wrangler deploy",
|
|
868
|
+
"dev": "wrangler dev",
|
|
869
|
+
"typecheck": "tsc --noEmit",
|
|
870
|
+
},
|
|
871
|
+
"dependencies": {
|
|
872
|
+
"@anthropic-ai/sdk": "^0.91.0",
|
|
873
|
+
"openai": "^6.0.0",
|
|
874
|
+
"zod": "^4.0.0",
|
|
875
|
+
},
|
|
876
|
+
"devDependencies": {
|
|
877
|
+
"@cloudflare/workers-types": "^4.20260426.0",
|
|
878
|
+
"typescript": "^5.6.0",
|
|
879
|
+
"wrangler": "^4.85.0",
|
|
880
|
+
},
|
|
881
|
+
}
|
|
882
|
+
return json.dumps(obj, indent=2) + "\n"
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def emit_tsconfig() -> str:
|
|
886
|
+
obj = {
|
|
887
|
+
"compilerOptions": {
|
|
888
|
+
"target": "ES2022",
|
|
889
|
+
"lib": ["ES2022"],
|
|
890
|
+
"module": "ES2022",
|
|
891
|
+
"moduleResolution": "Bundler",
|
|
892
|
+
"strict": True,
|
|
893
|
+
"skipLibCheck": True,
|
|
894
|
+
"esModuleInterop": True,
|
|
895
|
+
"isolatedModules": True,
|
|
896
|
+
"verbatimModuleSyntax": False,
|
|
897
|
+
"noEmit": True,
|
|
898
|
+
"types": ["@cloudflare/workers-types"],
|
|
899
|
+
},
|
|
900
|
+
"include": ["src/**/*.ts"],
|
|
901
|
+
}
|
|
902
|
+
return json.dumps(obj, indent=2) + "\n"
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# ───────── Adapter facade ─────────
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
class CloudflareAdapter:
|
|
909
|
+
"""Facade that emits a Cloudflare Workflow from a Pipeline IR.
|
|
910
|
+
|
|
911
|
+
Output layout::
|
|
912
|
+
|
|
913
|
+
out/
|
|
914
|
+
wrangler.jsonc
|
|
915
|
+
package.json
|
|
916
|
+
tsconfig.json
|
|
917
|
+
src/
|
|
918
|
+
index.ts
|
|
919
|
+
workflow.ts
|
|
920
|
+
signatures/<llm_judge_id>.ts
|
|
921
|
+
extracted/<other_id>.ts
|
|
922
|
+
|
|
923
|
+
The directory is ``wrangler deploy``-ready once the user fills in
|
|
924
|
+
the extracted/ stubs and signs in to Cloudflare.
|
|
925
|
+
"""
|
|
926
|
+
|
|
927
|
+
def __init__(self, config: CloudflareAdapterConfig | None = None) -> None:
|
|
928
|
+
self.config = config or CloudflareAdapterConfig()
|
|
929
|
+
|
|
930
|
+
def emit_workflow(self, pipeline: Pipeline) -> str:
|
|
931
|
+
return emit_workflow(pipeline, self.config)
|
|
932
|
+
|
|
933
|
+
def emit_index(self, pipeline: Pipeline) -> str:
|
|
934
|
+
return emit_index(pipeline, self.config)
|
|
935
|
+
|
|
936
|
+
def emit(self, pipeline: Pipeline, output_dir: str | Path) -> dict[str, Path]:
|
|
937
|
+
out = Path(output_dir)
|
|
938
|
+
src = out / "src"
|
|
939
|
+
sigs = src / "signatures"
|
|
940
|
+
extracted = src / "extracted"
|
|
941
|
+
for d in (out, src, sigs, extracted):
|
|
942
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
943
|
+
|
|
944
|
+
written: dict[str, Path] = {}
|
|
945
|
+
|
|
946
|
+
wf_path = src / "workflow.ts"
|
|
947
|
+
wf_path.write_text(self.emit_workflow(pipeline), encoding="utf-8")
|
|
948
|
+
written["workflow"] = wf_path
|
|
949
|
+
|
|
950
|
+
idx_path = src / "index.ts"
|
|
951
|
+
idx_path.write_text(self.emit_index(pipeline), encoding="utf-8")
|
|
952
|
+
written["index"] = idx_path
|
|
953
|
+
|
|
954
|
+
for node in pipeline.nodes:
|
|
955
|
+
if node.kind is NodeKind.HITL_GATE:
|
|
956
|
+
continue
|
|
957
|
+
if node.kind is NodeKind.LLM_JUDGE:
|
|
958
|
+
p = sigs / f"{node.id}.ts"
|
|
959
|
+
p.write_text(emit_signature_module(node, self.config), encoding="utf-8")
|
|
960
|
+
written[f"signatures/{node.id}"] = p
|
|
961
|
+
else:
|
|
962
|
+
p = extracted / f"{node.id}.ts"
|
|
963
|
+
p.write_text(emit_extracted_module(node), encoding="utf-8")
|
|
964
|
+
written[f"extracted/{node.id}"] = p
|
|
965
|
+
|
|
966
|
+
wrangler_path = out / "wrangler.jsonc"
|
|
967
|
+
wrangler_path.write_text(emit_wrangler(pipeline, self.config), encoding="utf-8")
|
|
968
|
+
written["wrangler"] = wrangler_path
|
|
969
|
+
|
|
970
|
+
pkg_path = out / "package.json"
|
|
971
|
+
pkg_path.write_text(emit_package_json(pipeline), encoding="utf-8")
|
|
972
|
+
written["package.json"] = pkg_path
|
|
973
|
+
|
|
974
|
+
ts_path = out / "tsconfig.json"
|
|
975
|
+
ts_path.write_text(emit_tsconfig(), encoding="utf-8")
|
|
976
|
+
written["tsconfig.json"] = ts_path
|
|
977
|
+
|
|
978
|
+
return written
|