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"", "", 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: