diff --git a/rust/README.md b/rust/README.md
index 6ce1a30..474ef6c 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -25,12 +25,26 @@ every command group reaches parity.
| `runtime:check`: native build identity and compilation target | Ported, intentional output/help difference |
| `doctor`: redacted connection sources, server metadata, warnings and next steps | Ported |
| `workflow:list`: one namespace/page, filters, Server envelopes, JSON and JSONL | Ported |
+| `workflow:list-runs`: one workflow response page, envelope/cursor, JSON and JSONL | Ported |
| Workflow, namespace and schedule commands | Next slices |
| `--tls-verify=false` against HTTPS | CLI support pending |
Server calls go through the published `durable-workflow` crate, pinned to
the latest qualified release.
+`workflow:list-runs` sends its workflow ID as one percent-encoded SDK path
+segment. IDs containing slashes, query delimiters or fragments stay within
+that segment; they cannot select another route. Empty IDs and `.` or `..`
+are refused with exit 2 before the runs request. This deliberately replaces
+the PHP command's interpolation of the ID into the request path.
+
+The command returns one Server response and leaves any `next_page_token`
+unconsumed. JSON preserves that envelope and cursor; JSONL emits only the
+returned records. Successful responses must identify the requested workflow
+and `list_runs` operation. Explicit row workflow IDs must match; omitted IDs
+in the published PHP Server shape inherit the verified envelope without
+adding fields. Contradictory identities or operations are refused.
+
## Build and test
Requires Rust 1.86 or newer.
@@ -85,6 +99,12 @@ option definitions and help text), generated from the pinned reference by
read it, so they match 2.x even for commands not ported yet. The harness
also checks `--help` for every catalog command.
+Catalog regeneration applies the reviewed description/help changes in
+`parity/catalog-updates.json`. Comparisons reconcile only those exact fields
+on descriptor surfaces; command-result JSON and unrelated definitions remain
+unchanged. The run-list help describes one response page instead of promising
+that every run has been retrieved.
+
A case can record an intentional difference with a reason and the exact
fields it excuses; any other differing field still fails. One exists today:
2.x prompts "Do you want to run …?" on stdout even when stdin is not a
diff --git a/rust/parity/cases.json b/rust/parity/cases.json
index f3a7170..3b90bf7 100644
--- a/rust/parity/cases.json
+++ b/rust/parity/cases.json
@@ -4429,6 +4429,1556 @@
}
}
}
+ },
+ {
+ "name": "workflow runs JSON envelope",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs JSONL records",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--output=jsonl"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs human table",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs empty human",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs empty JSONL",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--output=jsonl"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs namespace enrichment JSON",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false,
+ "namespace": "item-orders"
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ },
+ "namespace": "server-orders"
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs namespace enrichment JSONL",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--output=jsonl"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false,
+ "namespace": "item-orders"
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ },
+ "namespace": "server-orders"
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs human count None",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": null,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs human count 0",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 0,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs missing optional fields",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "run_id": "sparse-run"
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs HTTP 401",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 401,
+ "headers": {},
+ "body": {
+ "message": "Run request refused.",
+ "reason": "fixture-rejection",
+ "workflow_id": "wf-polyglot-231"
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs HTTP 403",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 403,
+ "headers": {},
+ "body": {
+ "message": "Run request refused.",
+ "reason": "fixture-rejection",
+ "workflow_id": "wf-polyglot-231"
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs HTTP 404",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 404,
+ "headers": {},
+ "body": {
+ "message": "Run request refused.",
+ "reason": "fixture-rejection",
+ "workflow_id": "wf-polyglot-231"
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs HTTP 422",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 422,
+ "headers": {},
+ "body": {
+ "message": "Run request refused.",
+ "reason": "fixture-rejection",
+ "workflow_id": "wf-polyglot-231"
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs HTTP 503",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 503,
+ "headers": {},
+ "body": {
+ "message": "Run request refused.",
+ "reason": "fixture-rejection",
+ "workflow_id": "wf-polyglot-231"
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs missing response version",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {},
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs unsupported response version",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "99"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs missing contract",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "runs": []
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs noncanonical response field",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ },
+ "run_ids": []
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs incompatible preflight",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [
+ [
+ [
+ "control_plane",
+ "version"
+ ],
+ "99"
+ ]
+ ],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "name": "workflow runs missing workflow ID",
+ "argv": [
+ "workflow:list-runs",
+ "--json"
+ ],
+ "mock": []
+ },
+ {
+ "name": "workflow runs refused foreign envelope",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "foreign",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "foreign",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "expected_difference": {
+ "*": {
+ "fields": [
+ "exit_code",
+ "stdout_json"
+ ],
+ "reason": "Bind successful list_runs data to the requested workflow and operation; the legacy PHP CLI admits contradictory success metadata (cli#113)."
+ }
+ }
+ },
+ {
+ "name": "workflow runs refused foreign row",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "foreign",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "expected_difference": {
+ "*": {
+ "fields": [
+ "exit_code",
+ "stdout_json"
+ ],
+ "reason": "Bind successful list_runs data to the requested workflow and operation; the legacy PHP CLI admits contradictory success metadata (cli#113)."
+ }
+ }
+ },
+ {
+ "name": "workflow runs refused null row",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": null,
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "expected_difference": {
+ "*": {
+ "fields": [
+ "exit_code",
+ "stdout_json"
+ ],
+ "reason": "Bind successful list_runs data to the requested workflow and operation; the legacy PHP CLI admits contradictory success metadata (cli#113)."
+ }
+ }
+ },
+ {
+ "name": "workflow runs refused nonstring row",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": 42,
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "expected_difference": {
+ "*": {
+ "fields": [
+ "exit_code",
+ "stdout_json"
+ ],
+ "reason": "Bind successful list_runs data to the requested workflow and operation; the legacy PHP CLI admits contradictory success metadata (cli#113)."
+ }
+ }
+ },
+ {
+ "name": "workflow runs refused wrong operation",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "describe",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "expected_difference": {
+ "*": {
+ "fields": [
+ "exit_code",
+ "stdout_json"
+ ],
+ "reason": "Bind successful list_runs data to the requested workflow and operation; the legacy PHP CLI admits contradictory success metadata (cli#113)."
+ }
+ }
+ },
+ {
+ "name": "workflow runs refused wrong projection",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-001",
+ "run_number": 1,
+ "workflow_type": "orders.process",
+ "status": "completed",
+ "status_bucket": "completed",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:00:00Z",
+ "closed_at": "2026-04-22T05:04:00Z",
+ "is_current_run": false
+ },
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ },
+ "control_plane_operation": "describe"
+ }
+ }
+ },
+ "expected_difference": {
+ "*": {
+ "fields": [
+ "exit_code",
+ "stdout_json"
+ ],
+ "reason": "Bind successful list_runs data to the requested workflow and operation; the legacy PHP CLI admits contradictory success metadata (cli#113)."
+ }
+ }
+ },
+ {
+ "name": "workflow runs refused nonrecord row",
+ "argv": [
+ "workflow:list-runs",
+ "wf-polyglot-231",
+ "--server={mock}",
+ "--json"
+ ],
+ "mock": [],
+ "mock_routes": {
+ "/api/workflows/wf-polyglot-231/runs": {
+ "status": 200,
+ "headers": {
+ "X-Durable-Workflow-Control-Plane-Version": "2"
+ },
+ "body": {
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "runs": [
+ "not a record",
+ {
+ "workflow_id": "wf-polyglot-231",
+ "run_id": "run-polyglot-231-002",
+ "run_number": 2,
+ "workflow_type": "orders.process",
+ "status": "running",
+ "status_bucket": "active",
+ "task_queue": "orders",
+ "started_at": "2026-04-22T05:05:00Z",
+ "closed_at": null,
+ "is_current_run": true
+ }
+ ],
+ "next_page_token": "retained-without-following",
+ "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response",
+ "version": 1,
+ "operation": "list_runs",
+ "workflow_id": "wf-polyglot-231",
+ "run_count": 2,
+ "contract": {
+ "schema": "durable-workflow.v2.control-plane-response.contract",
+ "version": 1,
+ "legacy_field_policy": "reject_non_canonical",
+ "legacy_fields": {
+ "run_ids": "runs"
+ },
+ "required_fields": [],
+ "success_fields": [
+ "run_count"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "expected_difference": {
+ "*": {
+ "fields": [
+ "exit_code",
+ "stdout_json"
+ ],
+ "reason": "Bind successful list_runs data to the requested workflow and operation; the legacy PHP CLI admits contradictory success metadata (cli#113)."
+ }
+ }
}
]
}
diff --git a/rust/parity/catalog-updates.json b/rust/parity/catalog-updates.json
new file mode 100644
index 0000000..6e208be
--- /dev/null
+++ b/rust/parity/catalog-updates.json
@@ -0,0 +1,12 @@
+{
+ "workflow:list-runs": {
+ "description": {
+ "reference": "List all runs for a workflow execution",
+ "candidate": "List a page of runs for a workflow execution"
+ },
+ "help": {
+ "reference": "List every run (including retries and continue-as-new successors) for a\nworkflow ID, oldest first.\n\nExamples:\n\n dw workflow:list-runs chk-42\n dw workflow:list-runs chk-42 --json",
+ "candidate": "Return one Server response page for a workflow ID, in the Server's order.\nThis command sends one request and does not follow next_page_token.\nJSON keeps the response envelope and any cursor; JSONL emits only the\nreturned run records. The page may include retries and continue-as-new\nsuccessors. Use --json to inspect whether the Server returned a cursor.\n\nExamples:\n\n dw workflow:list-runs chk-42\n dw workflow:list-runs chk-42 --json"
+ }
+ }
+}
diff --git a/rust/parity/compat-vectors.php b/rust/parity/compat-vectors.php
index f55763c..db3994a 100644
--- a/rust/parity/compat-vectors.php
+++ b/rust/parity/compat-vectors.php
@@ -431,17 +431,19 @@ function resultArtifact(string $slug, array $example): array {
$responseInputs[] = with(end($responseInputs), ['control_plane', 'update_id'], ' update-one ');
$listResponseOut = [];
foreach ($responseInputs as $index => $body) {
- foreach ([200, 404] as $status) {
- $result = null;
- $error = null;
- try {
- $result = ControlPlaneResponse::normalize('GET', '/workflows', $body, $status);
- $result['control_plane_version'] ??= '2';
- } catch (RuntimeException $exception) {
- $error = $exception->getMessage();
+ foreach (['/workflows', '/workflows/wf-runs/runs'] as $path) {
+ foreach ([200, 404] as $status) {
+ $result = null;
+ $error = null;
+ try {
+ $result = ControlPlaneResponse::normalize('GET', $path, $body, $status);
+ $result['control_plane_version'] ??= '2';
+ } catch (RuntimeException $exception) {
+ $error = $exception->getMessage();
+ }
+ $listResponseOut[] = ['case' => $index, 'path' => $path, 'body' => $body, 'status' => $status,
+ 'result_json' => $result === null ? null : enc($result), 'error' => $error];
}
- $listResponseOut[] = ['case' => $index, 'body' => $body, 'status' => $status,
- 'result_json' => $result === null ? null : enc($result), 'error' => $error];
}
}
diff --git a/rust/parity/parity.py b/rust/parity/parity.py
index f4265cd..73237be 100755
--- a/rust/parity/parity.py
+++ b/rust/parity/parity.py
@@ -20,6 +20,7 @@
import argparse
import copy
+import html
import http.server
import json
import os
@@ -238,6 +239,9 @@ def compare(reference, candidate, case):
differences = []
for field in fields:
left, right = reference[field], candidate[field]
+ if field in {"stdout_text", "stdout_json"}:
+ left = normalize_catalog_metadata(left, case, "reference")
+ right = normalize_catalog_metadata(right, case, "candidate")
if field == "stdout_text" and case.get("schema_sha256_updates"):
left = normalize_schema_manifest(left, case, "reference")
right = normalize_schema_manifest(right, case, "candidate")
@@ -246,6 +250,44 @@ def compare(reference, candidate, case):
return differences
+def normalize_catalog_metadata(value, case, side, updates=None):
+ """Reconcile exact reviewed help fields on descriptor surfaces only."""
+ argv = case.get("argv", [])
+ if argv and argv[0] not in {"list", "help"} and not any(flag in argv for flag in ["--help", "-h"]):
+ return value
+ if updates is None:
+ with open(os.path.join(CRATE, "parity", "catalog-updates.json"), encoding="utf-8") as handle:
+ updates = json.load(handle)
+
+ def visit(item):
+ if isinstance(item, list):
+ return [visit(child) for child in item]
+ if isinstance(item, dict):
+ result = {key: visit(child) for key, child in item.items()}
+ command = item.get("name")
+ if (isinstance(command, str) and command in updates
+ and ("definition" in item or "--short" in argv)):
+ for field, update in updates[command].items():
+ if item.get(field) == update[side]:
+ result[field] = f""
+ return result
+ return item
+
+ if isinstance(value, str):
+ for command, fields in updates.items():
+ if command not in value:
+ continue
+ for field, update in fields.items():
+ text = update[side]
+ plain = re.sub(r"?(?:info|comment)>", "", text)
+ for rendered in dict.fromkeys([text, html.escape(text, quote=False), plain]):
+ if value.count(rendered) == 1:
+ value = value.replace(rendered, f"")
+ break
+ return value
+ return visit(value)
+
+
def patched_cluster_info(patches):
"""The fixture with each [path, value] patch applied; a null value deletes."""
with open(MOCK_CLUSTER_INFO, encoding="utf-8") as handle:
@@ -355,6 +397,11 @@ def help_cases():
"fields": ["stdout_text"],
"reason": "The Rust-native runtime:check help describes build identity and target instead of PHP extensions (cli#72).",
}}
+ if case["argv"][0] == "workflow:list-runs":
+ case["expected_difference"] = {"*": {
+ "fields": ["stdout_text"],
+ "reason": "The help accurately describes one Server response page and an unconsumed cursor (cli#113).",
+ }}
return cases
diff --git a/rust/parity/test_parity.py b/rust/parity/test_parity.py
index 8178fd4..fb189aa 100644
--- a/rust/parity/test_parity.py
+++ b/rust/parity/test_parity.py
@@ -81,5 +81,55 @@ def test_invalid_manifest_is_a_difference(self):
self.assertEqual(["stdout_text"], self.differences(candidate=candidate))
+class CatalogMetadataParityTest(unittest.TestCase):
+ def setUp(self):
+ self.updates = {"target:command": {
+ "description": {"reference": "before", "candidate": "after"},
+ "help": {"reference": "before help", "candidate": "after help"},
+ }}
+ self.reference = {"name": "target:command", "description": "before", "help": "before help",
+ "definition": {"arguments": ["unchanged"]}}
+ self.candidate = dict(self.reference, description="after", help="after help")
+
+ def normalize(self, value, side, argv=None):
+ return parity.normalize_catalog_metadata(value, {"argv": argv or ["list"]}, side, self.updates)
+
+ def test_only_exact_owned_metadata_fields_are_reconciled(self):
+ self.assertEqual(self.normalize(self.reference, "reference"), self.normalize(self.candidate, "candidate"))
+ for field, value in [("description", "unexpected"), ("name", "other:command"),
+ ("definition", {"arguments": ["changed"]})]:
+ candidate = dict(self.candidate, **{field: value})
+ self.assertNotEqual(self.normalize(self.reference, "reference"), self.normalize(candidate, "candidate"))
+
+ def test_command_results_cannot_enable_catalog_normalization(self):
+ self.assertEqual(self.reference, self.normalize(self.reference, "reference", ["target:command", "--json"]))
+
+ def test_nested_argument_definitions_with_non_string_names_are_preserved(self):
+ for name in [{"argument": "name"}, [], 7, None]:
+ value = {"name": name, "definition": {}}
+ self.assertEqual(value, self.normalize(value, "candidate"))
+
+ def test_short_descriptor_entries_reconcile_only_the_owned_description(self):
+ reference = {"name": "target:command", "description": "before", "extra": "kept"}
+ candidate = dict(reference, description="after")
+ argv = ["list", "--short", "--format=json"]
+ self.assertEqual(self.normalize(reference, "reference", argv), self.normalize(candidate, "candidate", argv))
+ candidate["extra"] = "changed"
+ self.assertNotEqual(self.normalize(reference, "reference", argv), self.normalize(candidate, "candidate", argv))
+
+ def test_text_requires_the_owned_command_and_a_unique_known_value(self):
+ reference = "target:command before\n"
+ candidate = "target:command after\n"
+ self.assertEqual(self.normalize(reference, "reference"), self.normalize(candidate, "candidate"))
+ for altered in ["other:command after\n", "target:command unexpected\n", candidate + "after\n"]:
+ self.assertNotEqual(self.normalize(reference, "reference"), self.normalize(altered, "candidate"))
+
+ def test_markdown_style_removal_keeps_the_exact_owned_help_change(self):
+ self.updates["target:command"]["help"] = {
+ "reference": "before help", "candidate": "after help"}
+ self.assertEqual(self.normalize("target:command\nbefore help\n", "reference"),
+ self.normalize("target:command\nafter help\n", "candidate"))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/rust/parity/update-catalog.sh b/rust/parity/update-catalog.sh
index 47cc03b..7a00ef3 100755
--- a/rust/parity/update-catalog.sh
+++ b/rust/parity/update-catalog.sh
@@ -10,7 +10,7 @@ reference="$crate/target/dw-reference"
# Help texts embed the invoked path (%command.full_name%). Store placeholders
# that the CLI fills from its own argv[0], never this machine's path.
-DW_CONFIG_HOME="$(mktemp -d)" "$reference" list --format=json | DW_REFERENCE="$reference" python3 -c '
+DW_CONFIG_HOME="$(mktemp -d)" "$reference" list --format=json | DW_REFERENCE="$reference" DW_CATALOG_UPDATES="$crate/parity/catalog-updates.json" python3 -c '
import json, os, sys
reference = os.environ["DW_REFERENCE"]
@@ -24,6 +24,8 @@ def placeholders(value):
return value
listing = placeholders(json.load(sys.stdin))
+with open(os.environ["DW_CATALOG_UPDATES"], encoding="utf-8") as handle:
+ updates = json.load(handle)
commands = []
for command in listing["commands"]:
usage = command["usage"]
@@ -36,6 +38,10 @@ for command in listing["commands"]:
"help": command["help"],
"definition": command["definition"],
})
+ for field, update in updates.get(command["name"], {}).items():
+ if commands[-1][field] != update["reference"]:
+ raise SystemExit("Reference catalog metadata changed: " + command["name"] + ":" + field)
+ commands[-1][field] = update["candidate"]
json.dump({"source": "dw " + listing["application"]["version"].split(" ")[0], "commands": commands},
sys.stdout, indent=1, sort_keys=False)
print()
diff --git a/rust/src/catalog.json b/rust/src/catalog.json
index 22709e8..9492f48 100644
--- a/rust/src/catalog.json
+++ b/rust/src/catalog.json
@@ -11681,11 +11681,11 @@
},
{
"name": "workflow:list-runs",
- "description": "List all runs for a workflow execution",
+ "description": "List a page of runs for a workflow execution",
"synopsis": "workflow:list-runs [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--json] [--] ",
"aliases": [],
"hidden": false,
- "help": "List every run (including retries and continue-as-new successors) for a\nworkflow ID, oldest first.\n\nExamples:\n\n dw workflow:list-runs chk-42\n dw workflow:list-runs chk-42 --json",
+ "help": "Return one Server response page for a workflow ID, in the Server's order.\nThis command sends one request and does not follow next_page_token.\nJSON keeps the response envelope and any cursor; JSONL emits only the\nreturned run records. The page may include retries and continue-as-new\nsuccessors. Use --json to inspect whether the Server returned a cursor.\n\nExamples:\n\n dw workflow:list-runs chk-42\n dw workflow:list-runs chk-42 --json",
"definition": {
"arguments": {
"workflow-id": {
diff --git a/rust/src/client.rs b/rust/src/client.rs
index ea3ecc5..9d3fa52 100644
--- a/rust/src/client.rs
+++ b/rust/src/client.rs
@@ -93,12 +93,26 @@ impl Context {
/// One SDK request, with the PHP CLI's workflow response contract.
pub fn workflow_list(&self, query: &[(String, String)]) -> Result {
- let mut request = RawRequest::get(["workflows"])
- .protocol_version(compat::worker_protocol_version())
- .control_plane_version(compat::control_plane_version());
+ let mut request = RawRequest::get(["workflows"]);
for (name, value) in query {
request = request.query(name, value);
}
+ self.workflow_read(request, "/workflows")
+ }
+
+ /// Read one workflow's runs. The SDK encodes the identifier as one segment.
+ pub fn workflow_runs(&self, workflow_id: &str) -> Result {
+ let result = self.workflow_read(
+ RawRequest::get(["workflows", workflow_id, "runs"]),
+ &format!("/workflows/{workflow_id}/runs"),
+ )?;
+ workflow_response::bind_runs(result, workflow_id).map_err(CliError::Failure)
+ }
+
+ fn workflow_read(&self, request: RawRequest, path: &str) -> Result {
+ let request = request
+ .protocol_version(compat::worker_protocol_version())
+ .control_plane_version(compat::control_plane_version());
let response = self.runtime.block_on(async {
self.sdk()?
.control_plane_request(request)
@@ -107,8 +121,12 @@ impl Context {
})?;
let status = response.status();
let body = response.json().unwrap_or(Value::Null);
- let normalized =
- workflow_response::normalize(body.clone(), response.control_plane_version(), status);
+ let normalized = workflow_response::normalize(
+ body.clone(),
+ response.control_plane_version(),
+ status,
+ path,
+ );
if status >= 400 {
let payload = if status == 401 || status == 403 {
body
@@ -193,6 +211,7 @@ pub fn map_error(error: SdkError) -> CliError {
SdkError::InvalidBaseUrl => CliError::Invalid(
"Invalid server URL: pass the Server or Cloud runtime base URL without the /api suffix.".into(),
),
+ SdkError::InvalidRawRequest(message) => CliError::Invalid(message),
other => CliError::Failure(other.to_string()),
}
}
diff --git a/rust/src/client/workflow_response.rs b/rust/src/client/workflow_response.rs
index 9e023d3..a1d1d96 100644
--- a/rust/src/client/workflow_response.rs
+++ b/rust/src/client/workflow_response.rs
@@ -1,11 +1,9 @@
-//! The existing PHP workflow-list response boundary. The SDK owns HTTP;
+//! The existing PHP workflow response boundary. The SDK owns HTTP;
//! the CLI preserves its published envelope checks and field projection.
use crate::{compat, php};
use serde_json::{Map, Value};
-const PATH: &str = "/workflows";
-
fn text(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
@@ -13,48 +11,48 @@ fn text(value: Option<&Value>) -> Option<&str> {
.filter(|value| !value.is_empty())
}
-fn require_text(value: Option<&Value>, message: &str) -> Result<(), String> {
+fn require_text(value: Option<&Value>, message: &str, path: &str) -> Result<(), String> {
text(value)
.map(|_| ())
- .ok_or_else(|| format!("Server error: {message} for [{PATH}]."))
+ .ok_or_else(|| format!("Server error: {message} for [{path}]."))
}
-fn require_version(value: Option<&Value>, message: &str) -> Result<(), String> {
+fn require_version(value: Option<&Value>, message: &str, path: &str) -> Result<(), String> {
if (php::is_int(value) || php::as_str(value).is_some_and(php::ctype_digit))
&& php::int_cast(value) > 0
{
Ok(())
} else {
- Err(format!("Server error: {message} for [{PATH}]."))
+ Err(format!("Server error: {message} for [{path}]."))
}
}
-fn fields(definition: &Value, name: &str) -> Result, String> {
+fn fields(definition: &Value, name: &str, path: &str) -> Result, String> {
let values = php::get(definition, name).filter(|value| php::is_array(value)).ok_or_else(|| {
- format!("Server error: control-plane contract definition for [{PATH}] is missing required field [{name}].")
+ format!("Server error: control-plane contract definition for [{path}] is missing required field [{name}].")
})?;
php::values(values).into_iter().map(|value| {
text(Some(value)).map(str::to_owned).ok_or_else(|| {
- format!("Server error: control-plane contract definition for [{PATH}] contains an invalid [{name}] entry.")
+ format!("Server error: control-plane contract definition for [{path}] contains an invalid [{name}] entry.")
})
}).collect()
}
-fn legacy_fields(definition: &Value, body: &Map) -> Result<(), String> {
+fn legacy_fields(definition: &Value, body: &Map, path: &str) -> Result<(), String> {
let fields = php::get(definition, "legacy_fields").filter(|value| php::is_array(value)).ok_or_else(|| {
- format!("Server error: control-plane contract definition for [{PATH}] is missing required field [legacy_fields].")
+ format!("Server error: control-plane contract definition for [{path}] is missing required field [legacy_fields].")
})?;
let fields = php::array_map(fields.clone());
// PHP validates the complete mapping before inspecting any response key.
for (name, value) in &fields {
if php::int_key(name).is_some() || php::trim(name).is_empty() || text(Some(value)).is_none()
{
- return Err(format!("Server error: control-plane contract definition for [{PATH}] contains an invalid legacy field mapping."));
+ return Err(format!("Server error: control-plane contract definition for [{path}] contains an invalid legacy field mapping."));
}
}
for (name, value) in fields {
if body.contains_key(&name) {
- return Err(format!("Server error: non-canonical control-plane field [{name}] received for [{PATH}]; expected [{}].", value.as_str().unwrap()));
+ return Err(format!("Server error: non-canonical control-plane field [{name}] received for [{path}]; expected [{}].", value.as_str().unwrap()));
}
}
Ok(())
@@ -65,12 +63,13 @@ fn matching_field(
body: &Map,
field: &str,
value: Option<&Value>,
+ path: &str,
) -> Result<(), String> {
let contract_value = text(value.or_else(|| php::get(contract, field)));
let body_value = text(body.get(field));
if let (Some(expected), Some(actual)) = (contract_value, body_value) {
if expected != actual {
- return Err(format!("Server error: control-plane contract for [{PATH}] reported [{field}={expected}], but the response body reported [{field}={actual}]."));
+ return Err(format!("Server error: control-plane contract for [{path}] reported [{field}={expected}], but the response body reported [{field}={actual}]."));
}
}
Ok(())
@@ -81,26 +80,30 @@ fn validate(
definition: &Value,
body: &Map,
status: u16,
+ path: &str,
) -> Result<(), String> {
require_text(
php::get(contract, "schema"),
"invalid control-plane contract schema",
+ path,
)?;
require_version(
php::get(contract, "version"),
"invalid control-plane contract version",
+ path,
)?;
require_text(
php::get(contract, "operation"),
"control-plane contract is missing required field [operation]",
+ path,
)?;
- for field in fields(definition, "required_fields")? {
+ for field in fields(definition, "required_fields", path)? {
if text(php::get(contract, &field)).is_none() {
- return Err(format!("Server error: control-plane contract for [{PATH}] is missing required field [{field}]."));
+ return Err(format!("Server error: control-plane contract for [{path}] is missing required field [{field}]."));
}
}
for field in ["workflow_id", "run_id", "update_id"] {
- matching_field(contract, body, field, None)?;
+ matching_field(contract, body, field, None, path)?;
}
if let Some(field) = text(php::get(contract, "operation_name_field")) {
matching_field(
@@ -108,45 +111,54 @@ fn validate(
body,
field,
php::get(contract, "operation_name"),
+ path,
)?;
}
if status < 400 {
- for field in fields(definition, "success_fields")? {
+ for field in fields(definition, "success_fields", path)? {
if !php::has_key(contract, &field) {
- return Err(format!("Server error: control-plane contract for [{PATH}] is missing required field [{field}]."));
+ return Err(format!("Server error: control-plane contract for [{path}] is missing required field [{field}]."));
}
}
}
Ok(())
}
-pub fn normalize(body: Value, version: Option<&str>, status: u16) -> Result {
+pub fn normalize(
+ body: Value,
+ version: Option<&str>,
+ status: u16,
+ path: &str,
+) -> Result {
let expected = compat::control_plane_version();
let version = version.map(php::trim).filter(|value| !value.is_empty());
if version != Some(expected.as_str()) {
- return Err(format!("Server error: invalid control-plane response version for [{PATH}]; expected [{expected}], got [{}].", version.unwrap_or("missing")));
+ return Err(format!("Server error: invalid control-plane response version for [{path}]; expected [{expected}], got [{}].", version.unwrap_or("missing")));
}
let mut body = php::array_map(body);
let contract = body.get("control_plane").filter(|value| php::is_array(value)).cloned().ok_or_else(|| {
- format!("Server error: control-plane response for [{PATH}] is missing the shared control-plane contract.")
+ format!("Server error: control-plane response for [{path}] is missing the shared control-plane contract.")
})?;
let definition = php::get(&contract, "contract").filter(|value| php::is_array(value)).ok_or_else(|| {
- format!("Server error: control-plane response for [{PATH}] is missing the shared control-plane contract definition.")
+ format!("Server error: control-plane response for [{path}] is missing the shared control-plane contract definition.")
})?;
require_text(
php::get(definition, "schema"),
"invalid nested control-plane contract schema",
+ path,
)?;
require_version(
php::get(definition, "version"),
"invalid nested control-plane contract version",
+ path,
)?;
require_text(
php::get(definition, "legacy_field_policy"),
"invalid nested control-plane legacy field policy",
+ path,
)?;
- legacy_fields(definition, &body)?;
- validate(&contract, definition, &body, status)?;
+ legacy_fields(definition, &body, path)?;
+ validate(&contract, definition, &body, status, path)?;
for (name, field) in [
("control_plane_schema", "schema"),
("control_plane_schema_version", "version"),
@@ -171,7 +183,7 @@ pub fn normalize(body: Value, version: Option<&str>, status: u16) -> Result, status: u16) -> Result Result {
+ let path = format!("/workflows/{workflow_id}/runs");
+ let contract = body.get("control_plane").unwrap_or(&Value::Null);
+ if body.get("workflow_id").and_then(Value::as_str) != Some(workflow_id)
+ || contract.get("workflow_id").and_then(Value::as_str) != Some(workflow_id)
+ {
+ return Err(format!("Server error: run-list response for [{path}] does not match the requested workflow identity."));
+ }
+ if contract.get("operation").and_then(Value::as_str) != Some("list_runs")
+ || body.get("control_plane_operation").and_then(Value::as_str) != Some("list_runs")
+ {
+ return Err(format!(
+ "Server error: run-list response for [{path}] does not describe operation [list_runs]."
+ ));
+ }
+ let records = body.get("runs").and_then(Value::as_array).ok_or_else(|| {
+ format!("Server error: run-list response for [{path}] must contain run records.")
+ })?;
+ for record in records {
+ let fields = record.as_object().ok_or_else(|| {
+ format!("Server error: run-list response for [{path}] contains an invalid run record.")
+ })?;
+ // Published PHP Server 2.11.8 omits this field. Such rows inherit the
+ // verified envelope; any explicit identity must match without coercion.
+ if fields
+ .get("workflow_id")
+ .is_some_and(|value| value.as_str() != Some(workflow_id))
+ {
+ return Err(format!("Server error: run-list response for [{path}] contains a run for a different or unknown workflow."));
+ }
+ }
+ Ok(body)
+}
+
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "needs DW_COMPAT_PHP_VECTORS from the PHP vector generator"]
- fn workflow_list_response_matches_php() {
+ fn workflow_read_response_matches_php() {
let path = std::env::var("DW_COMPAT_PHP_VECTORS").expect("DW_COMPAT_PHP_VECTORS");
let vectors: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
let cases = vectors["workflow_list_response"].as_array().unwrap();
- assert!(cases.len() >= 250);
+ assert!(cases.len() >= 500);
for case in cases {
let actual = normalize(
case["body"].clone(),
Some("2"),
case["status"].as_u64().unwrap() as u16,
+ case["path"].as_str().unwrap(),
);
match actual {
Ok(body) => {
diff --git a/rust/src/commands/workflow.rs b/rust/src/commands/workflow.rs
index affb481..05dd54a 100644
--- a/rust/src/commands/workflow.rs
+++ b/rust/src/commands/workflow.rs
@@ -1,4 +1,4 @@
-//! The first workflow port: one visibility page from `workflow:list`.
+//! Read-only workflow visibility through the published SDK transport.
use crate::cli::Input;
use crate::client::Context;
@@ -33,6 +33,7 @@ pub fn list(context: &Context, input: &Input) -> Result {
let result = with_namespace(
context.workflow_list(&query)?,
&context.connection.namespace,
+ "workflows",
);
if context.mode.machine_readable() {
output::print_json_list(context.mode, &result, "workflows");
@@ -81,16 +82,76 @@ fn nonempty_scalar(value: Option<&Value>) -> bool {
php::is_scalar(value) && !php::trim(&php::to_string(value)).is_empty()
}
-fn with_namespace(value: Value, fallback: &str) -> Value {
+pub fn list_runs(context: &Context, input: &Input) -> Result {
+ context.preflight(false)?;
+ let workflow_id = input.arg("workflow-id").unwrap_or_default();
+ let result = with_namespace(
+ context.workflow_runs(workflow_id)?,
+ &context.connection.namespace,
+ "runs",
+ );
+ if context.mode.machine_readable() {
+ output::print_json_list(context.mode, &result, "runs");
+ return Ok(0);
+ }
+ let namespace = formatter::display_text(&php::to_string(result.get("namespace")));
+ let runs = result.get("runs").map(php::values).unwrap_or_default();
+ if runs.is_empty() {
+ formatter::writeln_out("No runs found.");
+ formatter::writeln_out(&format!("Namespace: {namespace}"));
+ return Ok(0);
+ }
+ formatter::writeln_out(&format!(
+ "Workflow: {}",
+ formatter::display_text(workflow_id),
+ ));
+ formatter::writeln_out(&format!("Namespace: {namespace}"));
+ let count = result
+ .get("run_count")
+ .filter(|value| !value.is_null())
+ .map(|value| php::to_string(Some(value)))
+ .unwrap_or_else(|| runs.len().to_string());
+ formatter::writeln_out(&format!("Run Count: {}", formatter::display_text(&count)));
+ formatter::writeln_out("");
+ let rows = runs
+ .into_iter()
+ .map(|run| {
+ [
+ "run_id",
+ "run_number",
+ "workflow_type",
+ "status",
+ "task_queue",
+ "started_at",
+ "closed_at",
+ ]
+ .into_iter()
+ .map(|name| cell(run, name))
+ .collect()
+ })
+ .collect::>>();
+ formatter::writeln_out(&output::render_table(
+ &[
+ "Run ID",
+ "#",
+ "Type",
+ "Status",
+ "Task Queue",
+ "Started",
+ "Closed",
+ ],
+ &rows,
+ ));
+ Ok(0)
+}
+
+fn with_namespace(value: Value, fallback: &str, items_key: &str) -> Value {
let mut body = php::array_map(value);
if !nonempty_scalar(body.get("namespace")) {
body.insert("namespace".into(), json!(fallback));
}
let namespace = php::to_string(body.get("namespace"));
- if let Some(workflows) = body
- .get_mut("workflows")
- .filter(|value| php::is_array(value))
- {
+ if let Some(workflows) = body.get_mut(items_key).filter(|value| php::is_array(value)) {
let enrich = |workflow: &mut Value| {
if php::is_array(workflow) && !nonempty_scalar(php::get(workflow, "namespace")) {
let mut fields = php::array_map(workflow.take());
diff --git a/rust/src/error.rs b/rust/src/error.rs
index 0ea89ce..06e4606 100644
--- a/rust/src/error.rs
+++ b/rust/src/error.rs
@@ -150,6 +150,7 @@ fn first_validation_message(body: &Map) -> Option {
pub struct ErrorContext<'a> {
pub command: &'a str,
pub namespace: Option,
+ pub workflow_id: Option,
pub server_flag: Option,
pub env_flag: Option,
}
@@ -339,6 +340,11 @@ pub fn envelope(error: &CliError, context: &ErrorContext) -> Value {
if let CliError::Http { status, body, .. } = error {
add_http_details(&mut envelope, error, *status, body.as_ref());
}
+ if let Some(workflow_id) = &context.workflow_id {
+ envelope
+ .entry("workflow_id")
+ .or_insert_with(|| json!(workflow_id));
+ }
if let CliError::Compatibility(failure) = error {
envelope.insert(
"compatibility".into(),
@@ -404,7 +410,7 @@ fn next_step_lines(next: &[Value]) -> Vec {
/// Human error text for stderr.
pub fn human(error: &CliError, context: &ErrorContext) -> String {
let display = |text: &str| {
- if context.command == "workflow:list" {
+ if matches!(context.command, "workflow:list" | "workflow:list-runs") {
crate::formatter::display_text(text)
} else {
text.to_owned()
@@ -443,6 +449,7 @@ mod tests {
ErrorContext {
command: "workflow:cancel",
namespace: Some("default".into()),
+ workflow_id: None,
server_flag: None,
env_flag: None,
}
@@ -507,6 +514,7 @@ mod tests {
let context = ErrorContext {
command: "server:health",
namespace: None,
+ workflow_id: None,
server_flag: Some("http://127.0.0.1:1".into()),
env_flag: None,
};
diff --git a/rust/src/main.rs b/rust/src/main.rs
index ed09258..2463142 100644
--- a/rust/src/main.rs
+++ b/rust/src/main.rs
@@ -48,6 +48,7 @@ fn handler(name: &str) -> Option {
"server:health" => Handler::Server(server::health),
"server:info" => Handler::Server(server::info),
"workflow:list" => Handler::Server(workflow::list),
+ "workflow:list-runs" => Handler::Server(workflow::list_runs),
"env:list" => Handler::Local(env::list),
"env:set" => Handler::Local(env::set),
"env:use" => Handler::Local(env::use_env),
@@ -58,7 +59,7 @@ fn handler(name: &str) -> Option {
}
/// Commands whose errors carry the resolved namespace.
-const NAMESPACE_SCOPED: &[&str] = &["workflow:list"];
+const NAMESPACE_SCOPED: &[&str] = &["workflow:list", "workflow:list-runs"];
/// Verbs of a group and the command each one names.
type GroupVerbs = &'static [(&'static str, &'static str)];
@@ -310,6 +311,11 @@ fn report(error: &CliError, input: &Input, server_command: bool) -> i32 {
let context = ErrorContext {
command: &input.command,
namespace,
+ workflow_id: input
+ .arg("workflow-id")
+ .map(php::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_owned),
server_flag: input.opt_str("server").map(str::to_string),
env_flag: input.opt_str("env").map(str::to_string),
};
@@ -547,7 +553,7 @@ fn run(raw: Vec) -> i32 {
if !matches!(command.as_str(), "doctor" | "server:start-dev") {
context.warn_if_incompatible(
client::uses_worker_protocol(&command),
- command == "workflow:list",
+ NAMESPACE_SCOPED.contains(&command.as_str()),
);
}
run(&context, &input)
diff --git a/rust/tests/workflow_list.rs b/rust/tests/workflow_list.rs
index 8095f08..199295c 100644
--- a/rust/tests/workflow_list.rs
+++ b/rust/tests/workflow_list.rs
@@ -34,9 +34,13 @@ impl Scratch {
}
fn run(&self, url: &str, args: &[&str]) -> Output {
+ self.run_command("workflow:list", url, args)
+ }
+
+ fn run_command(&self, command: &str, url: &str, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_dw"))
.args([
- "workflow:list",
+ command,
"--server",
url,
"--namespace=orders",
@@ -135,6 +139,16 @@ impl Server {
.cloned()
.collect()
}
+
+ fn runs_requests(&self) -> Vec {
+ self.requests
+ .lock()
+ .unwrap()
+ .iter()
+ .filter(|request| request.starts_with("GET /prefix/api/workflows/"))
+ .cloned()
+ .collect()
+ }
}
impl Drop for Server {
@@ -167,6 +181,21 @@ fn body() -> Value {
body
}
+fn runs_body() -> Value {
+ json!({"workflow_id": "wf-runs", "runs": [
+ {"run_id": "run-one", "run_number": 1, "workflow_type": "orders.process",
+ "status": "completed", "task_queue": "orders", "started_at": "first", "closed_at": "done"},
+ {"run_id": "run-two", "run_number": 2, "workflow_type": "orders.process",
+ "status": "running", "task_queue": "orders", "started_at": "second", "closed_at": null}
+ ], "next_page_token": "retained-without-following", "control_plane": {
+ "schema": "durable-workflow.v2.control-plane-response", "version": 1, "operation": "list_runs",
+ "workflow_id": "wf-runs", "run_count": 2,
+ "contract": {"schema": "durable-workflow.v2.control-plane-response.contract", "version": 1,
+ "legacy_field_policy": "reject_non_canonical", "legacy_fields": {},
+ "required_fields": [], "success_fields": ["run_count"]}
+ }})
+}
+
fn success(output: &Output) -> Value {
assert_eq!(
output.status.code(),
@@ -417,3 +446,227 @@ fn malformed_success_is_refused_and_remote_human_values_are_not_terminal_instruc
let machine = success(&scratch.run(&server.url, &["--json"]));
assert_eq!(machine["workflows"][0]["workflow_id"], attack);
}
+
+#[test]
+fn runs_encodes_one_identifier_with_sdk_auth_and_preserves_the_exact_envelope() {
+ let scratch = Scratch::new();
+ let id = "order /?#é";
+ let mut response = runs_body();
+ response["workflow_id"] = json!(id);
+ response["control_plane"]["workflow_id"] = json!(id);
+ let server = Server::new(info(), 200, Some("2"), response);
+ let output = scratch.run_command("workflow:list-runs", &server.url, &[id, "--json"]);
+ let result = success(&output);
+ assert_eq!(result["namespace"], "orders");
+ assert_eq!(result["runs"][0]["namespace"], "orders");
+ assert_eq!(result["run_count"], 2);
+ assert_eq!(result["next_page_token"], "retained-without-following");
+ assert_eq!(result["control_plane_version"], "2");
+ assert_eq!(result["control_plane_operation"], "list_runs");
+ let requests = server.runs_requests();
+ assert_eq!(requests.len(), 1);
+ let request = requests[0].to_lowercase();
+ assert!(request.starts_with("get /prefix/api/workflows/order%20%2f%3f%23%c3%a9/runs "));
+ for header in [
+ "x-namespace: orders",
+ "authorization: bearer fixture-token",
+ "x-durable-workflow-protocol-version: 1.0",
+ "x-durable-workflow-control-plane-version: 2",
+ ] {
+ assert!(request.contains(header), "missing {header}: {request}");
+ }
+}
+
+#[test]
+fn runs_jsonl_preserves_existing_namespaces_and_never_follows_a_cursor() {
+ let scratch = Scratch::new();
+ let mut response = runs_body();
+ response["namespace"] = json!("server-orders");
+ response["runs"][0]["namespace"] = json!("item-orders");
+ let server = Server::new(info(), 200, Some("2"), response);
+ let output = scratch.run_command(
+ "workflow:list-runs",
+ &server.url,
+ &["wf-runs", "--output=jsonl"],
+ );
+ assert_eq!(output.status.code(), Some(0));
+ let items: Vec = String::from_utf8(output.stdout)
+ .unwrap()
+ .lines()
+ .map(|line| serde_json::from_str(line).unwrap())
+ .collect();
+ assert_eq!(items.len(), 2);
+ assert_eq!(items[0]["namespace"], "item-orders");
+ assert_eq!(items[1]["namespace"], "server-orders");
+ assert_eq!(server.runs_requests().len(), 1);
+}
+
+#[test]
+fn runs_human_count_empty_result_and_table_follow_php() {
+ let scratch = Scratch::new();
+ for count in [json!(null), json!(0), json!("server-count")] {
+ let mut response = runs_body();
+ response["run_count"] = count.clone();
+ let server = Server::new(info(), 200, Some("2"), response);
+ let output = scratch.run_command("workflow:list-runs", &server.url, &["wf-runs"]);
+ assert_eq!(output.status.code(), Some(0));
+ let human = String::from_utf8(output.stdout).unwrap();
+ let expected = match count {
+ Value::Null => "2".into(),
+ value => value.to_string().trim_matches('"').to_owned(),
+ };
+ assert!(
+ human.starts_with(&format!(
+ "Workflow: wf-runs\nNamespace: orders\nRun Count: {expected}\n\n"
+ )),
+ "{human}"
+ );
+ assert!(
+ human.contains("Run ID") && human.contains("Task Queue") && human.contains("run-two")
+ );
+ }
+ let mut response = runs_body();
+ response["runs"] = json!([]);
+ let server = Server::new(info(), 200, Some("2"), response);
+ let output = scratch.run_command("workflow:list-runs", &server.url, &["wf-runs"]);
+ assert_eq!(output.status.code(), Some(0));
+ assert_eq!(
+ String::from_utf8(output.stdout).unwrap(),
+ "No runs found.\nNamespace: orders\n"
+ );
+}
+
+#[test]
+fn runs_http_failures_are_not_retried_or_masked_by_response_contracts() {
+ let scratch = Scratch::new();
+ for (status, exit) in [(401, 4), (403, 4), (404, 5), (422, 2), (500, 6)] {
+ let server = Server::new(
+ info(),
+ status,
+ None,
+ json!({"message": "run rejection", "workflow_id": "server-workflow", "reason": "fixture"}),
+ );
+ let output = scratch.run_command("workflow:list-runs", &server.url, &["wf-runs", "--json"]);
+ assert_eq!(output.status.code(), Some(exit));
+ let error: Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(error["namespace"], "orders");
+ assert_eq!(error["command"], "workflow:list-runs");
+ assert_eq!(error["workflow_id"], "server-workflow");
+ assert_eq!(error["reason"], "fixture");
+ assert_eq!(server.runs_requests().len(), 1);
+ }
+}
+
+#[test]
+fn runs_usage_and_sdk_path_validation_send_no_target_request() {
+ let scratch = Scratch::new();
+ let server = Server::new(info(), 200, Some("2"), runs_body());
+ assert_eq!(
+ scratch
+ .run_command("workflow:list-runs", &server.url, &[])
+ .status
+ .code(),
+ Some(1)
+ );
+ assert!(server.requests.lock().unwrap().is_empty());
+ for id in ["", ".", ".."] {
+ let output = scratch.run_command("workflow:list-runs", &server.url, &[id, "--json"]);
+ assert_eq!(output.status.code(), Some(2));
+ let error: Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(error["exit_code"], 2);
+ assert_eq!(error["namespace"], "orders");
+ }
+ assert!(server.runs_requests().is_empty());
+}
+
+#[test]
+fn runs_contract_errors_name_the_requested_path_and_human_values_stay_literal() {
+ let scratch = Scratch::new();
+ let server = Server::new(info(), 200, None, runs_body());
+ let output = scratch.run_command("workflow:list-runs", &server.url, &["wf-runs", "--json"]);
+ assert_eq!(output.status.code(), Some(1));
+ let error: Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert!(error["error"]
+ .as_str()
+ .unwrap()
+ .contains("[/workflows/wf-runs/runs]"));
+ assert_eq!(error["workflow_id"], "wf-runs");
+ let attack = "UNTRUSTED\nFORGED\u{1b}[2J";
+ let mut response = runs_body();
+ response["runs"][0]["run_id"] = json!(attack);
+ response["runs"][0]["status"] = json!(attack);
+ response["run_count"] = json!(attack);
+ response["workflow_id"] = json!(attack);
+ response["control_plane"]["workflow_id"] = json!(attack);
+ let server = Server::new(info(), 200, Some("2"), response);
+ let output = scratch.run_command("workflow:list-runs", &server.url, &[attack]);
+ assert_eq!(output.status.code(), Some(0));
+ let human = String::from_utf8(output.stdout).unwrap();
+ assert!(human.contains("UNTRUSTED\\nFORGED\\u{001b}[2J"));
+ assert!(!human
+ .chars()
+ .any(|character| character.is_control() && character != '\n'));
+ let result =
+ success(&scratch.run_command("workflow:list-runs", &server.url, &[attack, "--json"]));
+ assert_eq!(result["runs"][0]["run_id"], attack);
+}
+
+#[test]
+fn runs_success_must_match_the_requested_workflow_operation_and_present_row_identities() {
+ let scratch = Scratch::new();
+ let mut cases = Vec::new();
+ let mut foreign = runs_body();
+ foreign["workflow_id"] = json!("foreign");
+ foreign["control_plane"]["workflow_id"] = json!("foreign");
+ cases.push(foreign);
+ for value in [json!("foreign"), json!(null), json!(42)] {
+ let mut response = runs_body();
+ response["runs"][0]["workflow_id"] = value;
+ cases.push(response);
+ }
+ let mut wrong_operation = runs_body();
+ wrong_operation["control_plane"]["operation"] = json!("describe");
+ cases.push(wrong_operation);
+ let mut wrong_projection = runs_body();
+ wrong_projection["control_plane_operation"] = json!("describe");
+ cases.push(wrong_projection);
+ let mut scalar_run = runs_body();
+ scalar_run["runs"][0] = json!("not a run record");
+ cases.push(scalar_run);
+ let expected = vec![Some(1); cases.len()];
+ let mut codes = Vec::new();
+ for response in cases {
+ let server = Server::new(info(), 200, Some("2"), response);
+ let output = scratch.run_command("workflow:list-runs", &server.url, &["wf-runs", "--json"]);
+ codes.push(output.status.code());
+ if output.status.code() == Some(1) {
+ let error: Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(error["workflow_id"], "wf-runs");
+ assert!(error.get("runs").is_none());
+ }
+ assert_eq!(server.runs_requests().len(), 1);
+ }
+ assert_eq!(codes, expected);
+}
+
+#[test]
+fn runs_accepts_the_published_php_shape_and_matching_explicit_row_identities() {
+ let scratch = Scratch::new();
+ for explicit in [false, true] {
+ let mut response = runs_body();
+ if explicit {
+ for row in response["runs"].as_array_mut().unwrap() {
+ row["workflow_id"] = json!("wf-runs");
+ }
+ }
+ let server = Server::new(info(), 200, Some("2"), response);
+ let result = success(&scratch.run_command(
+ "workflow:list-runs",
+ &server.url,
+ &["wf-runs", "--json"],
+ ));
+ assert_eq!(result["runs"][0].get("workflow_id").is_some(), explicit);
+ assert_eq!(result["next_page_token"], "retained-without-following");
+ assert_eq!(server.runs_requests().len(), 1);
+ }
+}
diff --git a/src/Commands/WorkflowCommand/ListRunsCommand.php b/src/Commands/WorkflowCommand/ListRunsCommand.php
index 07e04a2..7e8a4de 100644
--- a/src/Commands/WorkflowCommand/ListRunsCommand.php
+++ b/src/Commands/WorkflowCommand/ListRunsCommand.php
@@ -17,10 +17,13 @@ protected function configure(): void
{
parent::configure();
$this->setName('workflow:list-runs')
- ->setDescription('List all runs for a workflow execution')
+ ->setDescription('List a page of runs for a workflow execution')
->setHelp(<<<'HELP'
-List every run (including retries and continue-as-new successors) for a
-workflow ID, oldest first.
+Return one Server response page for a workflow ID, in the Server's order.
+This command sends one request and does not follow next_page_token.
+JSON keeps the response envelope and any cursor; JSONL emits only the
+returned run records. The page may include retries and continue-as-new
+successors. Use --json to inspect whether the Server returned a cursor.
Examples: