diff --git a/README.md b/README.md index e9e5454..ccf5525 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ dw workflow:query current-status --json dw workflow:update approve --input='{"approver":"operator"}' dw workflow:cancel --reason="Customer request" dw workflow:history +dw service-call:describe --json ``` ## Automation Contract diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c8a4f18..daaa95a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -513,6 +513,21 @@ dw workflow:history order-123 01HXYZ dw workflow:history order-123 01HXYZ --follow ``` +### Nexus service calls + +Inspect one durable call in its endpoint namespace: + +```bash +dw service-call:describe --namespace=shared +dw service-call:describe --json +``` + +The response preserves the original caller and target, outcome, deadline, +attempt records, typed failure and retained payload references. The command +does not retry or cancel the call, or fetch external objects. JSON output +preserves the complete Server response, including the lossless Avro +`result_envelope` and outcome metadata. + ### Namespaces ```bash diff --git a/schemas/output/manifest.json b/schemas/output/manifest.json index 2d4423b..e3fd4fc 100644 --- a/schemas/output/manifest.json +++ b/schemas/output/manifest.json @@ -6,6 +6,13 @@ "json_schema_draft": "https://json-schema.org/draft/2020-12/schema", "stability": "patch-stable-additive", "commands": { + "service-call:describe": { + "output": "--json", + "schema": "schemas/output/service-call.schema.json", + "schema_id": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/service-call.schema.json", + "resolver_url": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/service-call.schema.json", + "sha256": "sha256:b9058cb4e958c8717676ec68aae46f0710751711249c32249bb021cacb344f3d" + }, "debug": { "output": "--json", "schema": "schemas/output/workflow-debug.schema.json", diff --git a/schemas/output/service-call.schema.json b/schemas/output/service-call.schema.json new file mode 100644 index 0000000..e551583 --- /dev/null +++ b/schemas/output/service-call.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/service-call.schema.json", + "title": "dw service-call:describe --json", + "type": "object", + "required": ["namespace", "id", "status", "service_call_attempts", "retry_attempt_count"], + "properties": { + "namespace": {"type": "string"}, + "id": {"type": "string"}, + "status": {"type": "string"}, + "caller_namespace": {"type": ["string", "null"]}, + "caller_workflow_instance_id": {"type": ["string", "null"]}, + "caller_workflow_run_id": {"type": ["string", "null"]}, + "target_namespace": {"type": ["string", "null"]}, + "linked_workflow_instance_id": {"type": ["string", "null"]}, + "linked_workflow_run_id": {"type": ["string", "null"]}, + "linked_workflow_update_id": {"type": ["string", "null"]}, + "call_deadline_at": {"type": ["string", "null"]}, + "service_call_attempts": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "retry_attempt_count": {"type": "integer", "minimum": 0}, + "caller_observed_error_type": {"type": ["string", "null"]}, + "typed_error_message": {"type": ["string", "null"]} + }, + "additionalProperties": true +} diff --git a/src/Application.php b/src/Application.php index 3404436..f362236 100644 --- a/src/Application.php +++ b/src/Application.php @@ -18,6 +18,7 @@ use DurableWorkflow\Cli\Commands\SchemaCommand; use DurableWorkflow\Cli\Commands\SearchAttributeCommand; use DurableWorkflow\Cli\Commands\ServerCommand; +use DurableWorkflow\Cli\Commands\ServiceCallCommand; use DurableWorkflow\Cli\Commands\StorageCommand; use DurableWorkflow\Cli\Commands\SystemCommand; use DurableWorkflow\Cli\Commands\TaskQueueCommand; @@ -148,6 +149,9 @@ public function __construct(?callable $serverClientFactory = null) new WorkflowCommand\HistoryExportCommand(), new WorkflowCommand\MigrateV1Command(), + // Durable Nexus inspection + new ServiceCallCommand\DescribeCommand(), + // Environment profiles (local config) new EnvCommand\ListCommand(), new EnvCommand\SetCommand(), diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php index bc2d62b..4f4762b 100644 --- a/src/Commands/BaseCommand.php +++ b/src/Commands/BaseCommand.php @@ -52,6 +52,7 @@ abstract class BaseCommand extends Command 'search-attribute:create', 'search-attribute:delete', 'search-attribute:list', + 'service-call:describe', 'storage:test', 'system:activity-timeout-pass', 'system:activity-timeout-status', diff --git a/src/Commands/ServiceCallCommand/DescribeCommand.php b/src/Commands/ServiceCallCommand/DescribeCommand.php new file mode 100644 index 0000000..b63e6d8 --- /dev/null +++ b/src/Commands/ServiceCallCommand/DescribeCommand.php @@ -0,0 +1,78 @@ +setName('service-call:describe') + ->setDescription('Inspect a durable Nexus service call and its original target') + ->setHelp(<<<'HELP' +Read one service call in the selected endpoint namespace. The JSON response +preserves the server's outcome, attempt records, original caller and target, +deadlines, typed failure and retained payload references. The result remains its +lossless Avro result_envelope. Only a GET is issued: no retry, cancellation or +external-object fetch is requested. + + dw service-call:describe billing invoices create call-42 --namespace=shared + dw service-call:describe billing invoices create call-42 --json +HELP) + ->addArgument('endpoint', InputArgument::REQUIRED, 'Endpoint name') + ->addArgument('service', InputArgument::REQUIRED, 'Service name') + ->addArgument('operation', InputArgument::REQUIRED, 'Operation name') + ->addArgument('call-id', InputArgument::REQUIRED, 'Original service call ID') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output the complete server response as JSON'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $segments = array_map( + static fn (string $name): string => rawurlencode((string) $input->getArgument($name)), + ['endpoint', 'service', 'operation', 'call-id'], + ); + $result = $this->addNamespaceContext($input, $this->client($input)->get(sprintf( + '/service-endpoints/%s/services/%s/operations/%s/service-calls/%s', + ...$segments, + ))); + + if ($this->wantsJson($input)) { + return $this->renderJson($output, $result); + } + + $output->writeln('Nexus Service Call'); + foreach ([ + 'Namespace' => 'namespace', 'Call ID' => 'id', 'Status' => 'status', + 'Outcome' => 'outcome', 'Deadline' => 'call_deadline_at', + 'Caller Namespace' => 'caller_namespace', 'Caller Workflow' => 'caller_workflow_instance_id', + 'Caller Run' => 'caller_workflow_run_id', 'Target Namespace' => 'target_namespace', + 'Target Workflow' => 'linked_workflow_instance_id', 'Target Run' => 'linked_workflow_run_id', + 'Target Update' => 'linked_workflow_update_id', 'Retry Attempts' => 'retry_attempt_count', + 'Failure Type' => 'failure_type', 'Failure Message' => 'message', + 'Caller Failure Type' => 'caller_observed_error_type', + 'Result Envelope' => 'result_envelope', 'Output Reference' => 'output_payload_reference', + 'Failure Reference' => 'failure_payload_reference', 'Outcome Metadata' => 'outcome_metadata', + 'Attempts' => 'service_call_attempts', + ] as $label => $field) { + $value = $result[$field] ?? null; + $text = '-'; + if (array_key_exists($field, $result)) { + $text = is_string($value) ? $value : json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } + $output->writeln(' '.$label.': '.OutputFormatter::escape($text)); + } + + return Command::SUCCESS; + } +} diff --git a/tests/Commands/OutputContractTest.php b/tests/Commands/OutputContractTest.php index 36e4e65..82810a3 100644 --- a/tests/Commands/OutputContractTest.php +++ b/tests/Commands/OutputContractTest.php @@ -385,6 +385,7 @@ public function test_json_mode_keeps_stderr_silent_on_error(): void */ public static function namespaceScopedCommandNames(): iterable { + yield 'service call describe' => ['service-call:describe']; yield 'activity complete' => ['activity:complete']; yield 'activity describe' => ['activity:describe']; yield 'activity fail' => ['activity:fail']; diff --git a/tests/Commands/ServiceCallDescribeCommandTest.php b/tests/Commands/ServiceCallDescribeCommandTest.php new file mode 100644 index 0000000..a8f3339 --- /dev/null +++ b/tests/Commands/ServiceCallDescribeCommandTest.php @@ -0,0 +1,106 @@ + 'call-17', 'namespace' => '017', 'status' => $status, + 'caller_namespace' => '0', 'caller_workflow_instance_id' => 'caller', 'caller_workflow_run_id' => 'original-caller', + 'target_namespace' => '017', 'linked_workflow_instance_id' => 'target', 'linked_workflow_run_id' => 'original-target', + 'call_deadline_at' => '2026-10-18T03:00:00.123456Z', + 'outcome_metadata' => ['long' => PHP_INT_MAX, 'null' => null, 'unicode' => 'λ雪'], + 'result_envelope' => ['codec' => 'avro', 'blob' => 'AA=='], + 'caller_observed_error_type' => 'OriginalServiceFailure', 'typed_error_message' => 'exact failure', + 'service_call_attempts' => [['attempt' => 1, 'status' => 'failed'], ['attempt' => 2, 'status' => 'completed']], + 'retry_attempt_count' => 2, 'output_payload_reference' => ['provider' => 'unavailable', 'key' => 'retained'], + ]; + $client = new ServiceCallDescribeClient($response); + $command = (new Application())->find('service-call:describe'); + self::assertInstanceOf(DescribeCommand::class, $command); + $command->setServerClient($client); + $tester = new CommandTester($command); + + self::assertSame(0, $tester->execute([ + 'endpoint' => 'billing/017', 'service' => 'invoices λ', 'operation' => '0', 'call-id' => 'call-17', + '--namespace' => '017', '--json' => true, + ])); + self::assertSame([['/service-endpoints/billing%2F017/services/invoices%20%CE%BB/operations/0/service-calls/call-17', []]], $client->reads); + self::assertSame($response, json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR)); + self::assertTrue((new Validator())->validate( + json_decode($tester->getDisplay(), flags: JSON_THROW_ON_ERROR), + json_decode(json_encode(OutputSchemaRegistry::schema('service-call:describe'), JSON_THROW_ON_ERROR), flags: JSON_THROW_ON_ERROR), + )->isValid()); + } + + public function test_human_output_preserves_failure_identity_zero_and_console_markup(): void + { + $command = new DescribeCommand(); + $command->setServerClient(new ServiceCallDescribeClient([ + 'namespace' => '0', 'id' => 'original', 'status' => 'failed', 'linked_workflow_run_id' => 'target-run', + 'failure_type' => 'OriginalFailure', 'message' => 'λ雪', + 'retry_attempt_count' => 0, 'service_call_attempts' => [], + 'result_envelope' => ['codec' => 'avro', 'blob' => 'AA=='], + 'outcome_metadata' => ['long' => PHP_INT_MAX, 'null' => null], + ])); + $tester = new CommandTester($command); + self::assertSame(0, $tester->execute(['endpoint' => 'e', 'service' => 's', 'operation' => 'o', 'call-id' => 'original'])); + foreach (['Namespace: 0', 'Target Run: target-run', 'Retry Attempts: 0', 'OriginalFailure', 'λ雪', 'Result Envelope: {"codec":"avro","blob":"AA=="}', (string) PHP_INT_MAX, '"null":null'] as $expected) { + self::assertStringContainsString($expected, $tester->getDisplay()); + } + } + + public function test_a_server_selector_refusal_is_never_rendered_as_a_success(): void + { + $client = new ServiceCallDescribeClient([]); + $client->failure = new ServerHttpException('service_call_not_found', 404); + $command = new DescribeCommand(); + $command->setServerClient($client); + $tester = new CommandTester($command); + self::assertSame(5, $tester->execute(['endpoint' => 'e', 'service' => 's', 'operation' => 'o', 'call-id' => 'wrong', '--namespace' => '017', '--json' => true])); + self::assertStringContainsString('service_call_not_found', $tester->getDisplay()); + self::assertSame('017', json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR)['namespace']); + self::assertCount(1, $client->reads); + } +} + +final class ServiceCallDescribeClient extends ServerClient +{ + public array $reads = []; + public ?ServerHttpException $failure = null; + + public function __construct(private readonly array $response) + { + parent::__construct('http://localhost'); + } + + public function get(string $path, array $query = []): array + { + $this->reads[] = [$path, $query]; + if ($this->failure !== null) { + throw $this->failure; + } + + return $this->response; + } +}