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/adapters/dbos.py ADDED
@@ -0,0 +1,1235 @@
1
+ """DBOS adapter — emits a durable Python app from a Pipeline IR.
2
+
3
+ Layer 3 of rote: takes a validated :class:`rote.ir.Pipeline` and emits a
4
+ runnable DBOS Transact application. DBOS is the "no workflow runner"
5
+ target — durable execution as a Python library checkpointing to Postgres
6
+ (or SQLite for local dev). There is no orchestrator process to deploy;
7
+ ``python main.py`` *is* the runtime.
8
+
9
+ Output layout::
10
+
11
+ out/
12
+ main.py # @DBOS.workflow + one @DBOS.step per node
13
+ dbos-config.yaml # CLI/Cloud tooling config (`dbos start`)
14
+ README.md # how to run, signal HITL gates, deploy
15
+ extracted/<module>.py # stubs for pure_function / external_call
16
+ extracted/<agent_loop>.py # stubs for agent_loop nodes
17
+ signatures/<llm_judge>.py # typed Pydantic + vendor-SDK signatures
18
+
19
+ Key design choices vs. the other adapters:
20
+
21
+ * **Single process, single file orchestration.** DBOS has no
22
+ workflow/activity split (Temporal) and no platform entrypoint class
23
+ (Cloudflare). Steps and the workflow live together in ``main.py``;
24
+ durability comes from the library checkpointing every step result to
25
+ the system database.
26
+
27
+ * **Parallel waves via ``Queue``.** DBOS's documented concurrency
28
+ primitive is enqueueing work onto a :class:`dbos.Queue`, which returns
29
+ a ``WorkflowHandle`` per enqueued function. Multi-node waves enqueue
30
+ every node then join with ``get_result()``; single-node waves call the
31
+ step directly. This keeps the emitted code synchronous and the
32
+ step-checkpoint ordering deterministic (asyncio.gather over steps has
33
+ subtler replay-ordering semantics).
34
+
35
+ * **HITL gates via durable messages.** ``DBOS.recv(topic=...)`` parks
36
+ the workflow in the system database until ``DBOS.send(workflow_id,
37
+ payload, topic=...)`` delivers the message — the officially documented
38
+ human-in-the-loop pattern. The IR ``signal`` name maps to the topic.
39
+
40
+ * **Data-flow threading matches the Temporal adapter.** The workflow
41
+ takes the pipeline input dict as ``pipeline_input``, binds every
42
+ node's result (including HITL gate resume payloads) as
43
+ ``<id>_result``, and builds each step's payload from the node's
44
+ ``inputs:`` bindings via the shared reference grammar
45
+ (:func:`rote.ir.parse_input_ref`). Forward references are rejected at
46
+ emit time by :func:`rote.adapters._common.check_input_refs_available`.
47
+
48
+ * **Typed LLM signatures as generated Pydantic modules.** When the IR
49
+ carries ``signature_spec`` (preferred), the adapter converts the JSON
50
+ Schemas to Pydantic model source and emits a direct vendor-SDK call
51
+ with structured (tool-use / JSON-schema) output — the Python analog of
52
+ the Cloudflare adapter's Zod emission. The legacy ``signature`` path
53
+ form falls back to importing the user's module, matching Temporal.
54
+
55
+ The emitted code never imports MCP runtime — same architectural invariant
56
+ as the other adapters, enforced by AST tests.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import json
62
+ import keyword
63
+ import re
64
+ import textwrap
65
+ from dataclasses import dataclass
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ from rote.adapters._common import (
70
+ _execution_waves,
71
+ _pipeline_hash,
72
+ _to_pascal_case,
73
+ check_input_refs_available,
74
+ )
75
+ from rote.adapters.temporal import (
76
+ _impl_path_parts,
77
+ _payload_literal,
78
+ _signature_path_parts,
79
+ )
80
+ from rote.ir import LLMSignature, Node, NodeKind, Pipeline
81
+
82
+ # ───────── Adapter configuration ─────────
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class DbosAdapterConfig:
87
+ """Per-emission knobs for the DBOS adapter.
88
+
89
+ Defaults work out-of-the-box for the BDR example: SQLite system
90
+ database for local dev (overridable via ``DBOS_SYSTEM_DATABASE_URL``
91
+ for Postgres in production), admin server off.
92
+ """
93
+
94
+ anthropic_default_model: str = "claude-sonnet-4-6"
95
+ openai_default_model: str = "gpt-4.1"
96
+ # Module path used for *legacy* `signature: path.py:Class` judges,
97
+ # which import a user-maintained Python module instead of a
98
+ # generated one. Mirrors TemporalAdapterConfig.signatures_module.
99
+ legacy_signatures_module: str = "signatures"
100
+ # Steps enqueued for parallel waves land on this queue. None means
101
+ # derive "<pipeline-name>-queue".
102
+ queue_name: str | None = None
103
+
104
+
105
+ # ───────── Duration handling ─────────
106
+
107
+ _DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)(ms|s|m|h|d)$")
108
+
109
+ _UNIT_TO_SECONDS = {
110
+ "ms": 0.001,
111
+ "s": 1.0,
112
+ "m": 60.0,
113
+ "h": 3600.0,
114
+ "d": 86400.0,
115
+ }
116
+
117
+
118
+ def _duration_to_seconds(s: str) -> float:
119
+ """Convert an IR duration string ('5m', '30s', '7d') to seconds.
120
+
121
+ DBOS APIs take plain ``timeout_seconds`` floats, so unlike the
122
+ Temporal adapter (which emits a runtime parser) we convert at
123
+ emission time and emit a literal.
124
+ """
125
+ m = _DURATION_RE.fullmatch(s.strip())
126
+ if not m:
127
+ raise ValueError(
128
+ f"DBOS adapter: cannot parse IR duration {s!r}. Expected forms like '30s', '5m', '7d'."
129
+ )
130
+ return float(m.group(1)) * _UNIT_TO_SECONDS[m.group(2)]
131
+
132
+
133
+ def _seconds_literal(seconds: float) -> str:
134
+ """Render a seconds value as a compact Python literal."""
135
+ if seconds == int(seconds):
136
+ return str(int(seconds))
137
+ return repr(seconds)
138
+
139
+
140
+ # ───────── Retry mapping ─────────
141
+
142
+
143
+ def _step_decorator(node: Node) -> str:
144
+ """Render the ``@DBOS.step(...)`` decorator line for a node.
145
+
146
+ Maps the IR's ``RetryPolicy`` onto DBOS step retry parameters:
147
+
148
+ | IR field | DBOS parameter |
149
+ |---------------|-----------------------------------------------|
150
+ | ``max`` | ``max_attempts`` (= max + 1; DBOS counts the |
151
+ | | initial attempt, like Temporal) |
152
+ | ``backoff`` | ``backoff_rate`` (exponential → 2.0; |
153
+ | | constant → 1.0; linear → 1.0, the closest |
154
+ | | available approximation — DBOS delay is |
155
+ | | ``interval_seconds * backoff_rate**attempt``) |
156
+ | ``retry_on`` | not mapped (DBOS retries any exception; its |
157
+ | | ``should_retry`` predicate takes a callable, |
158
+ | | not categories — surfaced as a comment) |
159
+ """
160
+ args = [f'name="{node.id}"']
161
+ if node.retry and node.retry.max > 0:
162
+ args.append("retries_allowed=True")
163
+ args.append(f"max_attempts={node.retry.max + 1}")
164
+ backoff_rate = 2.0 if node.retry.backoff == "exponential" else 1.0
165
+ args.append(f"backoff_rate={backoff_rate}")
166
+ return f"@DBOS.step({', '.join(args)})"
167
+
168
+
169
+ def _retry_on_comment(node: Node) -> str:
170
+ """Emit a comment documenting retry_on categories DBOS can't express."""
171
+ if node.retry and node.retry.retry_on:
172
+ cats = ", ".join(node.retry.retry_on)
173
+ return (
174
+ f" # retry_on categories from the IR: {cats}. DBOS retries any\n"
175
+ f" # exception; narrow with @DBOS.step(should_retry=...) if needed.\n"
176
+ )
177
+ return ""
178
+
179
+
180
+ def _timeout_comment(node: Node) -> str:
181
+ """DBOS steps have no per-step timeout primitive; document the IR value."""
182
+ if node.timeout:
183
+ return (
184
+ f" # IR timeout {node.timeout!r}: DBOS has no per-step timeout\n"
185
+ f" # primitive; enforce inside the implementation if required.\n"
186
+ )
187
+ return ""
188
+
189
+
190
+ # ───────── JSON Schema → Pydantic source ─────────
191
+
192
+
193
+ def _pascal_ident(s: str) -> str:
194
+ """Sanitize an arbitrary schema title into a PascalCase identifier.
195
+
196
+ Preserves interior capitalization ('EmploymentEntry' stays
197
+ 'EmploymentEntry'; ``str.capitalize`` would mangle it to
198
+ 'Employmententry').
199
+ """
200
+ cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", s)
201
+ parts = [p for p in cleaned.split("_") if p]
202
+ if not parts:
203
+ return "Model"
204
+ ident = "".join(p[0].upper() + p[1:] for p in parts)
205
+ if ident[0].isdigit():
206
+ ident = f"Model{ident}"
207
+ return ident
208
+
209
+
210
+ def _py_literal(value: Any) -> str:
211
+ """Render a JSON-compatible value as a Python literal expression.
212
+
213
+ Strings use double quotes (matching the repo's ruff-format style);
214
+ containers recurse so nested strings get the same treatment.
215
+ """
216
+ if isinstance(value, str):
217
+ return json.dumps(value)
218
+ if isinstance(value, bool) or value is None:
219
+ return repr(value)
220
+ if isinstance(value, list):
221
+ return "[" + ", ".join(_py_literal(v) for v in value) + "]"
222
+ if isinstance(value, dict):
223
+ items = ", ".join(f"{_py_literal(k)}: {_py_literal(v)}" for k, v in value.items())
224
+ return "{" + items + "}"
225
+ return repr(value)
226
+
227
+
228
+ def _literal_alias(name: str, values: list[Any]) -> str:
229
+ """Render ``Name = Literal[...]``, wrapping when the one-liner is long."""
230
+ rendered = [_py_literal(v) for v in values]
231
+ one_line = f"{name} = Literal[{', '.join(rendered)}]"
232
+ if len(one_line) <= 96:
233
+ return one_line
234
+ body = "\n".join(f" {r}," for r in rendered)
235
+ return f"{name} = Literal[\n{body}\n]"
236
+
237
+
238
+ class _SchemaToPydantic:
239
+ """Convert JSON Schemas (with ``$defs``/``$ref``) to Pydantic source.
240
+
241
+ The Python analog of the Cloudflare adapter's ``json_schema_to_zod``.
242
+ Named ``$defs`` become named classes / ``Literal`` aliases; inline
243
+ object schemas get synthesized names. Blocks are accumulated in
244
+ dependency order (a class is emitted only after everything it
245
+ references), so the generated module imports cleanly top-to-bottom.
246
+ """
247
+
248
+ def __init__(self) -> None:
249
+ self._defs: dict[str, dict[str, Any]] = {}
250
+ self._blocks: list[str] = []
251
+ self._emitted: dict[str, str] = {} # $def name -> python name
252
+ self._in_progress: set[str] = set()
253
+ self._used_names: set[str] = set()
254
+ self.uses_literal = False
255
+
256
+ # ── public API ──
257
+
258
+ def add_defs(self, defs: dict[str, Any]) -> None:
259
+ for name, schema in defs.items():
260
+ if name in self._defs and self._defs[name] != schema:
261
+ raise ValueError(
262
+ f"DBOS adapter: $defs entry {name!r} appears in both input "
263
+ f"and output schemas with different definitions"
264
+ )
265
+ self._defs[name] = schema
266
+
267
+ def emit_root(self, class_name: str, schema: dict[str, Any]) -> None:
268
+ if schema.get("type") != "object":
269
+ raise ValueError(
270
+ f"DBOS adapter: root signature schema for {class_name} must be "
271
+ f"an object schema, got type={schema.get('type')!r}"
272
+ )
273
+ self._emit_object_class(class_name, schema)
274
+
275
+ @property
276
+ def blocks(self) -> list[str]:
277
+ return list(self._blocks)
278
+
279
+ # ── internals ──
280
+
281
+ def _unique_name(self, base: str) -> str:
282
+ name = base
283
+ n = 2
284
+ while name in self._used_names:
285
+ name = f"{base}{n}"
286
+ n += 1
287
+ self._used_names.add(name)
288
+ return name
289
+
290
+ def _ensure_def(self, ref: str) -> str:
291
+ if not ref.startswith("#/$defs/"):
292
+ raise ValueError(f"DBOS adapter: unsupported $ref form: {ref!r}")
293
+ def_name = ref[len("#/$defs/") :]
294
+ if def_name in self._emitted:
295
+ return self._emitted[def_name]
296
+ if def_name in self._in_progress:
297
+ raise ValueError(f"DBOS adapter: cyclic $ref through {def_name!r} is not supported")
298
+ if def_name not in self._defs:
299
+ raise ValueError(f"DBOS adapter: unknown $ref target: {def_name!r}")
300
+
301
+ schema = self._defs[def_name]
302
+ self._in_progress.add(def_name)
303
+ try:
304
+ py_name = self._unique_name(_pascal_ident(schema.get("title", def_name)))
305
+ self._emitted[def_name] = py_name
306
+ if "enum" in schema:
307
+ self.uses_literal = True
308
+ self._blocks.append(_literal_alias(py_name, schema["enum"]))
309
+ elif schema.get("type") == "object":
310
+ self._emit_object_class(py_name, schema)
311
+ else:
312
+ self._blocks.append(f"{py_name} = {self._type_expr(schema, py_name)}")
313
+ finally:
314
+ self._in_progress.discard(def_name)
315
+ return py_name
316
+
317
+ def _type_expr(self, schema: dict[str, Any], hint: str) -> str:
318
+ if "$ref" in schema:
319
+ return self._ensure_def(schema["$ref"])
320
+
321
+ for union_key in ("anyOf", "oneOf"):
322
+ if union_key in schema:
323
+ variants = schema[union_key]
324
+ non_null = [
325
+ v for v in variants if not (isinstance(v, dict) and v.get("type") == "null")
326
+ ]
327
+ has_null = len(non_null) < len(variants)
328
+ parts = [self._type_expr(v, f"{hint}Variant{i}") for i, v in enumerate(non_null)]
329
+ expr = " | ".join(parts) if parts else "None"
330
+ if has_null and parts:
331
+ expr += " | None"
332
+ return expr
333
+
334
+ if "enum" in schema:
335
+ self.uses_literal = True
336
+ values = ", ".join(_py_literal(v) for v in schema["enum"])
337
+ return f"Literal[{values}]"
338
+
339
+ schema_type = schema.get("type")
340
+ if schema_type == "object":
341
+ if not schema.get("properties"):
342
+ return "dict[str, Any]"
343
+ name = self._unique_name(_pascal_ident(schema.get("title", hint)))
344
+ self._emit_object_class(name, schema)
345
+ return name
346
+ if schema_type == "array":
347
+ items = schema.get("items")
348
+ if not isinstance(items, dict):
349
+ return "list[Any]"
350
+ return f"list[{self._type_expr(items, f'{hint}Item')}]"
351
+ if schema_type == "string":
352
+ return "str"
353
+ if schema_type == "integer":
354
+ return "int"
355
+ if schema_type == "number":
356
+ return "float"
357
+ if schema_type == "boolean":
358
+ return "bool"
359
+ if schema_type == "null":
360
+ return "None"
361
+ return "Any"
362
+
363
+ def _emit_object_class(self, class_name: str, schema: dict[str, Any]) -> None:
364
+ required = set(schema.get("required", []))
365
+ properties: dict[str, Any] = schema.get("properties", {})
366
+
367
+ field_lines: list[str] = []
368
+ for field_name, field_schema in properties.items():
369
+ if not field_name.isidentifier() or keyword.iskeyword(field_name):
370
+ raise ValueError(
371
+ f"DBOS adapter: schema property {field_name!r} in "
372
+ f"{class_name} is not a valid Python identifier"
373
+ )
374
+ expr = self._type_expr(field_schema, f"{class_name}{_pascal_ident(field_name)}")
375
+ if field_name in required and "default" not in field_schema:
376
+ field_lines.append(f" {field_name}: {expr}")
377
+ elif "default" in field_schema:
378
+ field_lines.append(
379
+ f" {field_name}: {expr} = {_py_literal(field_schema['default'])}"
380
+ )
381
+ else:
382
+ if not expr.endswith("| None") and expr != "None":
383
+ expr += " | None"
384
+ field_lines.append(f" {field_name}: {expr} = None")
385
+
386
+ lines = [f"class {class_name}(BaseModel):"]
387
+ description = schema.get("description")
388
+ if description:
389
+ first = str(description).strip().splitlines()[0]
390
+ lines.append(f' """{first}"""')
391
+ lines.append("")
392
+ if schema.get("additionalProperties") is False:
393
+ lines.append(' model_config = ConfigDict(extra="forbid")')
394
+ lines.append("")
395
+ if field_lines:
396
+ lines.extend(field_lines)
397
+ elif len(lines) == 1:
398
+ lines.append(" pass")
399
+ self._blocks.append("\n".join(lines))
400
+
401
+
402
+ # ───────── Long-string chunking (ruff line-length safety) ─────────
403
+
404
+
405
+ def _str_chunks(text: str, indent: str) -> list[str]:
406
+ """Split a string into escaped literal chunks that fit the line budget.
407
+
408
+ Emitted snapshots are committed to the repo and linted with ruff
409
+ (line-length 100), so single-line ``json.dumps`` literals for prompts
410
+ and schemas are not an option. The budget is measured against the
411
+ *escaped* form at the given indent depth so no emitted line exceeds
412
+ the limit.
413
+ """
414
+ width = max(40, 98 - len(indent) - 8)
415
+ chunks: list[str] = []
416
+ current = ""
417
+ for ch in text:
418
+ current += ch
419
+ if len(json.dumps(current)) >= width:
420
+ chunks.append(current)
421
+ current = ""
422
+ if current or not chunks:
423
+ chunks.append(current)
424
+ return chunks
425
+
426
+
427
+ def _chunked_str_literal(text: str, indent: str = " ") -> str:
428
+ """Render a long string as a parenthesized adjacent-literal expression."""
429
+ chunks = _str_chunks(text, indent)
430
+ if len(chunks) == 1:
431
+ return json.dumps(chunks[0])
432
+ body = "\n".join(f"{indent}{json.dumps(c)}" for c in chunks)
433
+ outer = indent[:-4] if len(indent) >= 4 else ""
434
+ return f"(\n{body}\n{outer})"
435
+
436
+
437
+ def _chunked_call_arg(text: str, indent: str) -> str:
438
+ """Render a long string as adjacent literals for direct use as a call arg.
439
+
440
+ Unlike :func:`_chunked_str_literal` this emits no wrapping parentheses
441
+ (the call's own parentheses group the literals), which keeps ruff's
442
+ UP034 (extraneous parentheses) quiet.
443
+ """
444
+ chunks = _str_chunks(text, indent)
445
+ return "\n".join(f"{indent}{json.dumps(c)}" for c in chunks)
446
+
447
+
448
+ # ───────── signatures/<id>.py emission ─────────
449
+
450
+
451
+ def emit_signature_module(node: Node, cfg: DbosAdapterConfig) -> str:
452
+ """Emit signatures/<node_id>.py for an llm_judge node with a spec.
453
+
454
+ The emitted module:
455
+
456
+ 1. Declares Pydantic models for input + output, generated from the
457
+ IR's ``signature_spec`` JSON Schemas.
458
+ 2. Exports a ``<Pascal>`` judge class whose synchronous ``forward``
459
+ interpolates the prompt, calls the vendor SDK with structured
460
+ output (Anthropic tool-use / OpenAI json_schema), validates the
461
+ response with Pydantic, and returns the typed output.
462
+
463
+ The prompt template is interpolated with a minimal ``{{ key }}``
464
+ substitution at runtime — same contract as the Cloudflare adapter's
465
+ TS emission, so the two runtimes share prompt semantics.
466
+ """
467
+ if node.signature_spec is None:
468
+ raise ValueError(
469
+ f"DBOS adapter: emit_signature_module requires signature_spec on node {node.id!r}"
470
+ )
471
+ spec = node.signature_spec
472
+ pascal = _to_pascal_case(node.id)
473
+
474
+ converter = _SchemaToPydantic()
475
+ converter.add_defs(spec.input_schema.get("$defs", {}))
476
+ converter.add_defs(spec.output_schema.get("$defs", {}))
477
+ converter.emit_root(f"{pascal}Input", spec.input_schema)
478
+ converter.emit_root(f"{pascal}Output", spec.output_schema)
479
+
480
+ model = spec.model or _default_model_for(spec, cfg)
481
+ desc_first = node.description.strip().splitlines()[0] if node.description else node.id
482
+
483
+ typing_names = "Any, Literal" if converter.uses_literal else "Any"
484
+ schema_json = json.dumps(spec.output_schema, separators=(",", ":"))
485
+
486
+ header = textwrap.dedent(
487
+ f'''\
488
+ """Typed LLM signature: {node.id}
489
+
490
+ {desc_first}
491
+
492
+ Auto-generated by rote.adapters.dbos from the IR's ``signature_spec``.
493
+ The non-determinism lives inside this module; the workflow step that
494
+ calls it stays a checkpointed, retryable unit.
495
+
496
+ DO NOT EDIT BY HAND. Re-run ``rote emit --runtime dbos`` to regenerate.
497
+ """
498
+
499
+ from __future__ import annotations
500
+
501
+ import json
502
+ import re
503
+ from typing import {typing_names}
504
+
505
+ from pydantic import BaseModel, ConfigDict
506
+ '''
507
+ )
508
+
509
+ models_block = "\n\n\n".join(converter.blocks)
510
+
511
+ prompt_block = f"PROMPT = {_chunked_str_literal(spec.prompt)}"
512
+ description_block = (
513
+ f"TOOL_DESCRIPTION = {_chunked_str_literal(desc_first)}\n\n"
514
+ if spec.client == "anthropic"
515
+ else ""
516
+ )
517
+ schema_block = (
518
+ "OUTPUT_JSON_SCHEMA: dict[str, Any] = json.loads(\n"
519
+ f"{_chunked_call_arg(schema_json, indent=' ')}\n"
520
+ ")"
521
+ )
522
+
523
+ interpolate_block = textwrap.dedent(
524
+ '''\
525
+ def _interpolate(template: str, variables: dict[str, Any]) -> str:
526
+ """Resolve ``{{ dotted.path }}`` placeholders against the input dict."""
527
+
528
+ def _resolve(match: re.Match[str]) -> str:
529
+ value: Any = variables
530
+ for part in match.group(1).split("."):
531
+ value = value.get(part) if isinstance(value, dict) else None
532
+ if value is None:
533
+ return ""
534
+ return value if isinstance(value, str) else json.dumps(value, default=str)
535
+
536
+ return re.sub(r"\\{\\{\\s*([\\w.]+)\\s*\\}\\}", _resolve, template)
537
+ '''
538
+ )
539
+
540
+ if spec.client == "anthropic":
541
+ call_block = _emit_forward_anthropic(node, pascal, model, spec)
542
+ elif spec.client == "openai":
543
+ call_block = _emit_forward_openai(node, pascal, model, spec)
544
+ else: # pragma: no cover — LLMSignature validates the client field
545
+ raise ValueError(f"Unsupported LLM client: {spec.client!r}")
546
+
547
+ return (
548
+ header
549
+ + "\n\n"
550
+ + models_block
551
+ + "\n\n\n"
552
+ + prompt_block
553
+ + "\n\n"
554
+ + description_block
555
+ + schema_block
556
+ + "\n\n\n"
557
+ + interpolate_block
558
+ + "\n\n"
559
+ + call_block
560
+ )
561
+
562
+
563
+ def _default_model_for(spec: LLMSignature, cfg: DbosAdapterConfig) -> str:
564
+ if spec.client == "openai":
565
+ return cfg.openai_default_model
566
+ return cfg.anthropic_default_model
567
+
568
+
569
+ def _temperature_line(spec: LLMSignature) -> str:
570
+ if spec.temperature is None:
571
+ return ""
572
+ return f" temperature={spec.temperature},\n"
573
+
574
+
575
+ def _emit_forward_anthropic(node: Node, pascal: str, model: str, spec: LLMSignature) -> str:
576
+ temp = _temperature_line(spec)
577
+ return (
578
+ f"class {pascal}:\n"
579
+ f' """Typed LLM judge for {node.id} (Anthropic structured output)."""\n'
580
+ f"\n"
581
+ f" def forward(self, inputs: {pascal}Input) -> {pascal}Output:\n"
582
+ f" # Lazy import: the SDK is only needed at call time, and the\n"
583
+ f" # module stays importable in environments without it.\n"
584
+ f" import anthropic\n"
585
+ f"\n"
586
+ f" client = anthropic.Anthropic()\n"
587
+ f" response = client.messages.create(\n"
588
+ f" model={json.dumps(model)},\n"
589
+ f" max_tokens=4096,\n"
590
+ f"{temp}"
591
+ f" tools=[\n"
592
+ f" {{\n"
593
+ f' "name": {json.dumps(node.id)},\n'
594
+ f' "description": TOOL_DESCRIPTION,\n'
595
+ f' "input_schema": OUTPUT_JSON_SCHEMA,\n'
596
+ f" }}\n"
597
+ f" ],\n"
598
+ f' tool_choice={{"type": "tool", "name": {json.dumps(node.id)}}},\n'
599
+ f" messages=[\n"
600
+ f" {{\n"
601
+ f' "role": "user",\n'
602
+ f' "content": _interpolate(PROMPT, inputs.model_dump()),\n'
603
+ f" }}\n"
604
+ f" ],\n"
605
+ f" )\n"
606
+ f" for block in response.content:\n"
607
+ f' if block.type == "tool_use":\n'
608
+ f" return {pascal}Output.model_validate(block.input)\n"
609
+ f" raise RuntimeError(\n"
610
+ f' "LLM did not return a tool_use block for {node.id}"\n'
611
+ f" )\n"
612
+ )
613
+
614
+
615
+ def _emit_forward_openai(node: Node, pascal: str, model: str, spec: LLMSignature) -> str:
616
+ temp = _temperature_line(spec)
617
+ return (
618
+ f"class {pascal}:\n"
619
+ f' """Typed LLM judge for {node.id} (OpenAI structured output)."""\n'
620
+ f"\n"
621
+ f" def forward(self, inputs: {pascal}Input) -> {pascal}Output:\n"
622
+ f" # Lazy import: the SDK is only needed at call time, and the\n"
623
+ f" # module stays importable in environments without it.\n"
624
+ f" import openai\n"
625
+ f"\n"
626
+ f" client = openai.OpenAI()\n"
627
+ f" response = client.chat.completions.create(\n"
628
+ f" model={json.dumps(model)},\n"
629
+ f"{temp}"
630
+ f" response_format={{\n"
631
+ f' "type": "json_schema",\n'
632
+ f' "json_schema": {{\n'
633
+ f' "name": {json.dumps(node.id)},\n'
634
+ f' "schema": OUTPUT_JSON_SCHEMA,\n'
635
+ f' "strict": True,\n'
636
+ f" }},\n"
637
+ f" }},\n"
638
+ f" messages=[\n"
639
+ f" {{\n"
640
+ f' "role": "user",\n'
641
+ f' "content": _interpolate(PROMPT, inputs.model_dump()),\n'
642
+ f" }}\n"
643
+ f" ],\n"
644
+ f" )\n"
645
+ f" content = response.choices[0].message.content\n"
646
+ f" if not content:\n"
647
+ f' raise RuntimeError("OpenAI returned no content for {node.id}")\n'
648
+ f" return {pascal}Output.model_validate(json.loads(content))\n"
649
+ )
650
+
651
+
652
+ # ───────── extracted/<module>.py emission ─────────
653
+
654
+
655
+ def _extracted_layout(pipeline: Pipeline) -> dict[str, list[Node]]:
656
+ """Group impl-bearing and agent_loop nodes into extracted modules.
657
+
658
+ ``pure_function`` / ``external_call`` nodes carry an
659
+ ``extracted/<module>.py:<func>`` impl path; several nodes may share
660
+ a module (BDR's hubspot.py has three functions). ``agent_loop``
661
+ nodes have no impl path — each gets its own ``<node_id>.py`` module
662
+ with a same-named stub function.
663
+ """
664
+ modules: dict[str, list[Node]] = {}
665
+ for node in pipeline.nodes:
666
+ if node.kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
667
+ assert node.impl is not None
668
+ module_name, _ = _impl_path_parts(node.impl)
669
+ modules.setdefault(module_name, []).append(node)
670
+ elif node.kind is NodeKind.AGENT_LOOP:
671
+ modules.setdefault(node.id, []).append(node)
672
+ return modules
673
+
674
+
675
+ def _extracted_func_name(node: Node) -> str:
676
+ if node.kind is NodeKind.AGENT_LOOP:
677
+ return node.id
678
+ assert node.impl is not None
679
+ _, func_name = _impl_path_parts(node.impl)
680
+ return func_name
681
+
682
+
683
+ def emit_extracted_module(module_name: str, nodes: list[Node]) -> str:
684
+ """Emit extracted/<module_name>.py with one stub function per node.
685
+
686
+ Same scaffolding convention as the Temporal example package: the
687
+ functions raise ``NotImplementedError`` until the user fills them in
688
+ with direct vendor API calls. The graduation history (MCP origin) is
689
+ documented in docstrings only — never in executable code.
690
+ """
691
+ parts: list[str] = [
692
+ textwrap.dedent(
693
+ f'''\
694
+ """Extracted module: {module_name}
695
+
696
+ Auto-generated stubs by rote.adapters.dbos. Replace each body
697
+ with the real implementation (direct vendor API calls — the MCP
698
+ tool calls from the source skill were graduated away at emit
699
+ time). Keep the signatures: the DBOS steps in main.py call these
700
+ with the step payload as keyword arguments.
701
+ """
702
+
703
+ from __future__ import annotations
704
+
705
+ from typing import Any
706
+ '''
707
+ )
708
+ ]
709
+
710
+ seen: set[str] = set()
711
+ for node in nodes:
712
+ func_name = _extracted_func_name(node)
713
+ if func_name in seen:
714
+ continue
715
+ seen.add(func_name)
716
+ desc_first = node.description.strip().splitlines()[0] if node.description else node.id
717
+
718
+ doc_lines = [f" {desc_first}", ""]
719
+ if node.kind is NodeKind.AGENT_LOOP:
720
+ doc_lines.append(" STUB — agent loops require an LLM agent runtime. Implement")
721
+ doc_lines.append(" against the project's preferred agent harness (Anthropic")
722
+ doc_lines.append(" Agent SDK, OpenAI Agents SDK, LangGraph, etc.).")
723
+ if node.tools:
724
+ doc_lines.append("")
725
+ doc_lines.append(" Tools the agent should be allowed to call:")
726
+ doc_lines.extend(f" - {t}" for t in node.tools)
727
+ if node.loop_body:
728
+ doc_lines.append("")
729
+ doc_lines.append(" Loop body sub-nodes (call once per iteration):")
730
+ doc_lines.extend(f" - {sn}" for sn in node.loop_body)
731
+ else:
732
+ doc_lines.append(" STUB — replace with the deterministic API call.")
733
+ if node.mandatory:
734
+ doc_lines.append("")
735
+ doc_lines.append(" MANDATORY: this node was marked mandatory in the source")
736
+ doc_lines.append(" skill. The workflow always calls it; do not make it")
737
+ doc_lines.append(" conditional.")
738
+ if node.constants:
739
+ doc_lines.append("")
740
+ doc_lines.append(" Constants from the IR (lifted from the source skill):")
741
+ for k, v in node.constants.items():
742
+ doc_lines.extend(
743
+ textwrap.wrap(
744
+ f"{k} = {v!r}",
745
+ width=88,
746
+ initial_indent=" ",
747
+ subsequent_indent=" ",
748
+ )
749
+ )
750
+
751
+ doc = "\n".join(doc_lines)
752
+ parts.append(
753
+ f"\n\ndef {func_name}(**payload: Any) -> Any:\n"
754
+ f' """\n{doc}\n """\n'
755
+ f" raise NotImplementedError(\n"
756
+ f' "{module_name}.{func_name}: implement against the vendor API"\n'
757
+ f" )\n"
758
+ )
759
+
760
+ return "".join(parts)
761
+
762
+
763
+ # ───────── main.py emission ─────────
764
+
765
+
766
+ def _emit_step_pure_or_external(node: Node) -> str:
767
+ assert node.impl is not None
768
+ module_name, func_name = _impl_path_parts(node.impl)
769
+ mandatory_marker = ""
770
+ if node.mandatory:
771
+ mandatory_marker = (
772
+ " # MANDATORY: this node was marked mandatory in the source\n"
773
+ " # skill. The workflow always calls it; do not make it conditional.\n"
774
+ )
775
+ return (
776
+ f"{_step_decorator(node)}\n"
777
+ f"def {node.id}(payload: dict) -> dict:\n"
778
+ f' """{node.description.strip().splitlines()[0]}\n'
779
+ f"\n"
780
+ f" Graduated from MCP tool call → deterministic API call. See\n"
781
+ f" ``extracted.{module_name}`` for the implementation.\n"
782
+ f' """\n'
783
+ f"{mandatory_marker}"
784
+ f"{_retry_on_comment(node)}"
785
+ f"{_timeout_comment(node)}"
786
+ f" from extracted.{module_name} import {func_name}\n"
787
+ f"\n"
788
+ f" return _serialize({func_name}(**payload))\n"
789
+ )
790
+
791
+
792
+ def _emit_step_llm_judge(node: Node, cfg: DbosAdapterConfig) -> str:
793
+ pascal_parts: tuple[str, str]
794
+ if node.signature_spec is not None:
795
+ # Preferred: generated signature module (signatures/<node_id>.py).
796
+ module_name = node.id
797
+ class_name = _to_pascal_case(node.id)
798
+ import_line = f" from signatures.{module_name} import {class_name}, {class_name}Input\n"
799
+ call_lines = (
800
+ f" judge = {class_name}()\n"
801
+ f" return judge.forward({class_name}Input(**payload)).model_dump()\n"
802
+ )
803
+ else:
804
+ # Legacy Temporal-style path: user-maintained module with an
805
+ # async ``forward`` (the Temporal adapter's convention).
806
+ assert node.signature is not None
807
+ pascal_parts = _signature_path_parts(node.signature)
808
+ module_name, class_name = pascal_parts
809
+ import_line = (
810
+ f" import asyncio\n"
811
+ f"\n"
812
+ f" from {cfg.legacy_signatures_module}.{module_name} import (\n"
813
+ f" {class_name},\n"
814
+ f" {class_name}Input,\n"
815
+ f" )\n"
816
+ )
817
+ call_lines = (
818
+ f" judge = {class_name}()\n"
819
+ f" result = asyncio.run(judge.forward({class_name}Input(**payload)))\n"
820
+ f" return result.model_dump()\n"
821
+ )
822
+ return (
823
+ f"{_step_decorator(node)}\n"
824
+ f"def {node.id}(payload: dict) -> dict:\n"
825
+ f' """{node.description.strip().splitlines()[0]}\n'
826
+ f"\n"
827
+ f" LLM judge — typed input/output, bounded decision space. The\n"
828
+ f" non-determinism lives inside this step, not the workflow.\n"
829
+ f' """\n'
830
+ f"{_retry_on_comment(node)}"
831
+ f"{_timeout_comment(node)}"
832
+ f"{import_line}"
833
+ f"\n"
834
+ f"{call_lines}"
835
+ )
836
+
837
+
838
+ def _emit_step_agent_loop(node: Node) -> str:
839
+ return (
840
+ f"{_step_decorator(node)}\n"
841
+ f"def {node.id}(payload: dict) -> dict:\n"
842
+ f' """{node.description.strip().splitlines()[0]}\n'
843
+ f"\n"
844
+ f" Agent loop — bounded, tool-restricted. The stub in\n"
845
+ f" ``extracted.{node.id}`` raises until implemented against an\n"
846
+ f" agent harness.\n"
847
+ f' """\n'
848
+ f"{_retry_on_comment(node)}"
849
+ f"{_timeout_comment(node)}"
850
+ f" from extracted.{node.id} import {node.id} as _impl\n"
851
+ f"\n"
852
+ f" return _serialize(_impl(**payload))\n"
853
+ )
854
+
855
+
856
+ def _emit_hitl_wait(node: Node, pipeline: Pipeline) -> str:
857
+ assert node.signal is not None
858
+ timeout_str = node.timeout or pipeline.config.hitl.default_timeout
859
+ timeout_seconds = _duration_to_seconds(timeout_str)
860
+ return (
861
+ f" # ─── HITL gate: {node.id} ───\n"
862
+ f" # Durable receive: the workflow parks in the system database until\n"
863
+ f" # DBOS.send(workflow_id, payload, topic={node.signal!r})\n"
864
+ f" # delivers the resume message. Survives process restarts.\n"
865
+ f" {node.id}_result = DBOS.recv(\n"
866
+ f' topic="{node.signal}",\n'
867
+ f" timeout_seconds={_seconds_literal(timeout_seconds)}, # {timeout_str}\n"
868
+ f" )\n"
869
+ f" if {node.id}_result is None:\n"
870
+ f" raise TimeoutError(\n"
871
+ f' "HITL gate {node.id!r} timed out after {timeout_str} waiting "\n'
872
+ f' "for signal {node.signal!r}"\n'
873
+ f" )\n"
874
+ )
875
+
876
+
877
+ def _emit_workflow_body(pipeline: Pipeline) -> str:
878
+ waves = _execution_waves(pipeline)
879
+ lines: list[str] = []
880
+
881
+ # Node ids whose results are bound by the time each wave starts —
882
+ # used to reject inputs that reference a later wave at emit time.
883
+ # HITL gates count: their DBOS.recv payload binds `<id>_result`, so
884
+ # gate resume payloads participate as that gate's result downstream.
885
+ available: set[str] = set()
886
+
887
+ for wave_idx, wave in enumerate(waves, start=1):
888
+ non_hitl = [n for n in wave if n.kind is not NodeKind.HITL_GATE]
889
+ hitl = [n for n in wave if n.kind is NodeKind.HITL_GATE]
890
+
891
+ for n in non_hitl:
892
+ check_input_refs_available(n, available)
893
+
894
+ lines.append("")
895
+ lines.append(f" # ─── Wave {wave_idx} ───")
896
+
897
+ if len(non_hitl) == 1:
898
+ node = non_hitl[0]
899
+ payload = _payload_literal(node, indent=" " * 8)
900
+ if payload == "{}":
901
+ lines.append(f" {node.id}_result = {node.id}({{}})")
902
+ else:
903
+ lines.append(f" {node.id}_result = {node.id}(")
904
+ lines.append(f" {payload}")
905
+ lines.append(" )")
906
+ elif len(non_hitl) > 1:
907
+ lines.append(" # Parallel fan-out: enqueue every node in the wave, then")
908
+ lines.append(" # join. Each enqueued step runs as its own one-step")
909
+ lines.append(" # workflow; get_result() blocks durably.")
910
+ for node in non_hitl:
911
+ payload = _payload_literal(node, indent=" " * 8)
912
+ if payload == "{}":
913
+ lines.append(f" {node.id}_handle = queue.enqueue({node.id}, {{}})")
914
+ else:
915
+ lines.append(f" {node.id}_handle = queue.enqueue(")
916
+ lines.append(f" {node.id},")
917
+ lines.append(f" {payload},")
918
+ lines.append(" )")
919
+ for node in non_hitl:
920
+ lines.append(f" {node.id}_result = {node.id}_handle.get_result()")
921
+
922
+ for gate in hitl:
923
+ lines.append(_emit_hitl_wait(gate, pipeline).rstrip("\n"))
924
+
925
+ available.update(n.id for n in wave)
926
+
927
+ lines.append("")
928
+ lines.append(" return {")
929
+ for exit_id in pipeline.exit_nodes:
930
+ lines.append(f' "{exit_id}": {exit_id}_result,')
931
+ lines.append(" }")
932
+ return "\n".join(lines)
933
+
934
+
935
+ def emit_main(pipeline: Pipeline, cfg: DbosAdapterConfig | None = None) -> str:
936
+ """Render the main.py source for a pipeline."""
937
+ cfg = cfg or DbosAdapterConfig()
938
+
939
+ pascal = _to_pascal_case(pipeline.name)
940
+ pipeline_h = _pipeline_hash(pipeline)
941
+ workflow_name = f"{pascal}_{pipeline_h}"
942
+ queue_name = cfg.queue_name or f"{pipeline.name}-queue"
943
+ sqlite_file = f"{pipeline.name}.dbos.sqlite"
944
+ desc_first = (
945
+ pipeline.description.strip().splitlines()[0] if pipeline.description else pipeline.name
946
+ )
947
+
948
+ header = textwrap.dedent(
949
+ f'''\
950
+ """Auto-generated by rote.adapters.dbos.
951
+
952
+ Pipeline: {pipeline.name} v{pipeline.version}
953
+ Source skill: {pipeline.source_skill or "unknown"}
954
+ Pipeline hash: {pipeline_h}
955
+
956
+ DO NOT EDIT BY HAND. Re-run ``rote emit --runtime dbos`` to regenerate.
957
+
958
+ Workflow versioning: the registered workflow name includes a hash of
959
+ the pipeline so regenerated pipelines become a new workflow type.
960
+ In-flight workflows recover onto the code they started with (DBOS
961
+ recovers by workflow name + application_version); new starts use the
962
+ new code.
963
+
964
+ Architecture note: every step in this file wraps a deterministic
965
+ function from ``extracted/`` or a typed LLM signature from
966
+ ``signatures/``. None of them call MCP tools at runtime — the MCP
967
+ tool calls from the source skill were graduated into direct API
968
+ calls during the rote emission step.
969
+ """
970
+
971
+ from __future__ import annotations
972
+
973
+ import json
974
+ import os
975
+ import sys
976
+ from pathlib import Path
977
+ from typing import Any
978
+
979
+ from dbos import DBOS, DBOSConfig, Queue
980
+
981
+ # ───────── DBOS configuration ─────────
982
+ #
983
+ # Local dev runs on SQLite (zero infrastructure). Production should
984
+ # point DBOS_SYSTEM_DATABASE_URL at Postgres — SQLite is
985
+ # single-process only.
986
+ _APP_DIR = Path(__file__).resolve().parent
987
+
988
+ config: DBOSConfig = {{
989
+ "name": "{pipeline.name}",
990
+ "system_database_url": os.environ.get(
991
+ "DBOS_SYSTEM_DATABASE_URL",
992
+ f"sqlite:///{{_APP_DIR / '{sqlite_file}'}}",
993
+ ),
994
+ # The admin server (port 3001) is opt-in; enable it when you
995
+ # want the DBOS console / management API.
996
+ "run_admin_server": False,
997
+ }}
998
+ DBOS(config=config)
999
+
1000
+ # Parallel waves fan out onto this queue; each enqueued step runs as
1001
+ # a one-step workflow with its own durable handle.
1002
+ queue = Queue("{queue_name}")
1003
+
1004
+
1005
+ def _serialize(obj: Any) -> Any:
1006
+ """Convert pydantic models / tuples to plain JSON-safe values.
1007
+
1008
+ Steps return JSON-serializable payloads so workflow state stays
1009
+ portable across the system database.
1010
+ """
1011
+ if hasattr(obj, "model_dump"):
1012
+ return obj.model_dump()
1013
+ if isinstance(obj, (list, tuple)):
1014
+ return [_serialize(x) for x in obj]
1015
+ if isinstance(obj, dict):
1016
+ return {{k: _serialize(v) for k, v in obj.items()}}
1017
+ return obj
1018
+ '''
1019
+ )
1020
+
1021
+ step_parts: list[str] = []
1022
+ for node in pipeline.nodes:
1023
+ if node.kind is NodeKind.HITL_GATE:
1024
+ continue
1025
+ step_parts.append("\n\n")
1026
+ if node.kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
1027
+ step_parts.append(_emit_step_pure_or_external(node))
1028
+ elif node.kind is NodeKind.LLM_JUDGE:
1029
+ step_parts.append(_emit_step_llm_judge(node, cfg))
1030
+ elif node.kind is NodeKind.AGENT_LOOP:
1031
+ step_parts.append(_emit_step_agent_loop(node))
1032
+
1033
+ workflow_block = (
1034
+ f"\n\n@DBOS.workflow(name={json.dumps(workflow_name)})\n"
1035
+ f"def run_pipeline(pipeline_input: dict) -> dict:\n"
1036
+ f' """{desc_first}"""\n'
1037
+ f"{_emit_workflow_body(pipeline)}\n"
1038
+ )
1039
+
1040
+ main_block = textwrap.dedent(
1041
+ """\
1042
+
1043
+
1044
+ if __name__ == "__main__":
1045
+ # Launch DBOS (connects to the system database, starts queue
1046
+ # workers and recovery), start one pipeline run with the input
1047
+ # from argv[1] (JSON), and block until it completes. HITL gates
1048
+ # are resumed from another process — see README.md.
1049
+ DBOS.launch()
1050
+ try:
1051
+ pipeline_input = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
1052
+ handle = DBOS.start_workflow(run_pipeline, pipeline_input)
1053
+ print(f"workflow started: {handle.workflow_id}", file=sys.stderr)
1054
+ print(json.dumps(handle.get_result(), indent=2, default=str))
1055
+ finally:
1056
+ DBOS.destroy()
1057
+ """
1058
+ )
1059
+
1060
+ return header + "".join(step_parts) + workflow_block + main_block
1061
+
1062
+
1063
+ # ───────── dbos-config.yaml + README emission ─────────
1064
+
1065
+
1066
+ def emit_dbos_config(pipeline: Pipeline) -> str:
1067
+ """Emit dbos-config.yaml so ``dbos start`` (and DBOS Cloud) work.
1068
+
1069
+ Runtime configuration is programmatic (the ``DBOSConfig`` dict in
1070
+ main.py); this file only feeds the CLI/Cloud tooling.
1071
+ """
1072
+ return (
1073
+ "# Auto-generated by rote.adapters.dbos. Used by the `dbos` CLI and\n"
1074
+ "# DBOS Cloud; runtime config lives in main.py's DBOSConfig.\n"
1075
+ f"name: {pipeline.name}\n"
1076
+ "language: python\n"
1077
+ "runtimeConfig:\n"
1078
+ " start:\n"
1079
+ " - python3 main.py\n"
1080
+ )
1081
+
1082
+
1083
+ def emit_readme(pipeline: Pipeline, cfg: DbosAdapterConfig) -> str:
1084
+ gates = [n for n in pipeline.nodes if n.kind is NodeKind.HITL_GATE]
1085
+ gate_lines = "\n".join(
1086
+ f"| `{g.id}` | `{g.signal}` | {g.timeout or pipeline.config.hitl.default_timeout} |"
1087
+ for g in gates
1088
+ )
1089
+ first_signal = gates[0].signal if gates else "example_signal"
1090
+ return textwrap.dedent(
1091
+ f"""\
1092
+ # {pipeline.name} — DBOS runtime
1093
+
1094
+ Auto-generated by `rote emit --runtime dbos`. Do not edit generated
1095
+ files by hand; re-run the emitter to regenerate.
1096
+
1097
+ DBOS is durable execution as a library: there is no orchestrator to
1098
+ deploy. `main.py` checkpoints every step to a system database and
1099
+ resumes from the last completed step after a crash.
1100
+
1101
+ ## Layout
1102
+
1103
+ - `main.py` — the `@DBOS.workflow` DAG plus one `@DBOS.step` per node
1104
+ - `extracted/` — stubs for deterministic nodes; fill these in with
1105
+ direct vendor API calls (`NotImplementedError` until you do)
1106
+ - `signatures/` — typed LLM judges generated from the pipeline IR
1107
+ (Pydantic models + direct vendor SDK calls)
1108
+ - `dbos-config.yaml` — CLI/Cloud tooling config for `dbos start`
1109
+
1110
+ ## Run locally
1111
+
1112
+ ```sh
1113
+ pip install dbos
1114
+ python main.py '{{"your": "input"}}'
1115
+ ```
1116
+
1117
+ By default the system database is a SQLite file next to `main.py` —
1118
+ zero infrastructure, ideal for development. For production, point
1119
+ DBOS at Postgres (SQLite is single-process only):
1120
+
1121
+ ```sh
1122
+ export DBOS_SYSTEM_DATABASE_URL="postgresql://user:pass@host:5432/db"
1123
+ python main.py '{{...}}' # or: dbos start
1124
+ ```
1125
+
1126
+ LLM judge steps call the vendor SDK directly and read the standard
1127
+ `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` environment variables.
1128
+
1129
+ ## HITL gates
1130
+
1131
+ The workflow parks durably at each gate until a message arrives on
1132
+ the gate's topic:
1133
+
1134
+ | Gate | Signal (topic) | Timeout |
1135
+ | --- | --- | --- |
1136
+ {gate_lines}
1137
+
1138
+ Resume a parked workflow from any process that can reach the system
1139
+ database:
1140
+
1141
+ ```python
1142
+ from dbos import DBOSClient
1143
+
1144
+ client = DBOSClient(system_database_url="...")
1145
+ client.send(workflow_id, {{"approved": True}}, topic="{first_signal}")
1146
+ ```
1147
+
1148
+ (In-process, `DBOS.send(workflow_id, payload, topic=...)` does the
1149
+ same.) A gate that times out raises `TimeoutError` and fails the
1150
+ run — silence is not approval.
1151
+ """
1152
+ )
1153
+
1154
+
1155
+ # ───────── Adapter facade ─────────
1156
+
1157
+
1158
+ class DbosAdapter:
1159
+ """Facade that emits a DBOS application from a Pipeline IR.
1160
+
1161
+ Output layout::
1162
+
1163
+ out/
1164
+ main.py
1165
+ dbos-config.yaml
1166
+ README.md
1167
+ extracted/__init__.py
1168
+ extracted/<module>.py
1169
+ signatures/__init__.py
1170
+ signatures/<llm_judge_id>.py
1171
+
1172
+ The directory runs with ``python main.py`` once the user fills in the
1173
+ extracted/ stubs (SQLite system DB by default; Postgres via
1174
+ ``DBOS_SYSTEM_DATABASE_URL``).
1175
+ """
1176
+
1177
+ def __init__(self, config: DbosAdapterConfig | None = None) -> None:
1178
+ self.config = config or DbosAdapterConfig()
1179
+
1180
+ def emit_main(self, pipeline: Pipeline) -> str:
1181
+ return emit_main(pipeline, self.config)
1182
+
1183
+ def emit(self, pipeline: Pipeline, output_dir: str | Path) -> dict[str, Path]:
1184
+ out = Path(output_dir)
1185
+ extracted_dir = out / "extracted"
1186
+ signatures_dir = out / "signatures"
1187
+ out.mkdir(parents=True, exist_ok=True)
1188
+
1189
+ written: dict[str, Path] = {}
1190
+
1191
+ main_path = out / "main.py"
1192
+ main_path.write_text(self.emit_main(pipeline), encoding="utf-8")
1193
+ written["main"] = main_path
1194
+
1195
+ extracted_modules = _extracted_layout(pipeline)
1196
+ if extracted_modules:
1197
+ extracted_dir.mkdir(exist_ok=True)
1198
+ init = extracted_dir / "__init__.py"
1199
+ init.write_text(
1200
+ f'"""Extracted deterministic modules for {pipeline.name}."""\n',
1201
+ encoding="utf-8",
1202
+ )
1203
+ written["extracted/__init__"] = init
1204
+ for module_name, nodes in sorted(extracted_modules.items()):
1205
+ p = extracted_dir / f"{module_name}.py"
1206
+ p.write_text(emit_extracted_module(module_name, nodes), encoding="utf-8")
1207
+ written[f"extracted/{module_name}"] = p
1208
+
1209
+ spec_judges = [
1210
+ n
1211
+ for n in pipeline.nodes
1212
+ if n.kind is NodeKind.LLM_JUDGE and n.signature_spec is not None
1213
+ ]
1214
+ if spec_judges:
1215
+ signatures_dir.mkdir(exist_ok=True)
1216
+ init = signatures_dir / "__init__.py"
1217
+ init.write_text(
1218
+ f'"""Generated LLM signatures for {pipeline.name}."""\n',
1219
+ encoding="utf-8",
1220
+ )
1221
+ written["signatures/__init__"] = init
1222
+ for node in spec_judges:
1223
+ p = signatures_dir / f"{node.id}.py"
1224
+ p.write_text(emit_signature_module(node, self.config), encoding="utf-8")
1225
+ written[f"signatures/{node.id}"] = p
1226
+
1227
+ config_path = out / "dbos-config.yaml"
1228
+ config_path.write_text(emit_dbos_config(pipeline), encoding="utf-8")
1229
+ written["dbos-config"] = config_path
1230
+
1231
+ readme_path = out / "README.md"
1232
+ readme_path.write_text(emit_readme(pipeline, self.config), encoding="utf-8")
1233
+ written["README"] = readme_path
1234
+
1235
+ return written