hanzo-tasks: the client is the /v1/tasks contract, not a Temporal wrapper

The package dialed Temporal over gRPC, a transport hanzoai/tasks removed in
v3, so nothing it did could reach the engine. It is now the HTTP contract the
engine serves under /v1/tasks/namespaces/{ns}: start, signal-with-start, list,
describe, signal, query, cancel, terminate, reset, history, and schedules — one
method per route, IAM bearer, tested against the request shapes. A workflow's
body still runs in a Go or TypeScript worker.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-01 07:58:30 -07:00
co-authored by Hanzo Dev
parent 588a790b4b
commit 39017ee7a1
8 changed files with 329 additions and 599 deletions
+26
View File
@@ -0,0 +1,26 @@
# hanzo-tasks
The Hanzo Tasks client: durable workflows over `/v1/tasks`.
Hanzo Cloud embeds the hanzoai/tasks engine and serves it at
`https://api.hanzo.ai/v1/tasks/namespaces/{namespace}/…`, authenticated by your
IAM bearer. This package is that HTTP contract, one method per route. A
workflow's body runs in a worker written with the Go (`tasks/pkg/sdk`) or
TypeScript (`@hanzoai/tasks`) SDK.
```bash
pip install hanzo-tasks
```
```python
from hanzo_tasks import Client, TasksConfig
with Client(TasksConfig(token=TOKEN, namespace="acme")) as tasks:
wf = tasks.start("agent.review", task_queue="agents", input={"repo": "acme/widgets"})
tasks.signal(wf.workflow_id, "approve", {"by": "z"})
state = tasks.query(wf.workflow_id, "status")
tasks.cancel(wf.workflow_id, reason="superseded")
```
Routes covered: start, signal-with-start, list, describe, signal, query,
cancel, terminate, reset, history; schedules create/get/list/delete/trigger.
+12 -37
View File
@@ -1,43 +1,18 @@
"""
hanzo-tasks — Durable workflow execution for AI agents.
"""hanzo-tasks — the Hanzo Tasks client.
Wraps the Temporal Python SDK with Hanzo conventions for agent task
orchestration, including pre-built workflows for pipelines and fan-out.
Durable workflows in Hanzo Cloud run on hanzoai/tasks, embedded in the cloud
process and served under `/v1/tasks`. From Python you start, signal, query,
cancel, terminate and reset them, and manage schedules; a workflow's body runs
in a worker written with the Go (`tasks/pkg/sdk`) or TypeScript
(`@hanzoai/tasks`) SDK.
Example:
>>> from hanzo_tasks import Client, TasksConfig
>>> client = await Client.connect(TasksConfig(namespace="hanzo"))
>>> handle = await client.submit(AgentTaskWorkflow.run, task_input, queue="agents")
>>> result = await handle.result()
>>> with Client(TasksConfig(token=TOKEN, namespace="acme")) as tasks:
... wf = tasks.start("agent.review", task_queue="agents", input={"repo": "acme/widgets"})
... tasks.signal(wf.workflow_id, "approve", {"by": "z"})
"""
from .activities import execute_agent_task, send_notification, set_agent_executor
from .client import Client, TasksConfig, WorkflowHandle
from .worker import Worker
from .workflows import (
AgentTaskInput,
AgentTaskOutput,
AgentTaskWorkflow,
FanOutWorkflow,
PipelineWorkflow,
)
from .client import DEFAULT_HOST, Client, TasksConfig, TasksError, Workflow
__version__ = "0.1.0"
__all__ = [
# Client
"Client",
"TasksConfig",
"WorkflowHandle",
# Worker
"Worker",
# Workflows
"AgentTaskWorkflow",
"PipelineWorkflow",
"FanOutWorkflow",
"AgentTaskInput",
"AgentTaskOutput",
# Activities
"execute_agent_task",
"send_notification",
"set_agent_executor",
]
__version__ = "0.2.0"
__all__ = ["Client", "TasksConfig", "TasksError", "Workflow", "DEFAULT_HOST"]
-35
View File
@@ -1,35 +0,0 @@
"""Activity definitions for agent task execution."""
from __future__ import annotations
from typing import Any, Callable
from temporalio import activity
# Activity executor — pluggable. The playground sets this to call the ZAP sidecar.
_agent_executor: Callable[..., Any] | None = None
def set_agent_executor(fn: Callable[..., Any]) -> None:
"""Set the function that executes agent tasks (called by playground)."""
global _agent_executor
_agent_executor = fn
@activity.defn
async def execute_agent_task(input: Any) -> Any:
"""Execute an agent task. Delegates to the registered executor."""
if _agent_executor is None:
raise RuntimeError(
"No agent executor registered. Call set_agent_executor() first."
)
return await _agent_executor(input)
@activity.defn
async def send_notification(input: dict[str, Any]) -> None:
"""Send a notification (webhook, SSE, etc)."""
import httpx
async with httpx.AsyncClient() as client:
await client.post(input.get("url", ""), json=input)
+211 -74
View File
@@ -1,100 +1,237 @@
"""Hanzo Tasks client — wraps Temporal client with Hanzo conventions."""
"""The Hanzo Tasks client: `/v1/tasks` over HTTP.
The durable engine (hanzoai/tasks) is embedded in Hanzo Cloud and answers under
`/v1/tasks/namespaces/{namespace}/...` on the same origin as everything else,
authenticated by the caller's IAM bearer. A workflow is started, signalled,
queried, cancelled, terminated and reset here; its body runs in a worker
written with the Go or TypeScript SDK. This module holds no engine protocol of
its own — it is the HTTP contract, one method per route.
"""
from __future__ import annotations
from dataclasses import dataclass
import uuid
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
from temporalio.client import Client as TemporalClient
import httpx
DEFAULT_HOST = "https://api.hanzo.ai"
@dataclass
@dataclass(frozen=True)
class TasksConfig:
"""Configuration for connecting to Temporal."""
"""Where the engine is and who is calling.
address: str = "localhost:7233"
namespace: str = "hanzo"
tls: bool = False
`token` is an IAM bearer (a user or service token); the org is read from it
server-side. `namespace` is the engine namespace the caller's workflows live
in — the org's own by convention.
"""
token: str
namespace: str = "default"
host: str = DEFAULT_HOST
timeout: float = 30.0
class WorkflowHandle:
"""Handle to a running workflow."""
@dataclass(frozen=True)
class Workflow:
"""One workflow execution as the engine describes it."""
def __init__(self, handle: Any) -> None:
self._handle = handle
workflow_id: str
run_id: str
status: str = ""
workflow_type: str = ""
raw: dict[str, Any] = field(default_factory=dict)
@property
def id(self) -> str:
return self._handle.id
@classmethod
def of(cls, d: dict[str, Any]) -> "Workflow":
wt = d.get("workflowType")
return cls(
workflow_id=str(d.get("workflowId", "")),
run_id=str(d.get("runId", "")),
status=str(d.get("status", "")),
workflow_type=str(wt.get("name", "") if isinstance(wt, dict) else wt or ""),
raw=d,
)
@property
def run_id(self) -> str:
return self._handle.result_run_id
async def result(self, result_type: type | None = None) -> Any:
return await self._handle.result(result_type=result_type)
class TasksError(RuntimeError):
"""The engine refused or failed a call; `status` is the HTTP status."""
async def cancel(self) -> None:
await self._handle.cancel()
async def signal(self, name: str, data: Any = None) -> None:
await self._handle.signal(name, data)
def __init__(self, status: int, message: str) -> None:
super().__init__(f"tasks: HTTP {status}: {message}")
self.status = status
class Client:
"""Hanzo Tasks client for submitting and managing workflows."""
"""Synchronous client over `/v1/tasks`. Use as a context manager or call `close()`."""
def __init__(self, temporal: TemporalClient) -> None:
self._temporal = temporal
@classmethod
async def connect(cls, config: TasksConfig | None = None) -> Client:
"""Connect to Temporal server."""
cfg = config or TasksConfig()
temporal = await TemporalClient.connect(cfg.address, namespace=cfg.namespace)
return cls(temporal)
async def submit(
self,
workflow: Any,
input: Any,
*,
id: str | None = None,
queue: str = "default",
**kwargs: Any,
) -> WorkflowHandle:
"""Submit a workflow for execution."""
handle = await self._temporal.start_workflow(
workflow,
input,
id=id or str(uuid4()),
task_queue=queue,
**kwargs,
def __init__(self, config: TasksConfig, transport: httpx.BaseTransport | None = None) -> None:
self.config = config
self._http = httpx.Client(
base_url=config.host.rstrip("/"),
headers={"Authorization": f"Bearer {config.token}", "Content-Type": "application/json"},
timeout=config.timeout,
transport=transport,
)
return WorkflowHandle(handle)
async def get_result(self, workflow_id: str, result_type: type | None = None) -> Any:
"""Get the result of a completed workflow."""
handle = self._temporal.get_workflow_handle(workflow_id)
return await handle.result(result_type=result_type)
# ── plumbing ─────────────────────────────────────────────────────────────
async def cancel(self, workflow_id: str) -> None:
"""Cancel a running workflow."""
handle = self._temporal.get_workflow_handle(workflow_id)
await handle.cancel()
def _path(self, *parts: str) -> str:
return "/v1/tasks/namespaces/" + "/".join([self.config.namespace, *parts])
async def signal(self, workflow_id: str, signal_name: str, data: Any = None) -> None:
"""Send a signal to a running workflow."""
handle = self._temporal.get_workflow_handle(workflow_id)
await handle.signal(signal_name, data)
def _call(self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None) -> Any:
r = self._http.request(method, path, params=params or None, json=body)
if r.status_code >= 400:
try:
msg = r.json().get("error") or r.text
except ValueError:
msg = r.text
raise TasksError(r.status_code, str(msg).strip())
if not r.content:
return None
try:
return r.json()
except ValueError:
return r.text
async def query(self, workflow_id: str, query_name: str) -> Any:
"""Query a running workflow."""
handle = self._temporal.get_workflow_handle(workflow_id)
return await handle.query(query_name)
def close(self) -> None:
self._http.close()
@property
def temporal(self) -> TemporalClient:
"""Access the underlying Temporal client."""
return self._temporal
def __enter__(self) -> "Client":
return self
def __exit__(self, *_: object) -> None:
self.close()
# ── workflows ────────────────────────────────────────────────────────────
def start(
self,
workflow_type: str,
*,
task_queue: str,
input: Any = None,
workflow_id: str | None = None,
request_id: str | None = None,
) -> Workflow:
"""Start a workflow. A caller-chosen `workflow_id` makes the start
idempotent; `request_id` deduplicates a retried request."""
body = {
"workflowId": workflow_id or str(uuid.uuid4()),
"workflowType": {"name": workflow_type},
"taskQueue": {"name": task_queue},
"input": input,
"requestId": request_id or str(uuid.uuid4()),
}
return Workflow.of(self._call("POST", self._path("workflows"), body=body) or body)
def signal_with_start(
self,
workflow_type: str,
*,
task_queue: str,
signal: str,
payload: Any = None,
input: Any = None,
workflow_id: str | None = None,
request_id: str | None = None,
) -> Workflow:
"""Signal a workflow, starting it first if it is not running."""
body = {
"workflowId": workflow_id or str(uuid.uuid4()),
"workflowType": {"name": workflow_type},
"taskQueue": {"name": task_queue},
"input": input,
"signalName": signal,
"signalPayload": payload,
"requestId": request_id or str(uuid.uuid4()),
}
return Workflow.of(self._call("POST", self._path("workflows", "signal-with-start"), body=body) or body)
def list(self, query: str = "") -> list[Workflow]:
out = self._call("GET", self._path("workflows"), params={"query": query} if query else None)
rows = out.get("workflows", out) if isinstance(out, dict) else out
return [Workflow.of(w) for w in (rows or [])]
def describe(self, workflow_id: str, run_id: str = "") -> Workflow:
out = self._call("GET", self._path("workflows", workflow_id), params={"runId": run_id} if run_id else None)
return Workflow.of(out or {"workflowId": workflow_id, "runId": run_id})
def signal(self, workflow_id: str, name: str, payload: Any = None, run_id: str = "") -> None:
self._call(
"POST",
self._path("workflows", workflow_id, "signal"),
params={"runId": run_id} if run_id else None,
body={"name": name, "payload": payload},
)
def query(self, workflow_id: str, query_type: str, args: Any = None, run_id: str = "") -> Any:
return self._call(
"POST",
self._path("workflows", workflow_id, "query"),
params={"runId": run_id} if run_id else None,
body={"queryType": query_type, "args": args},
)
def cancel(self, workflow_id: str, reason: str = "", run_id: str = "", identity: str = "") -> Workflow:
out = self._call(
"POST",
self._path("workflows", workflow_id, "cancel"),
params={"runId": run_id} if run_id else None,
body={"reason": reason, "identity": identity},
)
return Workflow.of(out or {"workflowId": workflow_id, "runId": run_id})
def terminate(self, workflow_id: str, reason: str = "", run_id: str = "", identity: str = "") -> Workflow:
out = self._call(
"POST",
self._path("workflows", workflow_id, "terminate"),
params={"runId": run_id} if run_id else None,
body={"reason": reason, "identity": identity},
)
return Workflow.of(out or {"workflowId": workflow_id, "runId": run_id})
def reset(self, workflow_id: str, event_id: int, run_id: str = "", reason: str = "", identity: str = "") -> Workflow:
out = self._call(
"POST",
self._path("workflows", workflow_id, "reset"),
body={"runId": run_id, "eventId": event_id, "reason": reason, "identity": identity},
)
return Workflow.of(out or {"workflowId": workflow_id, "runId": run_id})
def history(self, workflow_id: str, run_id: str = "", after: int = 0, page_size: int = 0, reverse: bool = False) -> Any:
params: dict[str, Any] = {}
if run_id:
params["runId"] = run_id
if after:
params["afterId"] = after
if page_size:
params["pageSize"] = page_size
if reverse:
params["reverse"] = "true"
return self._call("GET", self._path("workflows", workflow_id, "history"), params=params)
# ── schedules ────────────────────────────────────────────────────────────
def schedules(self) -> Any:
return self._call("GET", self._path("schedules"))
def create_schedule(self, schedule: dict[str, Any]) -> Any:
"""Create a schedule; `schedule` is the engine's schedule document
(id, spec, action) as the Go and TypeScript SDKs send it."""
return self._call("POST", self._path("schedules"), body=schedule)
def schedule(self, schedule_id: str) -> Any:
return self._call("GET", self._path("schedules", schedule_id))
def delete_schedule(self, schedule_id: str) -> None:
self._call("DELETE", self._path("schedules", schedule_id))
def trigger_schedule(self, schedule_id: str, overlap_policy: str = "", request_id: str | None = None) -> Any:
return self._call(
"POST",
self._path("schedules", schedule_id, "trigger"),
body={"requestId": request_id or str(uuid.uuid4()), "overlapPolicy": overlap_policy},
)
-54
View File
@@ -1,54 +0,0 @@
"""Hanzo Tasks worker — polls and executes activities."""
from __future__ import annotations
from typing import Any
from temporalio.worker import Worker as TemporalWorker
class Worker:
"""Hanzo Tasks worker that polls a queue and executes workflows/activities."""
def __init__(
self,
client: Any,
queue: str = "default",
workflows: list[Any] | None = None,
activities: list[Any] | None = None,
) -> None:
self._client = client
self._queue = queue
self._workflows: list[Any] = workflows or []
self._activities: list[Any] = activities or []
self._worker: TemporalWorker | None = None
def register_workflow(self, workflow_cls: Any) -> Any:
"""Register a workflow class. Can be used as a decorator."""
self._workflows.append(workflow_cls)
return workflow_cls
def register_activity(self, activity_fn: Any) -> Any:
"""Register an activity function. Can be used as a decorator."""
self._activities.append(activity_fn)
return activity_fn
async def run(self) -> None:
"""Start the worker. Blocks until shutdown."""
temporal_client = (
self._client.temporal
if hasattr(self._client, "temporal")
else self._client
)
self._worker = TemporalWorker(
temporal_client,
task_queue=self._queue,
workflows=self._workflows,
activities=self._activities,
)
await self._worker.run()
async def shutdown(self) -> None:
"""Gracefully shutdown the worker."""
if self._worker:
await self._worker.shutdown()
-80
View File
@@ -1,80 +0,0 @@
"""Pre-built workflows for agent task orchestration."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
@dataclass
class AgentTaskInput:
"""Input for a single agent task."""
space_id: str
agent_id: str
task_title: str
task_prompt: str
timeout_seconds: int = 3600
max_retries: int = 3
@dataclass
class AgentTaskOutput:
"""Output from an agent task execution."""
result: str = ""
error: str = ""
elapsed_seconds: float = 0.0
@workflow.defn
class AgentTaskWorkflow:
"""Execute a single agent task with retries and timeout."""
@workflow.run
async def run(self, input: AgentTaskInput) -> AgentTaskOutput:
return await workflow.execute_activity(
"execute_agent_task",
input,
start_to_close_timeout=timedelta(seconds=input.timeout_seconds),
retry_policy=RetryPolicy(maximum_attempts=input.max_retries),
)
@workflow.defn
class PipelineWorkflow:
"""Run agent tasks sequentially (pipeline)."""
@workflow.run
async def run(self, tasks: list[AgentTaskInput]) -> list[AgentTaskOutput]:
results: list[AgentTaskOutput] = []
for task in tasks:
result = await workflow.execute_activity(
"execute_agent_task",
task,
start_to_close_timeout=timedelta(seconds=task.timeout_seconds),
retry_policy=RetryPolicy(maximum_attempts=task.max_retries),
)
results.append(result)
return results
@workflow.defn
class FanOutWorkflow:
"""Run agent tasks in parallel (fan-out/fan-in)."""
@workflow.run
async def run(self, tasks: list[AgentTaskInput]) -> list[AgentTaskOutput]:
handles = []
for task in tasks:
handle = workflow.start_activity(
"execute_agent_task",
task,
start_to_close_timeout=timedelta(seconds=task.timeout_seconds),
retry_policy=RetryPolicy(maximum_attempts=task.max_retries),
)
handles.append(handle)
return [await h for h in handles]
+4 -5
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tasks"
version = "0.1.0"
description = "Hanzo Tasks SDK — Durable workflow execution for AI agents (powered by Temporal)"
version = "0.2.0"
description = "Hanzo Tasks client — start, signal, query and schedule durable workflows over /v1/tasks"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
@@ -19,9 +19,9 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
keywords = ["hanzo", "temporal", "tasks", "workflow", "durable", "agents"]
keywords = ["hanzo", "tasks", "workflow", "durable", "agents"]
dependencies = [
"temporalio>=1.9.0",
"httpx>=0.27",
]
[project.urls]
@@ -32,7 +32,6 @@ Documentation = "https://hanzo.ai/docs/tasks"
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.26.0",
]
[tool.setuptools.packages.find]
+76 -314
View File
@@ -1,317 +1,79 @@
"""Hanzo Tasks test suite.
Unit tests for client config, worker registration, dataclasses,
workflow decorators, and activity executor wiring. No Temporal
server required.
"""
import inspect
from dataclasses import asdict
import json
import httpx
import pytest
import pytest_asyncio
from hanzo_tasks import (
AgentTaskInput,
AgentTaskOutput,
AgentTaskWorkflow,
Client,
FanOutWorkflow,
PipelineWorkflow,
TasksConfig,
Worker,
WorkflowHandle,
execute_agent_task,
set_agent_executor,
)
# -- config ------------------------------------------------------------------
class TestTasksConfig:
def test_defaults(self):
cfg = TasksConfig()
assert cfg.address == "localhost:7233"
assert cfg.namespace == "hanzo"
assert cfg.tls is False
def test_custom(self):
cfg = TasksConfig(address="temporal.prod:7233", namespace="prod", tls=True)
assert cfg.address == "temporal.prod:7233"
assert cfg.namespace == "prod"
assert cfg.tls is True
# -- dataclasses -------------------------------------------------------------
class TestAgentTaskInput:
def test_defaults(self):
inp = AgentTaskInput(
space_id="sp-1",
agent_id="ag-1",
task_title="Fix bug",
task_prompt="Fix the null pointer in main.go",
)
assert inp.space_id == "sp-1"
assert inp.agent_id == "ag-1"
assert inp.task_title == "Fix bug"
assert inp.task_prompt == "Fix the null pointer in main.go"
assert inp.timeout_seconds == 3600
assert inp.max_retries == 3
def test_custom_timeout(self):
inp = AgentTaskInput(
space_id="sp-2",
agent_id="ag-2",
task_title="Deploy",
task_prompt="Deploy to prod",
timeout_seconds=600,
max_retries=1,
)
assert inp.timeout_seconds == 600
assert inp.max_retries == 1
def test_serializes_to_dict(self):
inp = AgentTaskInput(
space_id="sp-1",
agent_id="ag-1",
task_title="T",
task_prompt="P",
)
d = asdict(inp)
assert d["space_id"] == "sp-1"
assert d["agent_id"] == "ag-1"
assert d["task_title"] == "T"
assert d["task_prompt"] == "P"
assert d["timeout_seconds"] == 3600
assert d["max_retries"] == 3
assert len(d) == 6
class TestAgentTaskOutput:
def test_defaults(self):
out = AgentTaskOutput()
assert out.result == ""
assert out.error == ""
assert out.elapsed_seconds == 0.0
def test_success(self):
out = AgentTaskOutput(result="done", elapsed_seconds=1.5)
assert out.result == "done"
assert out.error == ""
assert out.elapsed_seconds == 1.5
def test_error(self):
out = AgentTaskOutput(error="timeout", elapsed_seconds=3600.0)
assert out.error == "timeout"
assert out.result == ""
def test_serializes_to_dict(self):
out = AgentTaskOutput(result="ok", elapsed_seconds=0.1)
d = asdict(out)
assert d == {"result": "ok", "error": "", "elapsed_seconds": 0.1}
# -- workflow decorators -----------------------------------------------------
class TestWorkflowDecorators:
def test_agent_task_workflow_has_run(self):
assert hasattr(AgentTaskWorkflow, "run")
assert inspect.iscoroutinefunction(AgentTaskWorkflow.run)
def test_pipeline_workflow_has_run(self):
assert hasattr(PipelineWorkflow, "run")
assert inspect.iscoroutinefunction(PipelineWorkflow.run)
def test_fanout_workflow_has_run(self):
assert hasattr(FanOutWorkflow, "run")
assert inspect.iscoroutinefunction(FanOutWorkflow.run)
def test_workflow_classes_are_distinct(self):
assert AgentTaskWorkflow is not PipelineWorkflow
assert PipelineWorkflow is not FanOutWorkflow
# -- worker ------------------------------------------------------------------
class TestWorker:
def test_init_defaults(self):
w = Worker(client=None, queue="test-q")
assert w._queue == "test-q"
assert w._workflows == []
assert w._activities == []
assert w._worker is None
def test_register_workflow(self):
w = Worker(client=None)
class MyWorkflow:
pass
result = w.register_workflow(MyWorkflow)
assert result is MyWorkflow
assert MyWorkflow in w._workflows
def test_register_activity(self):
w = Worker(client=None)
async def my_activity(input):
return "ok"
result = w.register_activity(my_activity)
assert result is my_activity
assert my_activity in w._activities
def test_register_multiple(self):
w = Worker(client=None)
for i in range(5):
w.register_workflow(type(f"Wf{i}", (), {}))
assert len(w._workflows) == 5
def test_init_with_preloaded(self):
workflows = [AgentTaskWorkflow, PipelineWorkflow]
activities = [execute_agent_task]
w = Worker(client=None, workflows=workflows, activities=activities)
assert len(w._workflows) == 2
assert len(w._activities) == 1
def test_does_not_mutate_caller_list(self):
workflows: list = []
w = Worker(client=None, workflows=workflows)
w.register_workflow(AgentTaskWorkflow)
# The caller's original list should not be modified since we
# pass a new list via `or []`, but if caller passes a list,
# it IS the same reference. That's expected Python behavior.
# Just verify worker has the workflow.
assert AgentTaskWorkflow in w._workflows
# -- executor wiring ---------------------------------------------------------
class TestSetAgentExecutor:
def test_set_and_reset(self):
import hanzo_tasks.activities as act
original = act._agent_executor
async def my_exec(input):
return "executed"
set_agent_executor(my_exec)
assert act._agent_executor is my_exec
# Restore
act._agent_executor = original
@pytest.mark.asyncio
async def test_execute_without_executor_raises(self):
import hanzo_tasks.activities as act
saved = act._agent_executor
act._agent_executor = None
try:
with pytest.raises(RuntimeError, match="No agent executor registered"):
await execute_agent_task(None)
finally:
act._agent_executor = saved
@pytest.mark.asyncio
async def test_execute_with_executor(self):
import hanzo_tasks.activities as act
saved = act._agent_executor
async def mock_exec(input):
return AgentTaskOutput(result=f"done:{input.task_title}", elapsed_seconds=0.01)
set_agent_executor(mock_exec)
try:
inp = AgentTaskInput(
space_id="sp-1",
agent_id="ag-1",
task_title="test",
task_prompt="do it",
)
out = await execute_agent_task(inp)
assert out.result == "done:test"
assert out.elapsed_seconds == 0.01
finally:
act._agent_executor = saved
# -- workflow handle ---------------------------------------------------------
class TestWorkflowHandle:
def test_id(self):
class FakeHandle:
id = "wf-123"
result_run_id = "run-456"
h = WorkflowHandle(FakeHandle())
assert h.id == "wf-123"
assert h.run_id == "run-456"
@pytest.mark.asyncio
async def test_cancel(self):
cancelled = False
class FakeHandle:
id = "wf-1"
result_run_id = "run-1"
async def cancel(self):
nonlocal cancelled
cancelled = True
h = WorkflowHandle(FakeHandle())
await h.cancel()
assert cancelled
@pytest.mark.asyncio
async def test_signal(self):
signals = []
class FakeHandle:
id = "wf-1"
result_run_id = "run-1"
async def signal(self, name, data=None):
signals.append((name, data))
h = WorkflowHandle(FakeHandle())
await h.signal("pause", {"reason": "lunch"})
assert signals == [("pause", {"reason": "lunch"})]
@pytest.mark.asyncio
async def test_result(self):
class FakeHandle:
id = "wf-1"
result_run_id = "run-1"
async def result(self, result_type=None):
return "the-result"
h = WorkflowHandle(FakeHandle())
assert await h.result() == "the-result"
# -- __init__ exports --------------------------------------------------------
class TestExports:
def test_version(self):
import hanzo_tasks
assert hanzo_tasks.__version__ == "0.1.0"
def test_all_exports_importable(self):
import hanzo_tasks
for name in hanzo_tasks.__all__:
assert hasattr(hanzo_tasks, name), f"{name} not found in hanzo_tasks"
from hanzo_tasks import Client, TasksConfig, TasksError
def make(handler):
return Client(TasksConfig(token="tok", namespace="acme", host="https://api.example"), transport=httpx.MockTransport(handler))
def test_start_posts_the_engine_shape():
seen = {}
def handler(req: httpx.Request) -> httpx.Response:
seen["method"], seen["path"], seen["auth"] = req.method, req.url.path, req.headers.get("authorization")
seen["body"] = json.loads(req.content)
return httpx.Response(200, json={"workflowId": seen["body"]["workflowId"], "runId": "r-1", "status": "Running"})
with make(handler) as c:
wf = c.start("agent.review", task_queue="agents", input={"repo": "acme/widgets"}, workflow_id="wf-1")
assert seen["method"] == "POST" and seen["path"] == "/v1/tasks/namespaces/acme/workflows"
assert seen["auth"] == "Bearer tok"
assert seen["body"]["workflowType"] == {"name": "agent.review"} and seen["body"]["taskQueue"] == {"name": "agents"}
assert seen["body"]["input"] == {"repo": "acme/widgets"} and seen["body"]["requestId"]
assert wf.workflow_id == "wf-1" and wf.run_id == "r-1" and wf.status == "Running"
def test_signal_query_cancel_reset_routes():
calls = []
def handler(req: httpx.Request) -> httpx.Response:
calls.append((req.method, req.url.path, req.url.query.decode(), json.loads(req.content) if req.content else None))
return httpx.Response(200, json={"workflowId": "wf-1", "runId": "r-1", "answer": 42})
with make(handler) as c:
c.signal("wf-1", "approve", {"by": "z"}, run_id="r-1")
out = c.query("wf-1", "status")
c.cancel("wf-1", reason="done")
c.terminate("wf-1", reason="hard")
c.reset("wf-1", event_id=7, run_id="r-1", reason="replay")
c.signal_with_start("agent.review", task_queue="agents", signal="kick", payload=1, workflow_id="wf-2")
paths = [(m, p) for m, p, _, _ in calls]
assert paths == [
("POST", "/v1/tasks/namespaces/acme/workflows/wf-1/signal"),
("POST", "/v1/tasks/namespaces/acme/workflows/wf-1/query"),
("POST", "/v1/tasks/namespaces/acme/workflows/wf-1/cancel"),
("POST", "/v1/tasks/namespaces/acme/workflows/wf-1/terminate"),
("POST", "/v1/tasks/namespaces/acme/workflows/wf-1/reset"),
("POST", "/v1/tasks/namespaces/acme/workflows/signal-with-start"),
]
assert calls[0][2] == "runId=r-1" and calls[0][3] == {"name": "approve", "payload": {"by": "z"}}
assert calls[1][3] == {"queryType": "status", "args": None} and out["answer"] == 42
assert calls[4][3] == {"runId": "r-1", "eventId": 7, "reason": "replay", "identity": ""}
assert calls[5][3]["signalName"] == "kick" and calls[5][3]["signalPayload"] == 1
def test_schedules_and_errors():
calls = []
def handler(req: httpx.Request) -> httpx.Response:
calls.append((req.method, req.url.path))
if req.url.path.endswith("/schedules/nope"):
return httpx.Response(404, json={"error": "schedule not found"})
return httpx.Response(200, json={"ok": True})
with make(handler) as c:
c.create_schedule({"id": "nightly", "spec": {"cron": "0 2 * * *"}, "action": {"workflowType": "agent.digest"}})
c.trigger_schedule("nightly")
c.delete_schedule("nightly")
with pytest.raises(TasksError) as e:
c.schedule("nope")
assert e.value.status == 404 and "schedule not found" in str(e.value)
assert calls[:3] == [
("POST", "/v1/tasks/namespaces/acme/schedules"),
("POST", "/v1/tasks/namespaces/acme/schedules/nightly/trigger"),
("DELETE", "/v1/tasks/namespaces/acme/schedules/nightly"),
]