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.
@@ -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