Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ dw watch workflow order-123 --run-id=01HXYZ --interval=5 --max-polls=60

# Send a signal
dw workflow:signal order-123 payment-received --input='{"amount":99.99}'
dw workflow:signal order-123 payment-received --input='{"amount":99.99}' --request-id=payment-order-123-2026-10-20
dw workflow:signal counter-1 increment --input='["not-an-int"]' --output=json
# {"error":"Server error: Signal argument validation failed.","exit_code":2,"status_code":422,"reason":"invalid_signal_arguments",...}

Expand All @@ -496,6 +497,7 @@ dw workflow:query counter-1 current-at --input='["not-an-int"]' --output=json

# Send an update
dw workflow:update order-123 approve --input='{"approver":"admin"}'
dw workflow:update order-123 approve --input='{"approver":"admin"}' --wait=completed --request-id=approve-order-123-2026-10-20

# Send an integration event through a bounded bridge adapter
dw bridge:webhook stripe --action=start_workflow --idempotency-key=stripe-event-1001 --target='{"workflow_type":"orders.fulfillment","task_queue":"external-workflows","business_key":"order-1001"}' --input='{"order_id":"order-1001"}'
Expand All @@ -513,6 +515,22 @@ dw workflow:history order-123 01HXYZ
dw workflow:history order-123 01HXYZ --follow
```

`workflow:signal` and `workflow:update` send a durable command key as the
`request_id` body field. The CLI generates `cli-signal-<hex>` or
`cli-update-<hex>` once per invocation unless `--request-id` (1-255 bytes,
opaque, case-sensitive) is given, and prints the key it used as `Request ID`
in human output and as `request_id` in `--json` output, including in error
envelopes after a timeout or network failure. Retrying the same command with
that key returns the original receipt instead of recording a second signal or
update; the response then carries `retained: true`, and `intent_verified`
reports whether the server could compare the retry against the retained
intent. A retry that reuses the key with a different name, arguments or run
target fails with HTTP 409 and `reason: request_id_conflict`; the error names
the key, the retained command (`retained_command_id`, `retained_command_type`,
`retained_outcome`) and the mismatched `conflict` parts. Servers that predate
keyed signals accept the field and omit the `retained`/`intent_verified`
flags.

### Nexus service calls

Inspect one durable call in its endpoint namespace:
Expand Down
4 changes: 2 additions & 2 deletions schemas/output/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@
"schema": "schemas/output/workflow-signal.schema.json",
"schema_id": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/workflow-signal.schema.json",
"resolver_url": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/workflow-signal.schema.json",
"sha256": "sha256:c42c02f7a54faa082982028482bc231ca29c495a2a00fabce5b0ae580a2505bf"
"sha256": "sha256:5a5fe23d6a1bead8508887b57b1dfb58448b92ed4292ce0da88bcdd1abf37e81"
},
"workflow:start": {
"output": "--json",
Expand All @@ -571,7 +571,7 @@
"schema": "schemas/output/workflow-update.schema.json",
"schema_id": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/workflow-update.schema.json",
"resolver_url": "https://durable-workflow.github.io/cli-json-envelopes/v3/schemas/workflow-update.schema.json",
"sha256": "sha256:a3997a4b623d84b166ea3a124b0a33e5c400acc51694332186813b96aa389eb1"
"sha256": "sha256:8888b18ff8d8ef7cac35f2aae362192b04617dc28760c25ddccf085880c3e25c"
}
},
"jsonl_commands": {
Expand Down
5 changes: 4 additions & 1 deletion schemas/output/workflow-signal.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"signal_name": { "type": "string" },
"outcome": { "type": "string" },
"command_status": { "type": ["string", "null"] },
"command_id": { "type": ["string", "null"] }
"command_id": { "type": ["string", "null"] },
"request_id": { "type": "string" },
"retained": { "type": ["boolean", "null"] },
"intent_verified": { "type": ["boolean", "null"] }
},
"additionalProperties": true
}
4 changes: 4 additions & 0 deletions schemas/output/workflow-update.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"update_name": { "type": "string" },
"update_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"retained": { "type": ["boolean", "null"] },
"intent_verified": { "type": ["boolean", "null"] },
"outcome": { "type": "string" },
"state": { "type": ["string", "null"] },
"update_state": { "type": ["string", "null"] },
Expand Down Expand Up @@ -119,6 +121,8 @@
"update_name": { "type": ["string", "null"] },
"update_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"retained": { "type": ["boolean", "null"] },
"intent_verified": { "type": ["boolean", "null"] },
"outcome": { "type": ["string", "null"] },
"reason": { "type": ["string", "null"] },
"rejection_reason": { "type": ["string", "null"] },
Expand Down
219 changes: 218 additions & 1 deletion src/Commands/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
{
private const INPUT_ENCODINGS = ['json', 'raw', 'base64'];

private const COMMAND_REQUEST_ID_MAX_CHARACTERS = 255;

private const REQUEST_ID_CONFLICT_REASON = 'request_id_conflict';

private const NAMESPACE_SCOPED_COMMAND_NAMES = [
'activity:complete',
'activity:describe',
Expand Down Expand Up @@ -122,6 +126,12 @@

private ?ProfileStore $profileStore = null;

/**
* The durable command key sent with the current signal or update request,
* remembered so error output can name it when the response is lost.
*/
private ?string $commandRequestId = null;

protected function configure(): void
{
$this->addOption('server', 's', InputOption::VALUE_OPTIONAL, 'Server URL', null);
Expand Down Expand Up @@ -663,6 +673,64 @@
return $payload;
}

/**
* Resolve the durable command key for a signal or update request: the
* explicit `--request-id` value when one was given, otherwise one key
* generated for this invocation. The key is remembered so a lost or
* conflicting response can still name it for a retry.
*
* @param string $commandKind `signal` or `update`
*/
protected function commandRequestId(InputInterface $input, string $commandKind): string
{
$this->commandRequestId = null;

$explicit = $input->hasOption('request-id') ? $input->getOption('request-id') : null;

if (is_scalar($explicit) && trim((string) $explicit) !== '') {
$explicit = (string) $explicit;

$characters = preg_match_all('/./us', $explicit);
if ($characters === false) {
throw new InvalidOptionException('--request-id must be valid UTF-8.');
}
if ($characters > self::COMMAND_REQUEST_ID_MAX_CHARACTERS) {
throw new InvalidOptionException(sprintf(
'--request-id must be 1 to %d Unicode characters; got %d characters.',
self::COMMAND_REQUEST_ID_MAX_CHARACTERS,
$characters,
));
}

return $this->commandRequestId = $explicit;
}

return $this->commandRequestId = sprintf('cli-%s-%s', $commandKind, bin2hex(random_bytes(16)));
}

/**
* Print the command key and, when the server reported them, the retained
* receipt flags so an operator can retry with the same key.
*
* @param array<string, mixed> $result
*/
protected function writeCommandReceiptLines(OutputInterface $output, array $result): void
{
if ($this->hasNonEmptyString($result['request_id'] ?? null)) {
$output->writeln(' Request ID: '.$result['request_id']);
}

if (is_bool($result['retained'] ?? null)) {
$output->writeln(' Retained: '.($result['retained']
? 'yes (original receipt returned for this request id)'
: 'no'));
}

if (is_bool($result['intent_verified'] ?? null)) {
$output->writeln(' Intent Verified: '.($result['intent_verified'] ? 'yes' : 'no'));
}
}

/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $request
Expand Down Expand Up @@ -871,6 +939,8 @@
'update_name',
'update_id',
'request_id',
'retained',
'intent_verified',
'outcome',
'reason',
'rejection_reason',
Expand Down Expand Up @@ -926,6 +996,8 @@
'update_name',
'update_id',
'request_id',
'retained',
'intent_verified',
'state',
'update_state',
'outcome',
Expand Down Expand Up @@ -1143,6 +1215,7 @@

$this->writeHumanError($output, $e->getMessage());
$this->writeHumanErrorNamespaceContext($output, $input);
$this->writeHumanErrorRequestIdContext($output, $input);
$this->writeHumanHttpErrorDetails($output, $e);

$recommendations = $this->errorRecommendations($e, $input);
Expand Down Expand Up @@ -1247,6 +1320,12 @@
'rejection_reason',
'rejection_category',
'request_id',
'retained',
'intent_verified',
'retained_command_id',
'retained_command_type',
'retained_outcome',
'conflict',
'workflow_sequence',
'result',
'result_envelope',
Expand Down Expand Up @@ -1309,7 +1388,18 @@
$target->writeln('Namespace: '.$namespace);
}

private function writeHumanErrorRequestIdContext(OutputInterface $output, InputInterface $input): void
{
$requestId = $this->errorRequestIdContext($input);
if ($requestId === null) {
return;
}

$target = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
$target->writeln('Request ID: '.$requestId);
}

private function writeHumanHttpErrorDetails(OutputInterface $output, \Throwable $e): void

Check failure on line 1402 in src/Commands/BaseCommand.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_cli&issues=AaFaWA3l0gYkmqlkQKMI&open=AaFaWA3l0gYkmqlkQKMI&pullRequest=61
{
if (! $e instanceof ServerHttpException) {
return;
Expand All @@ -1328,6 +1418,12 @@
$target->writeln(str_replace('_', ' ', ucfirst($key)).': '.$value);
}
}

if ($e->reason() === self::REQUEST_ID_CONFLICT_REASON) {
foreach ($this->requestIdConflictLines($e->body) as $line) {
$target->writeln($line);
}
}
}

$validationErrors = $e->validationErrors();
Expand Down Expand Up @@ -1380,8 +1476,12 @@
}
}

private function errorRequestIdContext(InputInterface $input): ?string

Check warning on line 1479 in src/Commands/BaseCommand.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 4 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_cli&issues=AaFaWA3l0gYkmqlkQKMG&open=AaFaWA3l0gYkmqlkQKMG&pullRequest=61
{
if ($this->commandRequestId !== null) {
return $this->commandRequestId;
}

if (! $input->hasOption('request-id')) {
return null;
}
Expand Down Expand Up @@ -1420,7 +1520,7 @@

$request = array_filter([
'wait_for' => $this->stringOptionContext($input, 'wait') ?? (is_scalar($envelope['wait_for'] ?? null) ? (string) $envelope['wait_for'] : 'accepted'),
'request_id' => $this->stringOptionContext($input, 'request-id') ?? (is_scalar($envelope['request_id'] ?? null) ? (string) $envelope['request_id'] : null),
'request_id' => $this->errorRequestIdContext($input) ?? (is_scalar($envelope['request_id'] ?? null) ? (string) $envelope['request_id'] : null),
'input' => $this->workflowUpdateInputContext($input),
], static fn (mixed $value): bool => $value !== null);

Expand Down Expand Up @@ -1574,6 +1674,104 @@
* @return list<array{id: string, severity: string, message: string, command?: string}>
*/
private function errorRecommendations(\Throwable $e, ?InputInterface $input): array
{
$recommendations = $this->baseErrorRecommendations($e, $input);

$retry = $this->requestIdRetryRecommendation($e);
if ($retry !== null) {
$recommendations[] = $retry;
}

return $recommendations;
}

/**
* When the outcome of a keyed signal or update is unknown (transport
* failure or a server-side error), tell the operator which key to reuse so
* the retry reads the original receipt instead of recording a new command.
*
* @return array{id: string, severity: string, message: string}|null
*/
private function requestIdRetryRecommendation(\Throwable $e): ?array
{
if ($this->commandRequestId === null) {
return null;
}

$uncertain = $e instanceof NetworkException
|| $e instanceof TimeoutException
|| ($e instanceof ServerHttpException && $e->statusCode >= 500 && $e->statusCode <= 599);

if (! $uncertain) {
return null;
}

return [
'id' => 'request_id.retry',
'severity' => 'error',
'message' => sprintf(
'The outcome of this command is unknown. Retry it unchanged with --request-id=%s so the server returns the original receipt instead of recording a second command.',
$this->commandRequestId,
),
];
}

/**
* @param array<string, mixed> $body
* @return list<string>
*/
private function requestIdConflictLines(array $body): array
{
$lines = [];

$retained = array_filter([
'id' => $this->nonEmptyScalarString($body['retained_command_id'] ?? null),
'type' => $this->nonEmptyScalarString($body['retained_command_type'] ?? null),
'outcome' => $this->nonEmptyScalarString($body['retained_outcome'] ?? null),
], static fn (?string $value): bool => $value !== null);

if ($retained !== []) {
$lines[] = 'Retained command: '.implode(', ', array_map(
static fn (string $field, string $value): string => $field.' '.$value,
array_keys($retained),
$retained,
));
}

$conflict = $this->requestIdConflictParts($body);
if ($conflict !== []) {
$lines[] = 'Conflicting parts: '.implode(', ', $conflict);
}

return $lines;
}

/**
* @param array<string, mixed> $body
* @return list<string>
*/
private function requestIdConflictParts(array $body): array
{
$conflict = $body['conflict'] ?? null;
if (! is_array($conflict)) {
return [];
}

$parts = [];
foreach ($conflict as $part) {
$value = $this->nonEmptyScalarString($part);
if ($value !== null) {
$parts[] = $value;
}
}

return $parts;
}

/**
* @return list<array{id: string, severity: string, message: string, command?: string}>
*/
private function baseErrorRecommendations(\Throwable $e, ?InputInterface $input): array

Check warning on line 1774 in src/Commands/BaseCommand.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 6 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_cli&issues=AaFaWA3l0gYkmqlkQKMH&open=AaFaWA3l0gYkmqlkQKMH&pullRequest=61
{
if ($e instanceof NetworkException) {
return [[
Expand Down Expand Up @@ -1641,6 +1839,25 @@
]];
}

if ($e->statusCode === 409 && $e->reason() === self::REQUEST_ID_CONFLICT_REASON) {
$body = $e->body ?? [];
$requestId = $this->nonEmptyScalarString($body['request_id'] ?? null)
?? ($input !== null ? $this->errorRequestIdContext($input) : null)
?? 'given';
$parts = $this->requestIdConflictParts($body);

return [[
'id' => 'request_id.conflict',
'severity' => 'error',
'message' => sprintf(
'Request id [%s] already identifies a %s command with a different %s. Repeat that original command unchanged to read its receipt, or pass a new --request-id to record a new command.',
$requestId,
$this->nonEmptyScalarString($body['retained_command_type'] ?? null) ?? 'retained',
$parts === [] ? 'intent' : implode('/', $parts),
),
]];
}

if ($e->statusCode >= 400 && $e->statusCode <= 499) {
return [[
'id' => 'request.invalid',
Expand Down
Loading
Loading