From 48c397c242b6e98dba384c091337f2fc6d573f73 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:55:17 +0000 Subject: [PATCH 1/2] Port workflow listing through the pinned SDK raw transport --- rust/README.md | 1 + rust/parity/cases.json | 1026 ++++++++++++++++++++++++++ rust/parity/compat-vectors.php | 64 ++ rust/src/client.rs | 52 +- rust/src/client/workflow_response.rs | 224 ++++++ rust/src/commands/doctor.rs | 14 +- rust/src/commands/mod.rs | 1 + rust/src/commands/server.rs | 4 +- rust/src/commands/workflow.rs | 124 ++++ rust/src/compat/request_contract.rs | 6 - rust/src/error.rs | 21 +- rust/src/formatter.rs | 19 + rust/src/main.rs | 14 +- rust/src/output.rs | 7 +- rust/src/php.rs | 14 + rust/tests/workflow_list.rs | 411 +++++++++++ 16 files changed, 1965 insertions(+), 37 deletions(-) create mode 100644 rust/src/client/workflow_response.rs create mode 100644 rust/src/commands/workflow.rs create mode 100644 rust/tests/workflow_list.rs diff --git a/rust/README.md b/rust/README.md index ca05bef..6ce1a30 100644 --- a/rust/README.md +++ b/rust/README.md @@ -24,6 +24,7 @@ every command group reaches parity. | `server:start-dev`: local Laravel launch, Compose prerequisites and Unix signal cleanup | Ported | | `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, namespace and schedule commands | Next slices | | `--tls-verify=false` against HTTPS | CLI support pending | diff --git a/rust/parity/cases.json b/rust/parity/cases.json index fd26e40..49071ad 100644 --- a/rust/parity/cases.json +++ b/rust/parity/cases.json @@ -3392,6 +3392,1032 @@ "env": { "DW_DEV_PHP_SIGNAL": "15" } + }, + { + "name": "workflow list JSON preserved envelope", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list JSONL items", + "argv": [ + "workflow:list", + "--server={mock}", + "--output=jsonl" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list human table", + "argv": [ + "workflow:list", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list all filters and page size", + "argv": [ + "workflow:list", + "--server={mock}", + "--type=orders.process", + "--status=running", + "--query=CustomerId=\"42\"", + "--limit=25", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list PHP page size cast", + "argv": [ + "workflow:list", + "--server={mock}", + "--limit=25tail", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list empty query strings", + "argv": [ + "workflow:list", + "--server={mock}", + "--type=", + "--query=", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list empty namespace human", + "argv": [ + "workflow:list", + "--server={mock}", + "--namespace=orders" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 0, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list empty JSONL", + "argv": [ + "workflow:list", + "--server={mock}", + "--output=jsonl" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 0, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list preserve response namespaces", + "argv": [ + "workflow:list", + "--server={mock}", + "--namespace=flag-space", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null, + "namespace": "item-space" + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + }, + "namespace": "server-space" + } + } + } + }, + { + "name": "workflow list missing version header", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": {}, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list wrong version header", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "99" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list missing success contract", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [] + } + } + } + }, + { + "name": "workflow list missing shared definition", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page" + } + } + } + } + }, + { + "name": "workflow list legacy field refusal", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + }, + "query": null + } + } + } + }, + { + "name": "workflow list response identity mismatch", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + }, + "workflow_id": "two" + }, + "workflow_id": "one" + } + } + } + }, + { + "name": "workflow list HTTP 401", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 401, + "headers": {}, + "body": { + "message": "fixture rejection", + "reason": "fixture" + } + } + } + }, + { + "name": "workflow list HTTP 403", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 403, + "headers": {}, + "body": { + "message": "fixture rejection", + "reason": "fixture" + } + } + } + }, + { + "name": "workflow list HTTP 404", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 404, + "headers": {}, + "body": { + "message": "fixture rejection", + "reason": "fixture" + } + } + } + }, + { + "name": "workflow list HTTP 422", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 422, + "headers": {}, + "body": { + "message": "fixture rejection", + "reason": "fixture" + } + } + } + }, + { + "name": "workflow list HTTP 500", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 500, + "headers": {}, + "body": { + "message": "fixture rejection", + "reason": "fixture" + } + } + } + }, + { + "name": "workflow list invalid status filter", + "argv": [ + "workflow:list", + "--server={mock}", + "--status=bogus", + "--json" + ], + "mock": [], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } + }, + { + "name": "workflow list incompatible preflight", + "argv": [ + "workflow:list", + "--server={mock}", + "--json" + ], + "mock": [ + [ + [ + "control_plane", + "version" + ], + "99" + ] + ], + "mock_routes": { + "/api/workflows": { + "status": 200, + "headers": { + "X-Durable-Workflow-Control-Plane-Version": "2" + }, + "body": { + "workflows": [ + { + "workflow_id": "wf-polyglot-list-1", + "run_id": "run-polyglot-list-1", + "workflow_type": "orders.process", + "business_key": "order-42", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:00:00Z", + "closed_at": null + }, + { + "workflow_id": "wf-polyglot-list-2", + "run_id": "run-polyglot-list-2", + "workflow_type": "orders.process", + "business_key": "order-43", + "status": "running", + "task_queue": "orders", + "started_at": "2026-04-22T05:01:00Z", + "closed_at": null + } + ], + "next_page_token": "next-polyglot-page", + "control_plane": { + "schema": "durable-workflow.v2.control-plane-response", + "version": 1, + "operation": "list", + "workflow_count": 2, + "next_page_token": "next-polyglot-page", + "contract": { + "schema": "durable-workflow.v2.control-plane-response.contract", + "version": 1, + "legacy_field_policy": "reject_non_canonical", + "legacy_fields": { + "query": "query_name" + }, + "required_fields": [], + "success_fields": [] + } + } + } + } + } } ] } diff --git a/rust/parity/compat-vectors.php b/rust/parity/compat-vectors.php index 3e528c2..f55763c 100644 --- a/rust/parity/compat-vectors.php +++ b/rust/parity/compat-vectors.php @@ -23,6 +23,7 @@ use DurableWorkflow\Cli\Support\CompatibilityDiagnostics; use DurableWorkflow\Cli\Support\CompatibilityException; use DurableWorkflow\Cli\Support\ControlPlaneRequestContract; +use DurableWorkflow\Cli\Support\ControlPlaneResponse; use DurableWorkflow\Cli\Support\ExternalTaskInputContract; use DurableWorkflow\Cli\Support\ExternalTaskResultContract; use DurableWorkflow\Cli\Support\ServerClient; @@ -382,6 +383,68 @@ function resultArtifact(string $slug, array $example): array { $intOut[] = [$raw, (int) dec($raw)]; } +// Workflow-list response projection, using the same PHP boundary as dw. +$listResponse = [ + 'workflows' => [], + 'control_plane' => [ + 'schema' => 'durable-workflow.v2.control-plane-response', 'version' => 1, + 'operation' => 'list', 'workflow_count' => 0, 'next_page_token' => null, + 'contract' => [ + 'schema' => 'durable-workflow.v2.control-plane-response.contract', 'version' => 1, + 'legacy_field_policy' => 'reject_non_canonical', + 'legacy_fields' => ['query' => 'query_name'], 'required_fields' => [], 'success_fields' => [], + ], + ], +]; +$responseInputs = [$listResponse, [], ['workflows' => []]]; +foreach ([ + ['control_plane'], ['control_plane', 'schema'], ['control_plane', 'version'], + ['control_plane', 'operation'], ['control_plane', 'contract'], + ['control_plane', 'contract', 'schema'], ['control_plane', 'contract', 'version'], + ['control_plane', 'contract', 'legacy_field_policy'], +] as $path) { + foreach ([null, [], '', ' ', 0, -1, true, 1.5, '1', '01', '1.0'] as $value) { + $responseInputs[] = with($listResponse, $path, $value); + } +} +foreach (['required_fields', 'success_fields'] as $field) { + foreach ([null, 'x', [1], [''], [' '], [' missing '], ['workflow_count'], ['next_page_token']] as $value) { + $responseInputs[] = with($listResponse, ['control_plane', 'contract', $field], $value); + } +} +foreach ([null, 'x', [], ['0' => 'x'], [' ' => 'x'], ['x' => ' '], ['x' => 1]] as $value) { + $responseInputs[] = with($listResponse, ['control_plane', 'contract', 'legacy_fields'], $value); +} +foreach ([null, '', 'x'] as $value) { + $responseInputs[] = with($listResponse, ['query'], $value); + foreach (['control_plane_schema', 'control_plane_schema_version', 'control_plane_operation', 'control_plane_version'] as $field) { + $responseInputs[] = with($listResponse, [$field], $value); + } +} +foreach (['workflow_id', 'run_id', 'update_id'] as $field) { + $responseInputs[] = with(with($listResponse, ['control_plane', $field], 'one'), [$field], 'two'); + $responseInputs[] = with(with($listResponse, ['control_plane', $field], ' one '), [$field], 'one'); +} +$responseInputs[] = with(with($listResponse, ['control_plane', 'operation_name_field'], 'query_name'), ['control_plane', 'operation_name'], 'status'); +$responseInputs[] = with(end($responseInputs), ['query_name'], 'different'); +$responseInputs[] = with($listResponse, ['control_plane', 'operation'], 'update'); +$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(); + } + $listResponseOut[] = ['case' => $index, 'body' => $body, 'status' => $status, + 'result_json' => $result === null ? null : enc($result), 'error' => $error]; + } +} + file_put_contents($outFile, json_encode([ 'php_version' => PHP_VERSION, 'cluster' => $clusterOut, @@ -393,5 +456,6 @@ function resultArtifact(string $slug, array $example): array { 'json_encode' => $encodeOut, 'float_string' => $stringOut, 'int_cast' => $intOut, + 'workflow_list_response' => $listResponseOut, ], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE)."\n"); echo "ok ".count($clusterOut)." cluster vectors\n"; diff --git a/rust/src/client.rs b/rust/src/client.rs index 6987e45..c14e97f 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -6,10 +6,12 @@ use crate::config::{self, Connection, ConnectionFlags, ProfileStore}; use crate::error::CliError; use crate::output::{resolve_mode, Mode}; use crate::{build_info, compat}; -use durable_workflow::{Client, Error as SdkError}; +use durable_workflow::{Client, Error as SdkError, RawRequest}; use serde_json::Value; use std::time::Duration; +mod workflow_response; + /// How long the compatibility preflight waits for cluster info, as in 2.x. pub const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(5); @@ -89,6 +91,51 @@ impl Context { .block_on(async { client.cluster_info().await.map_err(map_error) }) } + /// 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()); + for (name, value) in query { + request = request.query(name, value); + } + let response = self.runtime.block_on(async { + self.sdk()? + .control_plane_request(request) + .await + .map_err(map_error) + })?; + let status = response.status(); + let body = response.json().unwrap_or(Value::Null); + let normalized = + workflow_response::normalize(body.clone(), response.control_plane_version(), status); + if status >= 400 { + let payload = if status == 401 || status == 403 { + body + } else { + normalized.unwrap_or(body) + }; + let mut error = CliError::from_http(status, &crate::php::json_encode(&payload)); + if status == 403 && self.managed_namespace() { + if let CliError::Http { message, .. } = &mut error { + message.push_str(" This client/control-plane operation requires a client runtime credential; worker runtime credentials are limited to worker-protocol operations."); + } + } + return Err(error); + } + normalized.map_err(CliError::Failure) + } + + fn managed_namespace(&self) -> bool { + reqwest::Url::parse(&self.connection.server) + .ok() + .is_some_and(|url| { + url.path() + .rsplit_once("/api/runtime/v1/namespaces/") + .is_some_and(|(_, namespace)| !namespace.is_empty() && !namespace.contains('/')) + }) + } + /// The 2.x preflight every server command except `doctor`, /// `server:health` and `server:start-dev` runs before its first request: /// refuse with exit 8 when the Server is outside what this CLI supports. @@ -113,7 +160,8 @@ pub fn warn_first(info: &Value, include_worker_protocol: bool) { compat::warnings(info, build_info::version(), include_worker_protocol).first() { crate::formatter::writeln_err(&format!( - "{warning}{}", + "{}{}", + crate::formatter::display_text(warning), compat::WARNING_SUFFIX )); } diff --git a/rust/src/client/workflow_response.rs b/rust/src/client/workflow_response.rs new file mode 100644 index 0000000..9e023d3 --- /dev/null +++ b/rust/src/client/workflow_response.rs @@ -0,0 +1,224 @@ +//! The existing PHP workflow-list 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) + .map(php::trim) + .filter(|value| !value.is_empty()) +} + +fn require_text(value: Option<&Value>, message: &str) -> Result<(), String> { + text(value) + .map(|_| ()) + .ok_or_else(|| format!("Server error: {message} for [{PATH}].")) +} + +fn require_version(value: Option<&Value>, message: &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}].")) + } +} + +fn fields(definition: &Value, name: &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}].") + })?; + 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.") + }) + }).collect() +} + +fn legacy_fields(definition: &Value, body: &Map) -> 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].") + })?; + 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.")); + } + } + 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())); + } + } + Ok(()) +} + +fn matching_field( + contract: &Value, + body: &Map, + field: &str, + value: Option<&Value>, +) -> 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}].")); + } + } + Ok(()) +} + +fn validate( + contract: &Value, + definition: &Value, + body: &Map, + status: u16, +) -> Result<(), String> { + require_text( + php::get(contract, "schema"), + "invalid control-plane contract schema", + )?; + require_version( + php::get(contract, "version"), + "invalid control-plane contract version", + )?; + require_text( + php::get(contract, "operation"), + "control-plane contract is missing required field [operation]", + )?; + for field in fields(definition, "required_fields")? { + if text(php::get(contract, &field)).is_none() { + 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)?; + } + if let Some(field) = text(php::get(contract, "operation_name_field")) { + matching_field( + &Value::Null, + body, + field, + php::get(contract, "operation_name"), + )?; + } + if status < 400 { + for field in fields(definition, "success_fields")? { + if !php::has_key(contract, &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 { + 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"))); + } + 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.") + })?; + 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.") + })?; + require_text( + php::get(definition, "schema"), + "invalid nested control-plane contract schema", + )?; + require_version( + php::get(definition, "version"), + "invalid nested control-plane contract version", + )?; + require_text( + php::get(definition, "legacy_field_policy"), + "invalid nested control-plane legacy field policy", + )?; + legacy_fields(definition, &body)?; + validate(&contract, definition, &body, status)?; + for (name, field) in [ + ("control_plane_schema", "schema"), + ("control_plane_schema_version", "version"), + ("control_plane_operation", "operation"), + ] { + if body.get(name).is_none_or(Value::is_null) { + body.insert( + name.into(), + php::get(&contract, field).cloned().unwrap_or(Value::Null), + ); + } + } + for (name, value) in php::array_map(contract.clone()) { + if !matches!(name.as_str(), "schema" | "version" | "contract") { + body.entry(name).or_insert(value); + } + } + if let Some(field) = text(php::get(&contract, "operation_name_field")) { + if let Some(name) = php::raw_get(&contract, "operation_name") { + body.entry(field).or_insert_with(|| name.clone()); + } + } + if status < 400 && text(php::get(&contract, "operation")) == Some("update") { + let update_id = text(body.get("update_id")).or_else(|| text(php::get(&contract, "update_id"))) + .ok_or_else(|| format!("Server error: successful workflow update response for [{PATH}] is missing required field [update_id]."))?.to_owned(); + body.insert("update_id".into(), Value::String(update_id)); + } + if body.get("control_plane_version").is_none_or(Value::is_null) { + body.insert("control_plane_version".into(), Value::String(expected)); + } + Ok(Value::Object(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() { + 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); + for case in cases { + let actual = normalize( + case["body"].clone(), + Some("2"), + case["status"].as_u64().unwrap() as u16, + ); + match actual { + Ok(body) => { + assert!( + case["error"].is_null(), + "case {}: expected error {}", + case["case"], + case["error"] + ); + assert_eq!( + php::json_encode(&body), + case["result_json"].as_str().unwrap(), + "case {}", + case["case"] + ); + } + Err(error) => assert_eq!( + Some(error.as_str()), + case["error"].as_str(), + "case {}", + case["case"] + ), + } + } + } +} diff --git a/rust/src/commands/doctor.rs b/rust/src/commands/doctor.rs index 37df62c..0606437 100644 --- a/rust/src/commands/doctor.rs +++ b/rust/src/commands/doctor.rs @@ -214,19 +214,7 @@ fn text(value: &Value, fallback: &str) -> String { /// Only trusted render templates may create terminal formatting or layout. /// Keep remote/control text visible as data without changing machine JSON. fn display(value: &Value, fallback: &str) -> String { - let mut safe = String::new(); - for character in text(value, fallback).chars() { - match character { - '\n' => safe.push_str("\\n"), - '\r' => safe.push_str("\\r"), - '\t' => safe.push_str("\\t"), - character if character.is_control() || matches!(character, '\u{2028}' | '\u{2029}') => { - write!(safe, "\\u{{{:04x}}}", u32::from(character)).expect("write to string"); - } - character => safe.push(character), - } - } - formatter::escape(&safe) + formatter::display_text(&text(value, fallback)) } fn line(out: &mut String, label: &str, value: &Value) { diff --git a/rust/src/commands/mod.rs b/rust/src/commands/mod.rs index 8730180..e39a521 100644 --- a/rust/src/commands/mod.rs +++ b/rust/src/commands/mod.rs @@ -8,3 +8,4 @@ pub mod schema; pub mod server; pub mod start_dev; pub mod upgrade; +pub mod workflow; diff --git a/rust/src/commands/server.rs b/rust/src/commands/server.rs index 05a0159..b6b121f 100644 --- a/rust/src/commands/server.rs +++ b/rust/src/commands/server.rs @@ -49,7 +49,7 @@ fn scalar_text(value: &Value) -> Option { } } -pub fn health(context: &client::Context) -> Result { +pub fn health(context: &client::Context, _input: &crate::cli::Input) -> Result { let result = context .runtime() .block_on(async { context.sdk()?.health().await.map_err(client::map_error) })?; @@ -109,7 +109,7 @@ pub fn health(context: &client::Context) -> Result { } /// `server:info`: cluster info after the compatibility preflight. -pub fn info(context: &client::Context) -> Result { +pub fn info(context: &client::Context, _input: &crate::cli::Input) -> Result { let cluster_info = context.preflight(false)?; if context.mode.machine_readable() { print_json(&cluster_info); diff --git a/rust/src/commands/workflow.rs b/rust/src/commands/workflow.rs new file mode 100644 index 0000000..affb481 --- /dev/null +++ b/rust/src/commands/workflow.rs @@ -0,0 +1,124 @@ +//! The first workflow port: one visibility page from `workflow:list`. + +use crate::cli::Input; +use crate::client::Context; +use crate::compat::RequestContract; +use crate::error::CliError; +use crate::{formatter, output, php}; +use serde_json::{json, Value}; + +pub fn list(context: &Context, input: &Input) -> Result { + let info = context.preflight(false)?; + if let Some(status) = input.opt("status").flatten() { + RequestContract::from_cluster_info(&info) + .map_err(CliError::Failure)? + .assert_canonical_value("list", "status", Some(status), Some("--status")) + .map_err(CliError::Invalid)?; + } + let mut query = Vec::new(); + for (option, name) in [ + ("type", "workflow_type"), + ("status", "status"), + ("query", "query"), + ] { + if let Some(value) = input.opt(option).flatten() { + query.push((name.to_string(), value.to_string())); + } + } + let limit = input.opt("limit").flatten().map(|value| json!(value)); + query.push(( + "page_size".into(), + php::int_cast(limit.as_ref()).to_string(), + )); + let result = with_namespace( + context.workflow_list(&query)?, + &context.connection.namespace, + ); + if context.mode.machine_readable() { + output::print_json_list(context.mode, &result, "workflows"); + return Ok(0); + } + let namespace = formatter::display_text(&php::to_string(result.get("namespace"))); + let workflows = result.get("workflows").map(php::values).unwrap_or_default(); + if workflows.is_empty() { + formatter::writeln_out(&format!( + "No workflows found in namespace {namespace}." + )); + return Ok(0); + } + formatter::writeln_out(&format!("Namespace: {namespace}")); + let rows = workflows + .into_iter() + .map(|workflow| { + [ + "workflow_id", + "workflow_type", + "business_key", + "status", + "started_at", + "closed_at", + ] + .into_iter() + .map(|name| cell(workflow, name)) + .collect() + }) + .collect::>>(); + formatter::writeln_out(&output::render_table( + &[ + "Workflow ID", + "Type", + "Business Key", + "Status", + "Started", + "Closed", + ], + &rows, + )); + Ok(0) +} + +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 { + 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)) + { + let enrich = |workflow: &mut Value| { + if php::is_array(workflow) && !nonempty_scalar(php::get(workflow, "namespace")) { + let mut fields = php::array_map(workflow.take()); + fields.insert("namespace".into(), json!(namespace)); + *workflow = Value::Object(fields); + } + }; + match workflows { + Value::Array(items) => items.iter_mut().for_each(enrich), + Value::Object(items) => items.values_mut().for_each(enrich), + _ => {} + } + } + Value::Object(body) +} + +fn cell(workflow: &Value, name: &str) -> String { + let value = php::get(workflow, name); + if name == "status" { + // Escape the remote value before adding the CLI's trusted style. + let escaped = + value.map(|value| json!(formatter::display_text(&php::to_string(Some(value))))); + output::format_status(escaped.as_ref()) + } else { + formatter::display_text( + &value + .map(|value| php::to_string(Some(value))) + .unwrap_or_else(|| "-".into()), + ) + } +} diff --git a/rust/src/compat/request_contract.rs b/rust/src/compat/request_contract.rs index 02f944e..a9d362e 100644 --- a/rust/src/compat/request_contract.rs +++ b/rust/src/compat/request_contract.rs @@ -55,9 +55,6 @@ impl RequestContract { &self.operations } - /// Reserved for the list commands' filter checks (`validateControlPlaneOption`), - /// ported in the next slice. - #[allow(dead_code)] /// `assertCanonicalValue`: rejects a declared alias or a value outside a /// declared canonical set. `option_name` (such as `--duplicate-policy`) /// replaces the field name in the message. @@ -92,7 +89,6 @@ impl RequestContract { )) } - #[allow(dead_code)] fn field_definition(&self, operation: &str, field: &str) -> Option<&Value> { let operation = php::get(&self.operations, operation).filter(|value| php::is_array(value))?; @@ -103,7 +99,6 @@ impl RequestContract { /// `stringMap()` lookup: string keys and values, both trimmed; integer-like /// keys are integers in PHP and are skipped. The last duplicate wins. -#[allow(dead_code)] fn rejected_alias<'a>(aliases: Option<&'a Value>, value: &str) -> Option<&'a str> { let Some(Value::Object(map)) = aliases else { return None; @@ -123,7 +118,6 @@ fn rejected_alias<'a>(aliases: Option<&'a Value>, value: &str) -> Option<&'a str } /// `stringList()`: trimmed non-empty strings. -#[allow(dead_code)] fn string_list(value: Option<&Value>) -> Vec<&str> { value .filter(|value| php::is_array(value)) diff --git a/rust/src/error.rs b/rust/src/error.rs index e8bfeb3..f3b4526 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -403,20 +403,29 @@ fn next_step_lines(next: &[Value]) -> Vec { /// Human error text for stderr. pub fn human(error: &CliError, context: &ErrorContext) -> String { - let mut lines = vec![format!("{}", error.message())]; + let display = crate::formatter::display_text; + let mut lines = vec![format!("{}", display(error.message()))]; if let Some(namespace) = &context.namespace { - lines.push(format!("Namespace: {namespace}")); + lines.push(format!("Namespace: {}", display(namespace))); } if let Some(reason) = error.reason() { - lines.push(format!("Reason: {reason}")); + lines.push(format!("Reason: {}", display(reason))); } if let Some(body) = error.body() { - lines.extend(body_code_lines(body)); + lines.extend(body_code_lines(body).into_iter().map(|line| display(&line))); } if let Some(errors) = error.validation_errors() { - lines.extend(validation_lines(errors)); + lines.extend( + validation_lines(errors) + .into_iter() + .map(|line| display(&line)), + ); } - lines.extend(next_step_lines(&recommendations(error, context))); + lines.extend( + next_step_lines(&recommendations(error, context)) + .into_iter() + .map(|line| display(&line)), + ); lines.join("\n") } diff --git a/rust/src/formatter.rs b/rust/src/formatter.rs index 14e3965..be5e915 100644 --- a/rust/src/formatter.rs +++ b/rust/src/formatter.rs @@ -320,6 +320,25 @@ pub fn escape(text: &str) -> String { out } +/// Render remote values as data. Only trusted templates may create markup +/// or terminal layout; machine JSON keeps the original values. +pub fn display_text(text: &str) -> String { + use std::fmt::Write as _; + let mut safe = String::new(); + for character in text.chars() { + match character { + '\n' => safe.push_str("\\n"), + '\r' => safe.push_str("\\r"), + '\t' => safe.push_str("\\t"), + character if character.is_control() || matches!(character, '\u{2028}' | '\u{2029}') => { + write!(safe, "\\u{{{:04x}}}", u32::from(character)).expect("write to string"); + } + character => safe.push(character), + } + } + escape(&safe) +} + /// PHP's `strip_tags`, used by 2.x's raw text help and listing. pub fn strip_tags(text: &str) -> String { let mut out = String::with_capacity(text.len()); diff --git a/rust/src/main.rs b/rust/src/main.rs index 8259f32..51df20a 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -22,7 +22,7 @@ use output::Mode; use std::io::{BufRead, Write}; type Run = fn(&Input) -> Result; -type RunWithServer = fn(&client::Context) -> Result; +type RunWithServer = fn(&client::Context, &Input) -> Result; enum Handler { Local(Run), @@ -32,7 +32,9 @@ enum Handler { /// Commands ported so far. Every other catalog command is recognized and /// reported as not yet available. fn handler(name: &str) -> Option { - use commands::{completion, doctor, env, runtime, schema, server, start_dev, upgrade}; + use commands::{ + completion, doctor, env, runtime, schema, server, start_dev, upgrade, workflow, + }; Some(match name { "completion" => Handler::Local(completion::completion), "_complete" => Handler::Local(completion::complete), @@ -45,6 +47,7 @@ fn handler(name: &str) -> Option { "upgrade" => Handler::Local(upgrade::run), "server:health" => Handler::Server(server::health), "server:info" => Handler::Server(server::info), + "workflow:list" => Handler::Server(workflow::list), "env:list" => Handler::Local(env::list), "env:set" => Handler::Local(env::set), "env:use" => Handler::Local(env::use_env), @@ -54,9 +57,8 @@ fn handler(name: &str) -> Option { }) } -/// Commands whose errors carry the resolved namespace. None of the commands -/// ported so far are namespace scoped. -const NAMESPACE_SCOPED: &[&str] = &[]; +/// Commands whose errors carry the resolved namespace. +const NAMESPACE_SCOPED: &[&str] = &["workflow:list"]; /// Verbs of a group and the command each one names. type GroupVerbs = &'static [(&'static str, &'static str)]; @@ -545,7 +547,7 @@ fn run(raw: Vec) -> i32 { if !matches!(command.as_str(), "doctor" | "server:start-dev") { context.warn_if_incompatible(client::uses_worker_protocol(&command)); } - run(&context) + run(&context, &input) }), }; match result { diff --git a/rust/src/output.rs b/rust/src/output.rs index bbd51ef..ec277f3 100644 --- a/rust/src/output.rs +++ b/rust/src/output.rs @@ -100,8 +100,11 @@ pub fn print_json(value: &Value) { /// so counts and cursors survive. pub fn print_json_list(mode: Mode, envelope: &Value, items_key: &str) { if mode == Mode::Jsonl { - if let Some(items) = envelope.get(items_key).and_then(Value::as_array) { - for item in items { + if let Some(items) = envelope + .get(items_key) + .filter(|value| crate::php::is_array(value)) + { + for item in crate::php::values(items) { print_json(item); } } diff --git a/rust/src/php.rs b/rust/src/php.rs index 9fd7a0a..2d24ed8 100644 --- a/rust/src/php.rs +++ b/rust/src/php.rs @@ -114,6 +114,20 @@ pub fn values(value: &Value) -> Vec<&Value> { } } +/// A decoded PHP array after a string-key assignment. Numeric list keys +/// keep their order alongside the appended string fields. +pub fn array_map(value: Value) -> serde_json::Map { + match value { + Value::Object(map) => map, + Value::Array(items) => items + .into_iter() + .enumerate() + .map(|(index, value)| (index.to_string(), value)) + .collect(), + _ => serde_json::Map::new(), + } +} + /// PHP `ctype_digit()` for a string argument. pub fn ctype_digit(value: &str) -> bool { !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) diff --git a/rust/tests/workflow_list.rs b/rust/tests/workflow_list.rs new file mode 100644 index 0000000..de1a220 --- /dev/null +++ b/rust/tests/workflow_list.rs @@ -0,0 +1,411 @@ +use serde_json::{json, Value}; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +struct Scratch(PathBuf); + +impl Scratch { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "dw-workflow-list-{}-{now}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&path).unwrap(); + Self(path) + } + + fn run(&self, url: &str, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_dw")) + .args([ + "workflow:list", + "--server", + url, + "--namespace=orders", + "--token=fixture-token", + ]) + .args(args) + .env_clear() + .env("DW_CONFIG_HOME", &self.0) + .env("HOME", &self.0) + .output() + .unwrap() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct Server { + url: String, + requests: Arc>>, + stop: Arc, + thread: Option>, +} + +impl Server { + fn new(info: Value, status: u16, header: Option<&str>, body: Value) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/prefix", listener.local_addr().unwrap()); + listener.set_nonblocking(true).unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let observed = requests.clone(); + let stopped = stop.clone(); + let header = header.map(str::to_owned); + let thread = thread::spawn(move || { + while !stopped.load(Ordering::Relaxed) { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = Vec::new(); + let mut buffer = [0; 2048]; + while !bytes.windows(4).any(|part| part == b"\r\n\r\n") + && bytes.len() < 65536 + { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(size) => bytes.extend_from_slice(&buffer[..size]), + } + } + let request = String::from_utf8(bytes).unwrap(); + let cluster = request.starts_with("GET /prefix/api/cluster/info "); + observed.lock().unwrap().push(request); + let payload = + serde_json::to_vec(if cluster { &info } else { &body }).unwrap(); + let protocol = if cluster { + Some("2") + } else { + header.as_deref() + }; + let protocol = protocol + .map(|version| { + format!("X-Durable-Workflow-Control-Plane-Version: {version}\r\n") + }) + .unwrap_or_default(); + let status = if cluster { 200 } else { status }; + let headers = format!("HTTP/1.1 {status} Test\r\n{protocol}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", payload.len()); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(&payload); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)) + } + Err(error) => panic!("fixture accept failed: {error}"), + } + } + }); + Self { + url, + requests, + stop, + thread: Some(thread), + } + } + + fn list_requests(&self) -> Vec { + self.requests + .lock() + .unwrap() + .iter() + .filter(|request| request.starts_with("GET /prefix/api/workflows?")) + .cloned() + .collect() + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + self.thread.take().unwrap().join().unwrap(); + } +} + +fn info() -> Value { + serde_json::from_str(include_str!( + "../src/compat/fixtures/cluster-info-php-2.11.8.json" + )) + .unwrap() +} + +fn body() -> Value { + let fixture: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/control-plane/workflow-list-parity.json" + )) + .unwrap(); + let mut body = fixture["response_body"].clone(); + body["control_plane"] = json!({ + "schema": "durable-workflow.v2.control-plane-response", "version": 1, "operation": "list", + "workflow_count": 2, "next_page_token": "next-polyglot-page", + "contract": {"schema": "durable-workflow.v2.control-plane-response.contract", "version": 1, + "legacy_field_policy": "reject_non_canonical", "legacy_fields": {"query": "query_name"}, + "required_fields": [], "success_fields": []} + }); + body +} + +fn success(output: &Output) -> Value { + assert_eq!( + output.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn list_uses_published_sdk_transport_filters_and_exact_server_envelope() { + let scratch = Scratch::new(); + let server = Server::new(info(), 200, Some("2"), body()); + let output = scratch.run( + &server.url, + &[ + "--type=orders.process", + "--status=running", + "--query=CustomerId=\"42\"", + "--limit=25", + "--json", + ], + ); + let result = success(&output); + assert_eq!(result["next_page_token"], "next-polyglot-page"); + assert_eq!(result["namespace"], "orders"); + assert_eq!(result["workflows"][0]["namespace"], "orders"); + assert_eq!(result["workflows"][1]["workflow_id"], "wf-polyglot-list-2"); + assert_eq!(result["control_plane_version"], "2"); + assert_eq!(result["control_plane_schema_version"], 1); + assert_eq!(result["workflow_count"], 2); + let requests = server.list_requests(); + assert_eq!(requests.len(), 1); + let request = requests[0].to_lowercase(); + assert!(request.starts_with("get /prefix/api/workflows?workflow_type=orders.process&status=running&query=customerid%3d%2242%22&page_size=25 ")); + 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 jsonl_preserves_items_and_server_namespace_without_following_the_next_page() { + let scratch = Scratch::new(); + let mut response = body(); + response["namespace"] = json!("server-namespace"); + response["workflows"][0]["namespace"] = json!("original-item-namespace"); + let server = Server::new(info(), 200, Some("2"), response); + let output = scratch.run(&server.url, &["--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"], "original-item-namespace"); + assert_eq!(items[1]["namespace"], "server-namespace"); + assert_eq!(server.list_requests().len(), 1); +} + +#[test] +fn aliases_and_visibility_page_sizes_keep_php_casts() { + let scratch = Scratch::new(); + let server = Server::new(info(), 200, Some("2"), body()); + for (value, expected) in [ + ("25trailing", "25"), + ("", "0"), + ("-4", "-4"), + ("1e2", "100"), + ] { + let output = scratch.run(&server.url, &[&format!("--limit={value}"), "--json"]); + success(&output); + assert!(server + .list_requests() + .last() + .unwrap() + .contains(&format!("page_size={expected} "))); + } + for args in [&["workflows:list"][..], &["workflow", "list"][..]] { + let output = Command::new(env!("CARGO_BIN_EXE_dw")) + .args(args) + .args(["--server", &server.url, "--json"]) + .env_clear() + .env("DW_CONFIG_HOME", &scratch.0) + .output() + .unwrap(); + success(&output); + } +} + +#[test] +fn usage_profile_and_contract_refusals_send_no_list_request() { + let scratch = Scratch::new(); + let mut incompatible = info(); + incompatible["control_plane"]["version"] = json!("99"); + let server = Server::new(incompatible, 200, Some("2"), body()); + assert_eq!( + scratch.run(&server.url, &["--bad-option"]).status.code(), + Some(1) + ); + assert!(server.requests.lock().unwrap().is_empty()); + assert_eq!( + scratch + .run(&server.url, &["--env=missing", "--json"]) + .status + .code(), + Some(2) + ); + assert!(server.requests.lock().unwrap().is_empty()); + assert_eq!(scratch.run(&server.url, &["--json"]).status.code(), Some(8)); + assert!(server.list_requests().is_empty()); + + let server = Server::new(info(), 200, Some("2"), body()); + let output = scratch.run(&server.url, &["--status=bogus", "--json"]); + assert_eq!(output.status.code(), Some(2)); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["namespace"], "orders"); + assert!(server.list_requests().is_empty()); + + let server = Server::new(info(), 200, Some("2"), body()); + let https = server.url.replacen("http:", "https:", 1); + assert_eq!( + scratch + .run(&https, &["--tls-verify=false", "--json"]) + .status + .code(), + Some(2) + ); + assert!(server.requests.lock().unwrap().is_empty()); +} + +#[test] +fn selected_profile_supplies_namespace_token_and_jsonl_mode() { + let scratch = Scratch::new(); + let server = Server::new(info(), 200, Some("2"), body()); + fs::write(scratch.0.join("config.json"), serde_json::to_vec(&json!({ + "schema": "durable-workflow.cli.config", "version": 1, "current_env": "fixture", + "envs": {"fixture": {"server": server.url, "namespace": "profile-orders", "output": "jsonl", "token_source": {"type": "env", "value": "CLI_FIXTURE_AUTH"}}} + })).unwrap()).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_dw")) + .arg("workflow:list") + .env_clear() + .env("DW_CONFIG_HOME", &scratch.0) + .env("CLI_FIXTURE_AUTH", "profile-fixture-token") + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + 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"], "profile-orders"); + let requests = server.list_requests(); + assert_eq!(requests.len(), 1); + let request = requests[0].to_lowercase(); + assert!(request.contains("x-namespace: profile-orders")); + assert!(request.contains("authorization: bearer profile-fixture-token")); + assert!(request.starts_with("get /prefix/api/workflows?page_size=20 ")); +} + +#[test] +fn remote_errors_are_literal_in_human_output_and_unchanged_in_json() { + let scratch = Scratch::new(); + let attack = "UNTRUSTED\nFORGED\u{1b}[2J"; + let server = Server::new( + info(), + 422, + None, + json!({"message": attack, "reason": attack, "errors": {"field": [attack]}}), + ); + let output = scratch.run(&server.url, &[]); + assert_eq!(output.status.code(), Some(2)); + let human = String::from_utf8(output.stderr).unwrap(); + assert!(human.contains("UNTRUSTED\\nFORGED\\u{001b}[2J")); + assert!(!human + .chars() + .any(|character| character.is_control() && character != '\n')); + let output = scratch.run(&server.url, &["--json"]); + let machine: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(machine["server_response"]["message"], attack); +} + +#[test] +fn http_failures_remain_http_errors_and_are_not_retried_or_masked_by_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": "fixture rejection", "reason": "fixture"}), + ); + let output = scratch.run(&server.url, &["--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["reason"], "fixture"); + assert_eq!(server.list_requests().len(), 1); + } +} + +#[test] +fn malformed_success_is_refused_and_remote_human_values_are_not_terminal_instructions() { + let scratch = Scratch::new(); + for (header, response) in [ + (None, body()), + (Some("99"), body()), + (Some("2"), json!({"workflows": []})), + ] { + let server = Server::new(info(), 200, header, response); + let output = scratch.run(&server.url, &["--json"]); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(server.list_requests().len(), 1); + } + let attack = "UNTRUSTED\nFORGED\u{1b}[2J\u{2028}"; + let mut response = body(); + response["workflows"][0]["workflow_id"] = json!(attack); + response["workflows"][0]["status"] = json!(attack); + let server = Server::new(info(), 200, Some("2"), response); + let output = scratch.run(&server.url, &[]); + assert_eq!(output.status.code(), Some(0)); + let human = String::from_utf8(output.stdout).unwrap(); + assert!(human.contains("UNTRUSTED\\nFORGED\\u{001b}[2J\\u{2028}")); + assert!(!human + .chars() + .any(|character| character.is_control() && character != '\n')); + let machine = success(&scratch.run(&server.url, &["--json"])); + assert_eq!(machine["workflows"][0]["workflow_id"], attack); +} From 771552a69ca60b59d649a7d4eccc96c92c80d165 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:07:03 +0000 Subject: [PATCH 2/2] Scope literal rendering and document legacy filter error parity --- rust/parity/cases.json | 11 +++++++++++ rust/src/client.rs | 15 ++++++++++++--- rust/src/error.rs | 15 ++++++++++++++- rust/src/main.rs | 5 ++++- rust/tests/workflow_list.rs | 8 ++++++++ 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/rust/parity/cases.json b/rust/parity/cases.json index 49071ad..f3a7170 100644 --- a/rust/parity/cases.json +++ b/rust/parity/cases.json @@ -4294,6 +4294,17 @@ }, { "name": "workflow list invalid status filter", + "expected_difference": { + "*": { + "fields": [ + "stderr_category", + "stderr", + "stdout_json", + "stdout_text" + ], + "reason": "Published PHP 2.3.5 writes canonical-filter validation failures to stderr even with --json. This port follows accepted PHP main's structured JSON error, exit 2 and namespace context (cli#61/#72); focused tests assert the exact envelope and zero list requests." + } + }, "argv": [ "workflow:list", "--server={mock}", diff --git a/rust/src/client.rs b/rust/src/client.rs index c14e97f..ea3ecc5 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -148,20 +148,29 @@ impl Context { /// The 2.x session warning: the first compatibility warning, on stderr, /// before the command runs. Any failure to fetch cluster info is ignored. - pub fn warn_if_incompatible(&self, include_worker_protocol: bool) { + pub fn warn_if_incompatible(&self, include_worker_protocol: bool, literal: bool) { if let Ok(info) = self.cluster_info(Some(PREFLIGHT_TIMEOUT)) { - warn_first(&info, include_worker_protocol); + write_warning(&info, include_worker_protocol, literal); } } } pub fn warn_first(info: &Value, include_worker_protocol: bool) { + write_warning(info, include_worker_protocol, false); +} + +fn write_warning(info: &Value, include_worker_protocol: bool, literal: bool) { if let Some(warning) = compat::warnings(info, build_info::version(), include_worker_protocol).first() { + let warning = if literal { + crate::formatter::display_text(warning) + } else { + warning.clone() + }; crate::formatter::writeln_err(&format!( "{}{}", - crate::formatter::display_text(warning), + warning, compat::WARNING_SUFFIX )); } diff --git a/rust/src/error.rs b/rust/src/error.rs index f3b4526..0ea89ce 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -403,7 +403,13 @@ fn next_step_lines(next: &[Value]) -> Vec { /// Human error text for stderr. pub fn human(error: &CliError, context: &ErrorContext) -> String { - let display = crate::formatter::display_text; + let display = |text: &str| { + if context.command == "workflow:list" { + crate::formatter::display_text(text) + } else { + text.to_owned() + } + }; let mut lines = vec![format!("{}", display(error.message()))]; if let Some(namespace) = &context.namespace { lines.push(format!("Namespace: {}", display(namespace))); @@ -489,6 +495,13 @@ mod tests { ); } + #[test] + fn existing_commands_keep_their_ansi_placeholder_boundaries() { + let error = CliError::Invalid("Use .".into()); + let rendered = crate::formatter::format(&human(&error, &context()), true); + assert!(rendered.contains("\u{1b}[39;49m\u{1b}[37;41m\u{1b}[39;49m\u{1b}[37;41m")); + } + #[test] fn network_errors_point_to_doctor_with_the_selected_target() { let context = ErrorContext { diff --git a/rust/src/main.rs b/rust/src/main.rs index 51df20a..ed09258 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -545,7 +545,10 @@ fn run(raw: Vec) -> i32 { Handler::Server(run) => client::Context::new(&input).and_then(|context| { // doctor and server:start-dev opt out of the session warning in 2.x. if !matches!(command.as_str(), "doctor" | "server:start-dev") { - context.warn_if_incompatible(client::uses_worker_protocol(&command)); + context.warn_if_incompatible( + client::uses_worker_protocol(&command), + command == "workflow:list", + ); } run(&context, &input) }), diff --git a/rust/tests/workflow_list.rs b/rust/tests/workflow_list.rs index de1a220..8095f08 100644 --- a/rust/tests/workflow_list.rs +++ b/rust/tests/workflow_list.rs @@ -290,6 +290,14 @@ fn usage_profile_and_contract_refusals_send_no_list_request() { assert_eq!(output.status.code(), Some(2)); let error: Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(error["namespace"], "orders"); + assert_eq!(error["exit_code"], 2); + assert_eq!(error["command"], "workflow:list"); + assert_eq!( + error["error"], + "Server contract expects --status to be one of [running, completed, failed]; got [bogus]." + ); + assert_eq!(error["recommendations"][0]["id"], "input.invalid"); + assert!(output.stderr.is_empty()); assert!(server.list_requests().is_empty()); let server = Server::new(info(), 200, Some("2"), body());