Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ async def main():
asyncio.run(main())
```

## Code-first workflow DSL

```python
from orch8 import workflow

checkout = (
workflow("checkout")
.step("charge", "charge", {"customer_id": "cus_123", "cents": 2500})
.parallel(
"notify",
lambda branch: branch.step("email", "send-email", {"template": "receipt"}),
lambda branch: branch.step("audit", "write-audit", {}),
)
.build()
)
```

The builder covers all eleven block types and emits the same JSON accepted by
`create_sequence`; use ordinary `TypedDict` values for handler-specific params.

```python
engine_info = await client.request("GET", "/info")
```
Expand Down
5 changes: 5 additions & 0 deletions src/orch8/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from importlib.metadata import version as _version

from .client import Orch8Client
from .builder import WorkflowBuilder, workflow
from .adapters import durable_agent_handler
from .errors import Orch8Error
from .types import (
AddResourceRequest,
Expand Down Expand Up @@ -113,6 +115,9 @@
"Orch8Client",
"Orch8Error",
"Orch8Worker",
"WorkflowBuilder",
"workflow",
"durable_agent_handler",
"PluginDef",
"PoolResource",
"RegisterDeviceRequest",
Expand Down
28 changes: 28 additions & 0 deletions src/orch8/adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Framework-neutral durable worker adapters."""

from __future__ import annotations

import inspect
from collections.abc import Callable
from typing import Any


def durable_agent_handler(runner: Any) -> Callable[[Any], Any]:
"""Adapt LangGraph/CrewAI/AutoGen-style runners to an Orch8 handler."""

async def handle(task: Any) -> Any:
params = task.params if hasattr(task, "params") else task["params"]
instance_id = getattr(task, "instance_id", None) or (
task.get("instance_id") if isinstance(task, dict) else None
)
config = {"configurable": {"thread_id": instance_id}} if instance_id else None
if callable(getattr(runner, "ainvoke", None)):
return await runner.ainvoke(params, config)
for name in ("invoke", "kickoff", "run"):
method = getattr(runner, name, None)
if callable(method):
result = method(params, config) if name == "invoke" else method(params)
return await result if inspect.isawaitable(result) else result
raise TypeError("agent runner must implement ainvoke, invoke, kickoff, or run")

return handle
178 changes: 178 additions & 0 deletions src/orch8/builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Fluent, dependency-free builder for the Orch8 workflow JSON DSL."""

from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from typing import Any

Block = dict[str, Any]
Branch = Callable[["WorkflowBuilder"], None]


class WorkflowBuilder:
def __init__(self, name: str, namespace: str = "default") -> None:
if not name:
raise ValueError("workflow name cannot be empty")
self.name = name
self.namespace = namespace
self._items: list[Block] = []

def step(
self,
id: str,
handler: str,
params: Mapping[str, Any] | None = None,
**options: Any,
) -> WorkflowBuilder:
self._items.append(
{"type": "step", "id": id, "handler": handler, "params": dict(params or {}), **options}
)
return self

def parallel(self, id: str, *branches: Branch) -> WorkflowBuilder:
self._items.append(
{"type": "parallel", "id": id, "branches": [self._branch(fn) for fn in branches]}
)
return self

def race(self, id: str, *branches: Branch, semantics: str | None = None) -> WorkflowBuilder:
block: Block = {"type": "race", "id": id, "branches": [self._branch(fn) for fn in branches]}
if semantics is not None:
block["semantics"] = semantics
self._items.append(block)
return self

def loop(
self, id: str, condition: str, body: Branch, *, max_iterations: int = 1000, **options: Any
) -> WorkflowBuilder:
self._items.append(
{
"type": "loop",
"id": id,
"condition": condition,
"body": self._branch(body),
"max_iterations": max_iterations,
**options,
}
)
return self

def for_each(
self, id: str, collection: str, body: Branch, *, item_var: str = "item", **options: Any
) -> WorkflowBuilder:
self._items.append(
{
"type": "for_each",
"id": id,
"collection": collection,
"item_var": item_var,
"body": self._branch(body),
**options,
}
)
return self

def router(
self,
id: str,
routes: Sequence[tuple[str, Branch]],
*,
default: Branch | None = None,
) -> WorkflowBuilder:
block: Block = {
"type": "router",
"id": id,
"routes": [
{"condition": condition, "blocks": self._branch(branch)}
for condition, branch in routes
],
}
if default is not None:
block["default"] = self._branch(default)
self._items.append(block)
return self

def try_catch(
self,
id: str,
try_block: Branch,
catch_block: Branch,
*,
finally_block: Branch | None = None,
) -> WorkflowBuilder:
block: Block = {
"type": "try_catch",
"id": id,
"try_block": self._branch(try_block),
"catch_block": self._branch(catch_block),
}
if finally_block is not None:
block["finally_block"] = self._branch(finally_block)
self._items.append(block)
return self

def sub_sequence(
self, id: str, sequence_name: str, *, version: int | None = None, input: Any = None
) -> WorkflowBuilder:
block: Block = {"type": "sub_sequence", "id": id, "sequence_name": sequence_name}
if version is not None:
block["version"] = version
if input is not None:
block["input"] = input
self._items.append(block)
return self

def ab_split(
self, id: str, variants: Sequence[tuple[str, int, Branch]]
) -> WorkflowBuilder:
self._items.append(
{
"type": "ab_split",
"id": id,
"variants": [
{"name": name, "weight": weight, "blocks": self._branch(branch)}
for name, weight, branch in variants
],
}
)
return self

def cancellation_scope(self, id: str, body: Branch) -> WorkflowBuilder:
self._items.append(
{"type": "cancellation_scope", "id": id, "blocks": self._branch(body)}
)
return self

def saga(
self,
id: str,
steps: Sequence[tuple[str, Branch, Branch | None]],
) -> WorkflowBuilder:
resolved = []
for step_id, action, compensation in steps:
actions = self._branch(action)
compensations = self._branch(compensation) if compensation else []
if len(actions) != 1 or len(compensations) > 1:
raise ValueError("each saga action must have one block and compensation at most one")
item: Block = {"id": step_id, "action": actions[0]}
if compensations:
item["compensation"] = compensations[0]
resolved.append(item)
self._items.append({"type": "saga", "id": id, "steps": resolved})
return self

def raw(self, block: Mapping[str, Any]) -> WorkflowBuilder:
self._items.append(dict(block))
return self

def build(self) -> Block:
return {"name": self.name, "namespace": self.namespace, "blocks": list(self._items)}

def _branch(self, callback: Branch) -> list[Block]:
inner = WorkflowBuilder("_inner", self.namespace)
callback(inner)
return inner._items


def workflow(name: str, namespace: str = "default") -> WorkflowBuilder:
return WorkflowBuilder(name, namespace)
2 changes: 2 additions & 0 deletions src/orch8/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class ExecutionContext(BaseModel):


class SequenceDefinition(BaseModel):
schema_url: str | None = Field(default=None, alias="$schema")
schema_version: int = 1
id: str
tenant_id: str
namespace: str
Expand Down
15 changes: 15 additions & 0 deletions tests/test_adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import asyncio

from orch8 import durable_agent_handler


class Graph:
async def ainvoke(self, value, config):
return value, config


def test_langgraph_thread_is_instance_id() -> None:
result = asyncio.run(
durable_agent_handler(Graph())({"params": {"text": "hi"}, "instance_id": "inst-1"})
)
assert result == ({"text": "hi"}, {"configurable": {"thread_id": "inst-1"}})
34 changes: 34 additions & 0 deletions tests/test_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from orch8 import workflow


def test_builder_covers_nested_and_ab_split_blocks() -> None:
definition = (
workflow("campaign")
.step("prepare", "prepare", {"audience": "new"})
.parallel(
"fanout",
lambda branch: branch.step("email", "send-email", {"template": "welcome"}),
lambda branch: branch.ab_split(
"copy",
[
("a", 50, lambda variant: variant.step("a", "render", {"copy": "A"})),
("b", 50, lambda variant: variant.step("b", "render", {"copy": "B"})),
],
),
)
.build()
)
assert definition["name"] == "campaign"
assert definition["blocks"][1]["branches"][1][0]["type"] == "ab_split"


def test_saga_rejects_multiple_action_blocks() -> None:
try:
workflow("bad").saga(
"saga",
[("one", lambda branch: branch.step("a", "noop").step("b", "noop"), None)],
)
except ValueError as error:
assert "one block" in str(error)
else:
raise AssertionError("invalid saga was accepted")
Loading