Skip to content

Commit 4acd087

Browse files
Expose workflow run visibility in Python SDK
1 parent 3331f9d commit 4acd087

5 files changed

Lines changed: 290 additions & 0 deletions

File tree

src/durable_workflow/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
WorkflowExecution,
3535
WorkflowHandle,
3636
WorkflowList,
37+
WorkflowRun,
38+
WorkflowRunList,
3739
)
3840
from .errors import (
3941
ActivityCancelled,
@@ -171,6 +173,8 @@
171173
"WorkflowHandle",
172174
"WorkflowList",
173175
"WorkflowPayloadDecodeError",
176+
"WorkflowRun",
177+
"WorkflowRunList",
174178
"activity",
175179
"parse_auth_composition_contract",
176180
"sync",

src/durable_workflow/client.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,86 @@ class WorkflowList:
105105
next_page_token: str | None = None
106106

107107

108+
@dataclass
109+
class WorkflowRun:
110+
"""Current server view of one durable run in a workflow execution chain."""
111+
112+
workflow_id: str
113+
run_id: str
114+
workflow_type: str
115+
status: str | None = None
116+
namespace: str | None = None
117+
task_queue: str | None = None
118+
run_number: int | None = None
119+
run_count: int | None = None
120+
is_current_run: bool | None = None
121+
status_bucket: str | None = None
122+
business_key: str | None = None
123+
compatibility: str | None = None
124+
payload_codec: str | None = None
125+
input: Any = None
126+
output: Any = None
127+
memo: dict[str, Any] | None = None
128+
search_attributes: dict[str, Any] | None = None
129+
actions: dict[str, Any] | None = None
130+
started_at: str | None = None
131+
closed_at: str | None = None
132+
last_progress_at: str | None = None
133+
closed_reason: str | None = None
134+
wait_kind: str | None = None
135+
wait_reason: str | None = None
136+
execution_timeout_seconds: int | None = None
137+
run_timeout_seconds: int | None = None
138+
execution_deadline_at: str | None = None
139+
run_deadline_at: str | None = None
140+
141+
@classmethod
142+
def from_dict(
143+
cls, data: dict[str, Any], *, workflow_id: str | None = None, run_id: str | None = None
144+
) -> WorkflowRun:
145+
return cls(
146+
workflow_id=data.get("workflow_id", workflow_id or ""),
147+
run_id=data.get("run_id", run_id or ""),
148+
workflow_type=data.get("workflow_type", ""),
149+
status=data.get("status"),
150+
namespace=data.get("namespace"),
151+
task_queue=data.get("task_queue"),
152+
run_number=data.get("run_number"),
153+
run_count=data.get("run_count"),
154+
is_current_run=data.get("is_current_run"),
155+
status_bucket=data.get("status_bucket"),
156+
business_key=data.get("business_key"),
157+
compatibility=data.get("compatibility"),
158+
payload_codec=data.get("payload_codec"),
159+
input=data.get("input"),
160+
output=data.get("output"),
161+
memo=data.get("memo") if isinstance(data.get("memo"), dict) else None,
162+
search_attributes=(
163+
data.get("search_attributes") if isinstance(data.get("search_attributes"), dict) else None
164+
),
165+
actions=data.get("actions") if isinstance(data.get("actions"), dict) else None,
166+
started_at=data.get("started_at"),
167+
closed_at=data.get("closed_at"),
168+
last_progress_at=data.get("last_progress_at"),
169+
closed_reason=data.get("closed_reason"),
170+
wait_kind=data.get("wait_kind"),
171+
wait_reason=data.get("wait_reason"),
172+
execution_timeout_seconds=data.get("execution_timeout_seconds"),
173+
run_timeout_seconds=data.get("run_timeout_seconds"),
174+
execution_deadline_at=data.get("execution_deadline_at"),
175+
run_deadline_at=data.get("run_deadline_at"),
176+
)
177+
178+
179+
@dataclass
180+
class WorkflowRunList:
181+
"""All known durable runs for one workflow execution, oldest first."""
182+
183+
workflow_id: str
184+
run_count: int
185+
runs: list[WorkflowRun]
186+
187+
108188
@dataclass
109189
class TaskQueueTaskAdmission:
110190
"""Workflow/activity admission state for one task queue."""
@@ -1016,6 +1096,24 @@ async def get_history(self, workflow_id: str, run_id: str) -> Any:
10161096
"GET", f"/workflows/{workflow_id}/runs/{run_id}/history", context=workflow_id
10171097
)
10181098

1099+
async def list_workflow_runs(self, workflow_id: str) -> WorkflowRunList:
1100+
"""List all durable runs in one workflow execution chain, oldest first."""
1101+
data = await self._request("GET", f"/workflows/{workflow_id}/runs", context=workflow_id)
1102+
runs = [
1103+
WorkflowRun.from_dict(item, workflow_id=data.get("workflow_id", workflow_id))
1104+
for item in data.get("runs", [])
1105+
]
1106+
return WorkflowRunList(
1107+
workflow_id=data.get("workflow_id", workflow_id),
1108+
run_count=data.get("run_count", len(runs)),
1109+
runs=runs,
1110+
)
1111+
1112+
async def describe_workflow_run(self, workflow_id: str, run_id: str) -> WorkflowRun:
1113+
"""Return detailed status, payload, and actionability for one specific workflow run."""
1114+
data = await self._request("GET", f"/workflows/{workflow_id}/runs/{run_id}", context=workflow_id)
1115+
return WorkflowRun.from_dict(data, workflow_id=workflow_id, run_id=run_id)
1116+
10191117
async def send_webhook_bridge_event(
10201118
self,
10211119
adapter: str,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema": "durable-workflow.polyglot.control-plane-request-fixture",
3+
"version": 1,
4+
"operation": "workflow.list_runs",
5+
"request": {
6+
"method": "GET",
7+
"path": "/workflows/wf-polyglot-231/runs"
8+
},
9+
"semantic_body": {
10+
"workflow_id": "wf-polyglot-231",
11+
"run_count": 2,
12+
"run_ids": [
13+
"run-polyglot-231-001",
14+
"run-polyglot-231-002"
15+
]
16+
},
17+
"response_body": {
18+
"workflow_id": "wf-polyglot-231",
19+
"run_count": 2,
20+
"runs": [
21+
{
22+
"workflow_id": "wf-polyglot-231",
23+
"run_id": "run-polyglot-231-001",
24+
"run_number": 1,
25+
"workflow_type": "orders.process",
26+
"status": "completed",
27+
"status_bucket": "completed",
28+
"task_queue": "orders",
29+
"started_at": "2026-04-22T05:00:00Z",
30+
"closed_at": "2026-04-22T05:04:00Z",
31+
"is_current_run": false
32+
},
33+
{
34+
"workflow_id": "wf-polyglot-231",
35+
"run_id": "run-polyglot-231-002",
36+
"run_number": 2,
37+
"workflow_type": "orders.process",
38+
"status": "running",
39+
"status_bucket": "active",
40+
"task_queue": "orders",
41+
"started_at": "2026-04-22T05:05:00Z",
42+
"closed_at": null,
43+
"is_current_run": true
44+
}
45+
]
46+
},
47+
"cli": {
48+
"argv": {
49+
"workflow-id": "wf-polyglot-231",
50+
"--json": true
51+
}
52+
},
53+
"sdk_python": {
54+
"method": "list_workflow_runs",
55+
"args": {
56+
"workflow_id": "wf-polyglot-231"
57+
}
58+
}
59+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"schema": "durable-workflow.polyglot.control-plane-request-fixture",
3+
"version": 1,
4+
"operation": "workflow.show_run",
5+
"request": {
6+
"method": "GET",
7+
"path": "/workflows/wf-polyglot-231/runs/run-polyglot-231-002"
8+
},
9+
"semantic_body": {
10+
"workflow_id": "wf-polyglot-231",
11+
"run_id": "run-polyglot-231-002"
12+
},
13+
"response_body": {
14+
"workflow_id": "wf-polyglot-231",
15+
"run_id": "run-polyglot-231-002",
16+
"workflow_type": "orders.process",
17+
"namespace": "orders-prod",
18+
"business_key": "order-42",
19+
"status": "running",
20+
"status_bucket": "active",
21+
"run_number": 2,
22+
"run_count": 2,
23+
"is_current_run": true,
24+
"task_queue": "orders",
25+
"compatibility": "build-2026-04-22",
26+
"payload_codec": "avro",
27+
"execution_timeout_seconds": 86400,
28+
"run_timeout_seconds": 3600,
29+
"execution_deadline_at": "2026-04-23T05:00:00Z",
30+
"run_deadline_at": "2026-04-22T06:05:00Z",
31+
"started_at": "2026-04-22T05:05:00Z",
32+
"closed_at": null,
33+
"last_progress_at": "2026-04-22T05:06:00Z",
34+
"closed_reason": null,
35+
"wait_kind": "timer",
36+
"wait_reason": "shipment deadline",
37+
"input": [
38+
{
39+
"order_id": 42,
40+
"priority": "gold"
41+
}
42+
],
43+
"memo": {
44+
"source": "polyglot-fixture"
45+
},
46+
"search_attributes": {
47+
"CustomerId": "cust-42",
48+
"Tier": "gold"
49+
},
50+
"actions": {
51+
"can_query": true,
52+
"can_signal": true,
53+
"can_cancel": true,
54+
"can_terminate": true,
55+
"can_repair": false,
56+
"can_archive": false
57+
}
58+
},
59+
"cli": {
60+
"argv": {
61+
"workflow-id": "wf-polyglot-231",
62+
"run-id": "run-polyglot-231-002",
63+
"--json": true
64+
}
65+
},
66+
"sdk_python": {
67+
"method": "describe_workflow_run",
68+
"args": {
69+
"workflow_id": "wf-polyglot-231",
70+
"run_id": "run-polyglot-231-002"
71+
}
72+
}
73+
}

tests/test_client.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,62 @@ async def test_history_request_matches_polyglot_fixture(self, client: Client) ->
345345
assert history == fixture["response_body"]
346346

347347

348+
class TestWorkflowRunVisibility:
349+
@pytest.mark.asyncio
350+
async def test_list_runs_request_matches_polyglot_fixture(self, client: Client) -> None:
351+
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "workflow-list-runs-parity.json"
352+
fixture = json.loads(fixture_path.read_text())
353+
sdk = fixture["sdk_python"]
354+
355+
resp = _mock_response(200, fixture["response_body"])
356+
357+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
358+
result = await client.list_workflow_runs(**sdk["args"])
359+
360+
call_args = mock.call_args
361+
assert call_args.args[0] == fixture["request"]["method"]
362+
assert call_args.args[1] == f"/api{fixture['request']['path']}"
363+
assert call_args.kwargs.get("json") is None
364+
365+
semantic = fixture["semantic_body"]
366+
assert sdk["args"]["workflow_id"] == semantic["workflow_id"]
367+
assert result.workflow_id == semantic["workflow_id"]
368+
assert result.run_count == semantic["run_count"]
369+
assert [run.run_id for run in result.runs] == semantic["run_ids"]
370+
assert result.runs[0].workflow_type == fixture["response_body"]["runs"][0]["workflow_type"]
371+
assert result.runs[1].is_current_run is True
372+
373+
@pytest.mark.asyncio
374+
async def test_describe_run_request_matches_polyglot_fixture(self, client: Client) -> None:
375+
fixture_path = Path(__file__).parent / "fixtures" / "control-plane" / "workflow-show-run-parity.json"
376+
fixture = json.loads(fixture_path.read_text())
377+
sdk = fixture["sdk_python"]
378+
379+
resp = _mock_response(200, fixture["response_body"])
380+
381+
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
382+
run = await client.describe_workflow_run(**sdk["args"])
383+
384+
call_args = mock.call_args
385+
assert call_args.args[0] == fixture["request"]["method"]
386+
assert call_args.args[1] == f"/api{fixture['request']['path']}"
387+
assert call_args.kwargs.get("json") is None
388+
389+
semantic = fixture["semantic_body"]
390+
response = fixture["response_body"]
391+
assert sdk["args"]["workflow_id"] == semantic["workflow_id"]
392+
assert sdk["args"]["run_id"] == semantic["run_id"]
393+
assert run.workflow_id == semantic["workflow_id"]
394+
assert run.run_id == semantic["run_id"]
395+
assert run.workflow_type == response["workflow_type"]
396+
assert run.status == response["status"]
397+
assert run.status_bucket == response["status_bucket"]
398+
assert run.business_key == response["business_key"]
399+
assert run.memo == response["memo"]
400+
assert run.search_attributes == response["search_attributes"]
401+
assert run.actions == response["actions"]
402+
403+
348404
class TestSignalWorkflow:
349405
@pytest.mark.asyncio
350406
async def test_signal(self, client: Client) -> None:

0 commit comments

Comments
 (0)