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/ir.py ADDED
@@ -0,0 +1,495 @@
1
+ """Intermediate representation for graduated skills.
2
+
3
+ The IR is a runtime-agnostic DAG of typed nodes. Adapters
4
+ (``rote/adapters/temporal.py``, etc.) consume an :class:`Pipeline` and emit
5
+ runnable code for a specific durable execution engine.
6
+
7
+ The schema is intentionally driven by what real complex skills (BDR
8
+ outreach is the canonical example) need to express. Adding fields here
9
+ should always be motivated by a concrete skill that needs them, not
10
+ speculation.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from enum import StrEnum
18
+ from pathlib import Path
19
+ from typing import Any, Self
20
+
21
+ import yaml
22
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
23
+
24
+ # ───────── Data-flow input references ─────────
25
+ #
26
+ # A node declares where its runtime inputs come from via ``inputs:``,
27
+ # a mapping of parameter name → source reference. The grammar is
28
+ # deliberately tiny — four forms, no expression language:
29
+ #
30
+ # pipeline.input the whole pipeline input payload
31
+ # pipeline.input.<field> one top-level field of the pipeline input
32
+ # <node_id>.output the whole output of an upstream node
33
+ # <node_id>.output.<field> one top-level field of an upstream node's output
34
+ #
35
+ # Anything fancier (aggregation, arithmetic, deep paths) belongs in an
36
+ # extracted pure_function node, not in the reference syntax.
37
+
38
+ _INPUT_REF_RE = re.compile(
39
+ r"^(?:pipeline\.input|(?P<node>[A-Za-z_][A-Za-z0-9_]*)\.output)"
40
+ r"(?:\.(?P<field>[A-Za-z_][A-Za-z0-9_]*))?$"
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class InputRef:
46
+ """A parsed ``inputs:`` source reference.
47
+
48
+ ``node_id is None`` means the reference targets the pipeline input;
49
+ otherwise it targets the named node's output. ``field`` is the
50
+ optional top-level field selector.
51
+ """
52
+
53
+ node_id: str | None
54
+ field: str | None
55
+
56
+
57
+ def parse_input_ref(ref: str) -> InputRef:
58
+ """Parse an ``inputs:`` source reference string.
59
+
60
+ This is the single source of truth for the reference grammar —
61
+ the IR validator and every adapter resolve references through it.
62
+ Raises ``ValueError`` for anything outside the four allowed forms.
63
+ """
64
+ m = _INPUT_REF_RE.fullmatch(ref.strip())
65
+ if not m:
66
+ raise ValueError(
67
+ f"Invalid input reference {ref!r}. Allowed forms: "
68
+ f"'pipeline.input', 'pipeline.input.<field>', "
69
+ f"'<node_id>.output', '<node_id>.output.<field>'."
70
+ )
71
+ return InputRef(node_id=m.group("node"), field=m.group("field"))
72
+
73
+
74
+ class NodeKind(StrEnum):
75
+ """The five kinds a graduated step can be classified as.
76
+
77
+ Every node in a graduated pipeline is exactly one kind. Adapters
78
+ decide how to emit each kind for their target runtime.
79
+ """
80
+
81
+ PURE_FUNCTION = "pure_function"
82
+ LLM_JUDGE = "llm_judge"
83
+ AGENT_LOOP = "agent_loop"
84
+ HITL_GATE = "hitl_gate"
85
+ EXTERNAL_CALL = "external_call"
86
+
87
+
88
+ class RetryPolicy(BaseModel):
89
+ """Retry behavior for a node. Adapters map this onto runtime primitives."""
90
+
91
+ model_config = ConfigDict(extra="forbid")
92
+
93
+ max: int = Field(ge=0, description="Maximum retry attempts (0 = no retry)")
94
+ backoff: str = Field(default="exponential", description="linear | exponential | constant")
95
+ retry_on: list[str] | None = Field(
96
+ default=None,
97
+ description=(
98
+ "Error categories to retry on. None = retry all transient errors. "
99
+ "Named retry_on (not 'on') because YAML 1.1 parses bare 'on' as a boolean."
100
+ ),
101
+ )
102
+
103
+
104
+ class CacheConfig(BaseModel):
105
+ """Caching for nodes whose output is stable across runs (e.g., taxonomy IDs)."""
106
+
107
+ model_config = ConfigDict(extra="forbid")
108
+
109
+ strategy: str = Field(description="persistent | memory")
110
+ ttl: str = Field(description="Duration string, e.g., '30d', '1h'")
111
+
112
+
113
+ class TerminationConfig(BaseModel):
114
+ """Termination criteria for an agent_loop node."""
115
+
116
+ model_config = ConfigDict(extra="forbid")
117
+
118
+ condition: str = Field(description="Human-readable termination condition")
119
+ max_iterations: int = Field(ge=1, description="Hard upper bound on iterations")
120
+
121
+
122
+ class NotifyConfig(BaseModel):
123
+ """How a hitl_gate notifies the human reviewer."""
124
+
125
+ model_config = ConfigDict(extra="forbid")
126
+
127
+ channel: str = Field(description="slack | email | webhook")
128
+ target: str = Field(description="Channel name, email address, or webhook URL")
129
+ message_template: str | None = Field(
130
+ default=None,
131
+ description="Template string with {placeholders} from upstream node outputs",
132
+ )
133
+
134
+
135
+ class LLMSignature(BaseModel):
136
+ """Runtime-agnostic, structured form of an llm_judge signature.
137
+
138
+ Carries everything an adapter needs to emit a working LLM call in any
139
+ target language: JSON Schema for the I/O contract (which Pydantic and
140
+ Zod both derive cleanly from), a prompt template, and the vendor
141
+ client config. The legacy ``signature: 'path/to/file.py:Class'`` form
142
+ on :class:`Node` continues to work for Temporal back-compat; new
143
+ runtimes (Cloudflare Workflows, future TS targets) require this
144
+ structured form because there's no shared Python module to import.
145
+ """
146
+
147
+ model_config = ConfigDict(extra="forbid")
148
+
149
+ input_schema: dict[str, Any] = Field(
150
+ description="JSON Schema for the typed input. Adapters convert to Pydantic / Zod.",
151
+ )
152
+ output_schema: dict[str, Any] = Field(
153
+ description="JSON Schema for the typed output. Adapters convert to Pydantic / Zod.",
154
+ )
155
+ prompt: str = Field(
156
+ description=(
157
+ "Prompt template (Jinja-style {{ var }} interpolation). "
158
+ "Variables resolve against the input schema's properties."
159
+ ),
160
+ )
161
+ client: str = Field(
162
+ default="anthropic",
163
+ description="LLM vendor identifier. Currently 'anthropic' | 'openai'.",
164
+ )
165
+ model: str | None = Field(
166
+ default=None,
167
+ description="Vendor-specific model id. None = adapter chooses default.",
168
+ )
169
+ temperature: float | None = Field(
170
+ default=None,
171
+ ge=0.0,
172
+ le=2.0,
173
+ description="Sampling temperature; None = vendor default.",
174
+ )
175
+
176
+ @field_validator("client")
177
+ @classmethod
178
+ def _validate_client(cls, v: str) -> str:
179
+ allowed = {"anthropic", "openai"}
180
+ if v not in allowed:
181
+ raise ValueError(f"client must be one of {sorted(allowed)}, got {v!r}")
182
+ return v
183
+
184
+
185
+ class Node(BaseModel):
186
+ """A single step in the graduated pipeline.
187
+
188
+ The fields used depend on the node ``kind``. Cross-field validation
189
+ enforces that, e.g., ``llm_judge`` nodes have a ``signature`` and
190
+ ``hitl_gate`` nodes have a ``signal``.
191
+ """
192
+
193
+ model_config = ConfigDict(extra="forbid")
194
+
195
+ # Common fields
196
+ id: str = Field(description="Unique identifier within the pipeline")
197
+ kind: NodeKind
198
+ phase: str | None = Field(
199
+ default=None,
200
+ description="Metadata pointing back to the source skill's phase number",
201
+ )
202
+
203
+ @field_validator("phase", mode="before")
204
+ @classmethod
205
+ def _coerce_phase_to_string(cls, v: Any) -> str | None:
206
+ """BDR has 'Phase 1.5' which YAML parses as a float; coerce to string."""
207
+ if v is None:
208
+ return None
209
+ return str(v)
210
+
211
+ description: str = Field(description="What this node does, in prose")
212
+ input: dict[str, str] = Field(
213
+ default_factory=dict,
214
+ description="Input field name → type name (free-form for v0)",
215
+ )
216
+ inputs: dict[str, str] | None = Field(
217
+ default=None,
218
+ description=(
219
+ "Data-flow bindings: parameter name → source reference. "
220
+ "References use the grammar in parse_input_ref: 'pipeline.input', "
221
+ "'pipeline.input.<field>', '<node_id>.output', or "
222
+ "'<node_id>.output.<field>'. Complements ``input`` (which documents "
223
+ "types); ``inputs`` binds where the values come from at runtime. "
224
+ "Nodes without ``inputs`` receive an empty payload (back-compat). "
225
+ "For loop_body sub-nodes the bindings describe what the parent "
226
+ "loop passes per iteration — adapters do not resolve them at the "
227
+ "top level."
228
+ ),
229
+ )
230
+ output: dict[str, str] | str = Field(
231
+ default_factory=dict,
232
+ description="Output field name → type name, or a single type name",
233
+ )
234
+ timeout: str | None = Field(
235
+ default=None,
236
+ description="Duration string, e.g., '5m', '60s'",
237
+ )
238
+ retry: RetryPolicy | None = None
239
+ mandatory: bool = Field(
240
+ default=False,
241
+ description="If true, this node cannot be skipped or made conditional",
242
+ )
243
+ constants: dict[str, Any] | None = Field(
244
+ default=None,
245
+ description="Hard-coded constants extracted from the source skill",
246
+ )
247
+ cache: CacheConfig | None = None
248
+ fan_out: bool = Field(
249
+ default=False,
250
+ description="If true, this node is invoked once per input element",
251
+ )
252
+
253
+ # pure_function / external_call fields
254
+ impl: str | None = Field(
255
+ default=None,
256
+ description="Path to extracted function, e.g., 'extracted/foo.py:bar'",
257
+ )
258
+
259
+ # llm_judge fields
260
+ signature: str | None = Field(
261
+ default=None,
262
+ description=(
263
+ "Legacy: path to a Python signature class, e.g., 'signatures/foo.py:Foo'. "
264
+ "The Temporal adapter accepts this for back-compat. New runtimes "
265
+ "(Cloudflare, etc.) require ``signature_spec`` instead — see below."
266
+ ),
267
+ )
268
+ signature_spec: LLMSignature | None = Field(
269
+ default=None,
270
+ description=(
271
+ "Structured, runtime-agnostic signature: JSON Schema in/out + prompt "
272
+ "+ client config. Required for non-Python adapters; optional for "
273
+ "Temporal (which can fall back to ``signature`` path)."
274
+ ),
275
+ )
276
+ eval_set: str | None = Field(
277
+ default=None,
278
+ description="Path to seed eval set",
279
+ )
280
+
281
+ # agent_loop fields
282
+ tools: list[str] | None = Field(
283
+ default=None,
284
+ description="MCP tool names the agent can call inside the loop",
285
+ )
286
+ loop_body: list[str] | None = Field(
287
+ default=None,
288
+ description="IDs of nodes invoked from inside each loop iteration",
289
+ )
290
+ termination: TerminationConfig | None = None
291
+
292
+ # hitl_gate fields
293
+ signal: str | None = Field(
294
+ default=None,
295
+ description="Signal name the workflow waits for (resume on signal)",
296
+ )
297
+ notify: NotifyConfig | None = None
298
+
299
+ @model_validator(mode="after")
300
+ def _validate_kind_specific_fields(self) -> Self:
301
+ """Each node kind has required fields. Enforce them here."""
302
+ kind = self.kind
303
+ missing: list[str] = []
304
+
305
+ if kind in (NodeKind.PURE_FUNCTION, NodeKind.EXTERNAL_CALL):
306
+ if not self.impl:
307
+ missing.append("impl")
308
+ elif kind is NodeKind.LLM_JUDGE:
309
+ # Either the legacy path or the structured spec is acceptable.
310
+ # Both being absent is the failure mode.
311
+ if not self.signature and self.signature_spec is None:
312
+ missing.append("signature")
313
+ elif kind is NodeKind.AGENT_LOOP:
314
+ if not self.tools:
315
+ missing.append("tools")
316
+ elif kind is NodeKind.HITL_GATE and not self.signal:
317
+ missing.append("signal")
318
+
319
+ if missing:
320
+ raise ValueError(
321
+ f"Node {self.id!r} of kind {kind.value} is missing required field(s): "
322
+ f"{', '.join(missing)}"
323
+ )
324
+
325
+ # mandatory only meaningful for non-agentic kinds (you can't make an
326
+ # agent loop "mandatory" — that's a no-op)
327
+ if self.mandatory and kind is NodeKind.AGENT_LOOP:
328
+ raise ValueError(f"Node {self.id!r}: mandatory=true is not allowed on agent_loop nodes")
329
+
330
+ return self
331
+
332
+
333
+ class Edge(BaseModel):
334
+ """A directed edge in the pipeline DAG."""
335
+
336
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
337
+
338
+ from_: str = Field(alias="from", description="Source node id")
339
+ to: str = Field(description="Destination node id")
340
+ on_signal: str | None = Field(
341
+ default=None,
342
+ description="If set, edge only activates when the named signal fires",
343
+ )
344
+ fan_out: bool = Field(
345
+ default=False,
346
+ description="If true, the destination is invoked once per element of the source's output",
347
+ )
348
+
349
+
350
+ class ObservabilityConfig(BaseModel):
351
+ model_config = ConfigDict(extra="forbid")
352
+
353
+ traces: bool = True
354
+ eval_set_dir: str | None = None
355
+
356
+
357
+ class HITLConfig(BaseModel):
358
+ model_config = ConfigDict(extra="forbid")
359
+
360
+ default_timeout: str = "7d"
361
+
362
+
363
+ class PipelineConfig(BaseModel):
364
+ model_config = ConfigDict(extra="forbid")
365
+
366
+ schedule: str | None = None
367
+ on_failure: str = "notify_owner"
368
+ observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
369
+ hitl: HITLConfig = Field(default_factory=HITLConfig)
370
+
371
+
372
+ class PipelineInput(BaseModel):
373
+ """The pipeline's input contract."""
374
+
375
+ model_config = ConfigDict(extra="forbid")
376
+
377
+ type: str = Field(description="Type name for the pipeline input")
378
+ required: list[str] = Field(default_factory=list)
379
+ optional: list[str] = Field(default_factory=list)
380
+ input_schema: dict[str, Any] | None = Field(
381
+ default=None,
382
+ description=(
383
+ "JSON Schema for the pipeline input payload (the same schema "
384
+ "Pydantic emits via model_json_schema()). Runtime-agnostic: "
385
+ "adapters may derive input validation from it before the "
386
+ "workflow starts. Optional for back-compat with pipelines "
387
+ "that only declare required/optional field names."
388
+ ),
389
+ )
390
+
391
+
392
+ class Pipeline(BaseModel):
393
+ """A graduated skill, ready to be emitted into a runtime adapter."""
394
+
395
+ model_config = ConfigDict(extra="forbid")
396
+
397
+ name: str
398
+ version: str = "0.1.0"
399
+ source_skill: str | None = Field(
400
+ default=None,
401
+ description="Path to the source skill bundle this was graduated from",
402
+ )
403
+ description: str = ""
404
+ config: PipelineConfig = Field(default_factory=PipelineConfig)
405
+ input: PipelineInput
406
+ nodes: list[Node]
407
+ edges: list[Edge]
408
+ entry_nodes: list[str] = Field(default_factory=list)
409
+ exit_nodes: list[str] = Field(default_factory=list)
410
+
411
+ @model_validator(mode="after")
412
+ def _validate_dag_integrity(self) -> Self:
413
+ """Cross-references must point at real nodes; entry/exit must exist."""
414
+ node_ids = {n.id for n in self.nodes}
415
+ if len(node_ids) != len(self.nodes):
416
+ seen: set[str] = set()
417
+ dups = [n.id for n in self.nodes if n.id in seen or seen.add(n.id)] # type: ignore[func-returns-value]
418
+ raise ValueError(f"Duplicate node ids: {dups}")
419
+
420
+ for edge in self.edges:
421
+ if edge.from_ not in node_ids:
422
+ raise ValueError(f"Edge from unknown node: {edge.from_!r}")
423
+ if edge.to not in node_ids:
424
+ raise ValueError(f"Edge to unknown node: {edge.to!r}")
425
+
426
+ for entry in self.entry_nodes:
427
+ if entry not in node_ids:
428
+ raise ValueError(f"entry_nodes references unknown node: {entry!r}")
429
+ for exit_id in self.exit_nodes:
430
+ if exit_id not in node_ids:
431
+ raise ValueError(f"exit_nodes references unknown node: {exit_id!r}")
432
+
433
+ # loop_body references must also be valid node IDs
434
+ for node in self.nodes:
435
+ if node.loop_body:
436
+ for body_id in node.loop_body:
437
+ if body_id not in node_ids:
438
+ raise ValueError(
439
+ f"Node {node.id!r} loop_body references unknown node: {body_id!r}"
440
+ )
441
+
442
+ # inputs references must parse and point at real sources.
443
+ # Pipeline-input field names are checked against the declared
444
+ # contract (required + optional + input_schema properties) when
445
+ # one exists; a pipeline with an empty contract skips the check.
446
+ declared_fields = set(self.input.required) | set(self.input.optional)
447
+ if self.input.input_schema is not None:
448
+ props = self.input.input_schema.get("properties")
449
+ if isinstance(props, dict):
450
+ declared_fields |= set(props.keys())
451
+
452
+ for node in self.nodes:
453
+ if not node.inputs:
454
+ continue
455
+ for param, ref in node.inputs.items():
456
+ try:
457
+ parsed = parse_input_ref(ref)
458
+ except ValueError as e:
459
+ raise ValueError(f"Node {node.id!r} input {param!r}: {e}") from e
460
+ if parsed.node_id is None:
461
+ if parsed.field and declared_fields and parsed.field not in declared_fields:
462
+ raise ValueError(
463
+ f"Node {node.id!r} input {param!r} references pipeline input "
464
+ f"field {parsed.field!r}, which is not declared in the "
465
+ f"pipeline's input contract"
466
+ )
467
+ else:
468
+ if parsed.node_id not in node_ids:
469
+ raise ValueError(
470
+ f"Node {node.id!r} input {param!r} references unknown "
471
+ f"node: {parsed.node_id!r}"
472
+ )
473
+ if parsed.node_id == node.id:
474
+ raise ValueError(
475
+ f"Node {node.id!r} input {param!r} references its own output"
476
+ )
477
+
478
+ return self
479
+
480
+ def node_by_id(self, node_id: str) -> Node:
481
+ for n in self.nodes:
482
+ if n.id == node_id:
483
+ return n
484
+ raise KeyError(node_id)
485
+
486
+ def nodes_by_kind(self, kind: NodeKind) -> list[Node]:
487
+ return [n for n in self.nodes if n.kind is kind]
488
+
489
+
490
+ def load_pipeline(path: str | Path) -> Pipeline:
491
+ """Load and validate a pipeline.yaml file."""
492
+ p = Path(path)
493
+ with p.open("r", encoding="utf-8") as f:
494
+ raw = yaml.safe_load(f)
495
+ return Pipeline.model_validate(raw)
rote/serve/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """``rote serve`` — expose graduated pipelines as MCP tools.
2
+
3
+ Three modules:
4
+
5
+ * :mod:`rote.serve.registry` — the manifest registry (``~/.rote/registry.json``)
6
+ mapping tool names to graduated pipelines and their runtime trigger config.
7
+ * :mod:`rote.serve.backends` — runtime trigger backends (Temporal, Cloudflare)
8
+ that start a graduated workflow and poll its status.
9
+ * :mod:`rote.serve.server` — the FastMCP server that sources one MCP tool
10
+ (plus a ``<tool>_status`` companion) per registry entry.
11
+
12
+ The heavy dependency (``fastmcp``) is only imported by :mod:`rote.serve.server`
13
+ so that ``rote register`` works without the ``serve`` extra installed.
14
+ """
rote/serve/backends.py ADDED
@@ -0,0 +1,162 @@
1
+ """Runtime trigger backends for ``rote serve``.
2
+
3
+ Each registry entry names a runtime trigger (Temporal or Cloudflare).
4
+ This module knows how to *start* a graduated workflow on that runtime
5
+ and how to *poll* its status. Tool calls never block on workflow
6
+ completion — graduated pipelines run for minutes to days (HITL gates),
7
+ and their durability lives in the workflow engine, not in this process.
8
+ Starting returns ``{workflow_id, status: "started", ...}`` immediately;
9
+ the companion ``<tool>_status`` MCP tool polls via :func:`workflow_status`.
10
+
11
+ Clients are connected lazily, per call, so ``rote serve`` starts (and
12
+ lists tools) even when the runtime is unreachable — the error surfaces
13
+ on the tool call that needs it, with a message that says exactly what
14
+ was unreachable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import uuid
21
+ from typing import Any
22
+
23
+ from rote.serve.registry import CloudflareTrigger, RegistryEntry, TemporalTrigger
24
+
25
+ #: How long to wait for a Temporal frontend connection before declaring
26
+ #: it unreachable. Temporal's client otherwise retries indefinitely.
27
+ TEMPORAL_CONNECT_TIMEOUT_S = 10.0
28
+
29
+ #: HTTP timeout for Cloudflare worker trigger/status calls.
30
+ CLOUDFLARE_HTTP_TIMEOUT_S = 30.0
31
+
32
+
33
+ class BackendError(RuntimeError):
34
+ """A runtime trigger failed in a way the MCP client should see verbatim."""
35
+
36
+
37
+ # ───────── Public dispatch ─────────
38
+
39
+
40
+ async def start_workflow(entry: RegistryEntry, payload: dict[str, Any]) -> dict[str, Any]:
41
+ """Start the graduated workflow behind ``entry`` with ``payload`` as input."""
42
+ trigger = entry.trigger
43
+ if isinstance(trigger, TemporalTrigger):
44
+ return await _start_temporal(trigger, entry.name, payload)
45
+ return await _start_cloudflare(trigger, payload)
46
+
47
+
48
+ async def workflow_status(entry: RegistryEntry, workflow_id: str) -> dict[str, Any]:
49
+ """Poll the status of a previously started workflow."""
50
+ trigger = entry.trigger
51
+ if isinstance(trigger, TemporalTrigger):
52
+ return await _status_temporal(trigger, workflow_id)
53
+ return await _status_cloudflare(trigger, workflow_id)
54
+
55
+
56
+ # ───────── Temporal ─────────
57
+
58
+
59
+ async def _temporal_client(trigger: TemporalTrigger) -> Any:
60
+ try:
61
+ from temporalio.client import Client
62
+ except ImportError as e: # pragma: no cover - environment-dependent
63
+ raise BackendError(
64
+ "temporalio is not installed. Install the Temporal extra: pip install 'rote[temporal]'"
65
+ ) from e
66
+
67
+ try:
68
+ return await asyncio.wait_for(
69
+ Client.connect(trigger.address, namespace=trigger.namespace),
70
+ timeout=TEMPORAL_CONNECT_TIMEOUT_S,
71
+ )
72
+ except TimeoutError as e:
73
+ raise BackendError(
74
+ f"Temporal at {trigger.address} (namespace {trigger.namespace!r}) is "
75
+ f"unreachable: connection timed out after {TEMPORAL_CONNECT_TIMEOUT_S:.0f}s"
76
+ ) from e
77
+ except Exception as e:
78
+ raise BackendError(
79
+ f"Temporal at {trigger.address} (namespace {trigger.namespace!r}) is unreachable: {e}"
80
+ ) from e
81
+
82
+
83
+ async def _start_temporal(
84
+ trigger: TemporalTrigger, tool_name: str, payload: dict[str, Any]
85
+ ) -> dict[str, Any]:
86
+ client = await _temporal_client(trigger)
87
+ workflow_id = f"{tool_name}-{uuid.uuid4().hex[:12]}"
88
+ handle = await client.start_workflow(
89
+ trigger.workflow_name,
90
+ payload,
91
+ id=workflow_id,
92
+ task_queue=trigger.task_queue,
93
+ )
94
+ return {
95
+ "workflow_id": workflow_id,
96
+ "run_id": handle.first_execution_run_id or "",
97
+ "status": "started",
98
+ "runtime": "temporal",
99
+ }
100
+
101
+
102
+ async def _status_temporal(trigger: TemporalTrigger, workflow_id: str) -> dict[str, Any]:
103
+ client = await _temporal_client(trigger)
104
+ handle = client.get_workflow_handle(workflow_id)
105
+ description = await handle.describe()
106
+ status = description.status.name.lower() if description.status else "unknown"
107
+ return {
108
+ "workflow_id": workflow_id,
109
+ "status": status,
110
+ "runtime": "temporal",
111
+ }
112
+
113
+
114
+ # ───────── Cloudflare ─────────
115
+
116
+
117
+ async def _start_cloudflare(trigger: CloudflareTrigger, payload: dict[str, Any]) -> dict[str, Any]:
118
+ import httpx
119
+
120
+ try:
121
+ async with httpx.AsyncClient(timeout=CLOUDFLARE_HTTP_TIMEOUT_S) as http:
122
+ resp = await http.post(trigger.url, json=payload)
123
+ resp.raise_for_status()
124
+ data = resp.json()
125
+ except httpx.HTTPError as e:
126
+ raise BackendError(f"Cloudflare worker at {trigger.url} failed: {e}") from e
127
+
128
+ # The emitted src/index.ts responds with {id, status}.
129
+ return {
130
+ "workflow_id": str(data.get("id", "")),
131
+ "status": "started",
132
+ "runtime": "cloudflare",
133
+ "details": data.get("status"),
134
+ }
135
+
136
+
137
+ async def _status_cloudflare(trigger: CloudflareTrigger, workflow_id: str) -> dict[str, Any]:
138
+ if trigger.status_url is None:
139
+ raise BackendError(
140
+ "This Cloudflare worker has no status endpoint registered. The emitted "
141
+ "worker's default fetch handler only creates instances; check status with "
142
+ f"`wrangler workflows instances describe <workflow> {workflow_id}` or the "
143
+ "Cloudflare dashboard, or register with --cloudflare-status-url if the "
144
+ "worker exposes a status route."
145
+ )
146
+
147
+ import httpx
148
+
149
+ url = trigger.status_url.format(workflow_id=workflow_id)
150
+ try:
151
+ async with httpx.AsyncClient(timeout=CLOUDFLARE_HTTP_TIMEOUT_S) as http:
152
+ resp = await http.get(url)
153
+ resp.raise_for_status()
154
+ data = resp.json()
155
+ except httpx.HTTPError as e:
156
+ raise BackendError(f"Cloudflare status endpoint at {url} failed: {e}") from e
157
+
158
+ return {
159
+ "workflow_id": workflow_id,
160
+ "status": data.get("status", data),
161
+ "runtime": "cloudflare",
162
+ }