diff --git a/app/Support/ExternalWorkflowUpdateAdmission.php b/app/Support/ExternalWorkflowUpdateAdmission.php index b6220cfae..02f176962 100644 --- a/app/Support/ExternalWorkflowUpdateAdmission.php +++ b/app/Support/ExternalWorkflowUpdateAdmission.php @@ -22,6 +22,7 @@ use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; use Workflow\V2\Models\WorkflowUpdate; +use Workflow\V2\Support\BridgeCommandIdentity; use Workflow\V2\Support\RunCommandContract; use Workflow\V2\Support\StructuralLimits; use Workflow\V2\Support\UpdateCommandGate; @@ -292,7 +293,11 @@ private function shouldHandle(WorkflowRun $run, string $updateName): bool */ private function duplicateForRequest(WorkflowInstance $instance, CommandContext $context): ?array { - $command = UpdateRequestIdentity::find($instance, $context->attributes()['context']); + $attributes = $context->attributes()['context']; + $bridge = BridgeCommandIdentity::fromContext($attributes); + $command = $bridge instanceof BridgeCommandIdentity + ? $bridge->find($instance, CommandType::Update) + : UpdateRequestIdentity::find($instance, $attributes); $update = $command instanceof WorkflowCommand ? WorkflowUpdate::query()->where('workflow_command_id', $command->id)->first() : null; diff --git a/tests/Feature/ExternalWorkflowUpdateRequestIdTest.php b/tests/Feature/ExternalWorkflowUpdateRequestIdTest.php index d6d1a4391..c04797c4a 100644 --- a/tests/Feature/ExternalWorkflowUpdateRequestIdTest.php +++ b/tests/Feature/ExternalWorkflowUpdateRequestIdTest.php @@ -26,6 +26,50 @@ public static function namespaces(): iterable yield 'named' => ['retained-namespace']; } + public static function bridgeReceipts(): iterable + { + foreach (['default', 'retained-namespace'] as $namespace) { + foreach (['current', 'legacy', 'conflicting'] as $receipt) { + yield $namespace.' '.$receipt => [$namespace, $receipt]; + } + } + } + + #[DataProvider('bridgeReceipts')] + public function test_remote_bridge_redelivery_preserves_its_scoped_receipt(string $namespace, string $receipt): void + { + $this->startRemote('bridge-retained', $namespace); + $path = '/api/bridge-adapters/webhook/acme.crm:v1'; + $event = [ + 'action' => 'update_workflow', 'idempotency_key' => 'u-2', + 'target' => ['workflow_id' => 'bridge-retained', 'update_name' => 'add'], 'input' => [17], + ]; + $first = $this->postJson($path, $event, [...$this->apiHeaders($namespace), 'X-Request-Id' => 'proxy-first']) + ->assertAccepted()->assertJsonPath('outcome', 'accepted'); + $command = WorkflowCommand::query()->where('command_type', 'update')->sole(); + $context = $command->context; + if ($receipt === 'legacy') { + $context['request']['request_id'] = 'legacy-proxy'; + $context['server']['metadata']['request_id'] = 'u-2'; + $command->update(['request_id' => null, 'context' => $context]); + } elseif ($receipt === 'conflicting') { + $context['server']['metadata']['idempotency_key'] = 'different-provider-event'; + $command->update(['context' => $context]); + } + $before = $this->durableRows(); + $redelivery = $this->postJson($path, [...$event, 'input' => [99]], [ + ...$this->apiHeaders($namespace), 'X-Request-Id' => 'proxy-redelivery', + ]); + if ($receipt === 'conflicting') { + $redelivery->assertStatus(409)->assertJsonPath('reason', 'bridge_identity_conflict'); + } else { + $redelivery->assertOk()->assertJsonPath('accepted', false)->assertJsonPath('outcome', 'duplicate') + ->assertJsonPath('control_plane_outcome', 'deduped_existing_command') + ->assertJsonPath('run_id', $first->json('run_id')); + } + $this->assertSame($before, $this->durableRows()); + } + #[DataProvider('namespaces')] public function test_request_id_reused_for_another_declared_name_returns_original_without_mutation(string $namespace): void {