diff --git a/app/Http/Controllers/Api/WorkerController.php b/app/Http/Controllers/Api/WorkerController.php index 5b9337376..df7bfff68 100644 --- a/app/Http/Controllers/Api/WorkerController.php +++ b/app/Http/Controllers/Api/WorkerController.php @@ -1164,16 +1164,14 @@ private function workerProcessIdentity(mixed $processMetrics): array private function releaseLeasedWorkflowTasksForReplacedWorker(string $namespace, string $workerId): void { - WorkflowRun::query() - ->where('namespace', $namespace) + NamespaceWorkflowScope::ownedRunQuery(WorkflowRun::query(), $namespace) ->where('sticky_worker_id', $workerId) ->update([ 'sticky_worker_id' => null, 'sticky_until' => null, ]); - WorkflowTask::query() - ->where('namespace', $namespace) + NamespaceWorkflowScope::taskQuery($namespace) ->where('sticky_worker_id', $workerId) ->update([ 'sticky_worker_id' => null, @@ -1182,8 +1180,7 @@ private function releaseLeasedWorkflowTasksForReplacedWorker(string $namespace, 'sticky_claimed_at' => now(), ]); - WorkflowTask::query() - ->where('namespace', $namespace) + NamespaceWorkflowScope::taskQuery($namespace) ->where('task_type', TaskType::Workflow->value) ->where('status', TaskStatus::Leased->value) ->where('lease_owner', $workerId) @@ -1201,8 +1198,7 @@ private function releaseLeasedWorkflowTasksForReplacedWorker(string $namespace, private function releaseLeasedActivityTasksForReplacedWorker(string $namespace, string $workerId): void { - WorkflowTask::query() - ->where('namespace', $namespace) + NamespaceWorkflowScope::taskQuery($namespace) ->where('task_type', TaskType::Activity->value) ->where('status', TaskStatus::Leased->value) ->where('lease_owner', $workerId) @@ -1232,7 +1228,9 @@ private function expireLeasedActivityAttemptForReplacedWorker(WorkflowTask $task } /** @var ActivityExecution|null $execution */ - $execution = ActivityExecution::query()->find($executionId); + $execution = ActivityExecution::query() + ->where('workflow_run_id', $task->workflow_run_id) + ->find($executionId); if (! $execution instanceof ActivityExecution) { return; @@ -1242,6 +1240,7 @@ private function expireLeasedActivityAttemptForReplacedWorker(WorkflowTask $task $attempt = ActivityAttempt::query() ->where('workflow_task_id', $task->id) ->where('activity_execution_id', $execution->id) + ->where('workflow_run_id', $task->workflow_run_id) ->where('lease_owner', $workerId) ->where('status', ActivityAttemptStatus::Running->value) ->latest('attempt_number') diff --git a/app/Support/ActivityTaskPoller.php b/app/Support/ActivityTaskPoller.php index 907888631..f9db7ed71 100644 --- a/app/Support/ActivityTaskPoller.php +++ b/app/Support/ActivityTaskPoller.php @@ -12,6 +12,7 @@ use Workflow\V2\Models\ActivityExecution; use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Support\DefaultActivityTaskBridge; use Workflow\V2\Support\TaskFairnessKey; use Workflow\V2\Support\TaskFairnessScheduler; use Workflow\V2\Support\TaskFairnessState; @@ -897,6 +898,14 @@ private function claimReadyTask( throw new ActivityTaskClaimRolledBack; } + // Revalidate legacy task ownership before the ID-only + // package claim can write a lease or activity attempt. + // Custom bridges may keep their tasks outside this table. + if (($this->bridge instanceof DefaultActivityTaskBridge || WorkflowTask::query()->whereKey($taskId)->exists()) + && NamespaceWorkflowScope::lockTaskForMutation($namespace, $taskId) === null) { + return null; + } + $this->pollLeaseBindings->ensureUnbound( $namespace, TaskType::Activity, diff --git a/app/Support/NamespaceWorkflowScope.php b/app/Support/NamespaceWorkflowScope.php index 3716ab6ac..6d1cb3b3e 100644 --- a/app/Support/NamespaceWorkflowScope.php +++ b/app/Support/NamespaceWorkflowScope.php @@ -7,6 +7,7 @@ use Workflow\V2\Models\WorkflowRun; use Workflow\V2\Models\WorkflowRunSummary; use Workflow\V2\Models\WorkflowTask; +use Workflow\V2\Support\NamespaceIdentity; class NamespaceWorkflowScope { @@ -29,9 +30,8 @@ public static function bind(string $namespace, string $workflowId, ?string $work public static function workflowBound(string $namespace, string $workflowId): bool { - return WorkflowInstance::query() + return NamespaceIdentity::constrain(WorkflowInstance::query(), $namespace) ->whereKey($workflowId) - ->where('namespace', $namespace) ->exists(); } @@ -48,8 +48,7 @@ public static function namespaceForWorkflow(string $workflowId): ?string public static function runQuery(string $namespace, string $workflowId): Builder { - return WorkflowRun::query() - ->where('workflow_runs.namespace', $namespace) + return self::ownedRunQuery(WorkflowRun::query(), $namespace) ->where('workflow_runs.workflow_instance_id', $workflowId); } @@ -69,20 +68,28 @@ public static function run(string $namespace, string $workflowId, string $runId) public static function runSummaryQuery(string $namespace): Builder { - return WorkflowRunSummary::query() - ->where('workflow_run_summaries.namespace', $namespace); + // Cached metadata is not authority for a legacy import's owner. + // Keep filtering and pagination inside the canonical run boundary. + return NamespaceIdentity::constrain(WorkflowRunSummary::query(), $namespace) + ->whereHas('run', static fn (Builder $run) => self::ownedRunQuery($run, $namespace) + ->whereColumn('workflow_runs.workflow_instance_id', 'workflow_run_summaries.workflow_instance_id')); + } + + public static function ownedRunQuery(Builder $query, string $namespace): Builder + { + return NamespaceIdentity::constrain($query, $namespace) + ->whereHas('instance', static fn (Builder $instance) => NamespaceIdentity::constrain($instance, $namespace)); } public static function taskQuery(string $namespace): Builder { - return WorkflowTask::query() - ->where('workflow_tasks.namespace', $namespace); + return NamespaceIdentity::constrain(WorkflowTask::query(), $namespace) + ->whereHas('run', static fn (Builder $run) => self::ownedRunQuery($run, $namespace)); } public static function task(string $namespace, string $taskId): ?WorkflowTask { - return WorkflowTask::query() - ->where('namespace', $namespace) + return self::taskQuery($namespace) ->whereKey($taskId) ->first(); } @@ -98,7 +105,7 @@ public static function lockTaskForMutation(string $namespace, string $taskId): ? return null; } - $run = WorkflowRun::query()->where('namespace', $namespace) + $run = self::ownedRunQuery(WorkflowRun::query(), $namespace) ->whereKey($runId)->lockForUpdate()->first(); if (! $run instanceof WorkflowRun) { return null; diff --git a/app/Support/WorkflowTaskPoller.php b/app/Support/WorkflowTaskPoller.php index b0c1d4ab8..803595f73 100644 --- a/app/Support/WorkflowTaskPoller.php +++ b/app/Support/WorkflowTaskPoller.php @@ -1073,7 +1073,7 @@ private function claimReadyTask( // Request admission takes the same run lock before inspecting // leases. Either it observes this claim's immutable proof, or // this claim observes the newly accepted request. - $run = WorkflowRun::query()->where('namespace', $namespace) + $run = NamespaceWorkflowScope::ownedRunQuery(WorkflowRun::query(), $namespace) ->lockForUpdate()->find($runId); if (! $run instanceof WorkflowRun) { @@ -1627,9 +1627,8 @@ private function backfillReadyTaskCompatibility(string $namespace, array &$ready return; } - $updated = WorkflowTask::query() + $updated = NamespaceWorkflowScope::taskQuery($namespace) ->whereKey($taskId) - ->where('namespace', $namespace) ->where('task_type', TaskType::Workflow->value) ->where(function ($query): void { $query->whereNull('compatibility') @@ -2339,7 +2338,7 @@ private function workerCanExecuteTask( ->where('worker_id', $workerId) ->first(); - $run = WorkflowRun::query()->where('namespace', $namespace)->find($runId); + $run = NamespaceWorkflowScope::ownedRunQuery(WorkflowRun::query(), $namespace)->find($runId); if (! $worker instanceof WorkerRegistration || ! $run instanceof WorkflowRun || ! WorkflowReplayEligibility::allows($worker, $run, $protocolVersion)) { return false; diff --git a/composer.json b/composer.json index 7ca0aea19..6c4a3033a 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.2", "apache/avro": "^1.12", - "durable-workflow/workflow": "2.15.10", + "durable-workflow/workflow": "2.15.12", "laravel/framework": "^13.30", "laravel/tinker": "^3.0", "league/flysystem-aws-s3-v3": "^3.35.3" diff --git a/composer.lock b/composer.lock index 056bd823f..4a98a06ab 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2bf73b63536e15782166fb6cdcbc5519", + "content-hash": "26f05963a35e77fc93c3cc53a277a070", "packages": [ { "name": "apache/avro", @@ -655,16 +655,16 @@ }, { "name": "durable-workflow/workflow", - "version": "2.15.10", + "version": "2.15.12", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "e2b199673d087fd278628db066e215217eec61cb" + "reference": "7c80ffccdb6ab6e6ace8339618a9d6f1d6d9432f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/e2b199673d087fd278628db066e215217eec61cb", - "reference": "e2b199673d087fd278628db066e215217eec61cb", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/7c80ffccdb6ab6e6ace8339618a9d6f1d6d9432f", + "reference": "7c80ffccdb6ab6e6ace8339618a9d6f1d6d9432f", "shasum": "" }, "require": { @@ -705,7 +705,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.15.10", + "product-train": "2.15.12", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -732,9 +732,9 @@ "description": "Embedded durable workflow runtime and orchestration engine for Laravel applications.", "support": { "issues": "https://github.com/durable-workflow/workflow/issues", - "source": "https://github.com/durable-workflow/workflow/tree/2.15.10" + "source": "https://github.com/durable-workflow/workflow/tree/2.15.12" }, - "time": "2026-10-31T09:12:29+00:00" + "time": "2026-11-01T07:57:51+00:00" }, { "name": "egulias/email-validator", diff --git a/tests/Feature/ActivityWorkerDeregistrationTest.php b/tests/Feature/ActivityWorkerDeregistrationTest.php index d1617f45d..d767419d9 100644 --- a/tests/Feature/ActivityWorkerDeregistrationTest.php +++ b/tests/Feature/ActivityWorkerDeregistrationTest.php @@ -190,7 +190,7 @@ public function test_contended_attempt_refuses_without_committing_the_registrati private function leaseActivity(int $maxAttempts): array { - $workflow = WorkflowStub::make(ExternalGreetingWorkflow::class, 'deregister-original-activity'); + $workflow = WorkflowStub::make(ExternalGreetingWorkflow::class, 'deregister-original-activity', 'default'); $start = $workflow->start('Ada'); $this->runReadyWorkflowTask($start->runId()); $execution = ActivityExecution::query()->where('workflow_run_id', $start->runId())->sole(); diff --git a/tests/Feature/LegacyActivityPollWindowTest.php b/tests/Feature/LegacyActivityPollWindowTest.php new file mode 100644 index 000000000..8ac673742 --- /dev/null +++ b/tests/Feature/LegacyActivityPollWindowTest.php @@ -0,0 +1,42 @@ +createNamespace('default'); + $this->configureWorkflowTypes(['tests.external-greeting-workflow' => ExternalGreetingWorkflow::class]); + $tasks = []; + foreach (['foreign', 'owned'] as $owner) { + $runId = $this->postJson('/api/workflows', [ + 'workflow_id' => $owner, 'workflow_type' => 'tests.external-greeting-workflow', + 'task_queue' => 'workflow', 'input' => ['Ada'], + ], $this->apiHeaders())->assertCreated()->json('run_id'); + $this->runReadyWorkflowTask($runId); + $tasks[$owner] = WorkflowTask::query()->where('workflow_run_id', $runId)->where('task_type', 'activity')->sole(); + } + DB::table('workflow_instances')->where('id', 'foreign')->update(['namespace' => 'other']); + $tasks['foreign']->forceFill(['priority' => 0])->save(); + for ($i = 1; $i < 10; $i++) { + $tasks['foreign']->replicate()->save(); + } + $this->registerWorker('window-worker', 'external-activities'); + $this->postJson('/api/worker/activity-tasks/poll', [ + 'worker_id' => 'window-worker', 'task_queue' => 'external-activities', + ], $this->workerHeaders())->assertOk()->assertJsonPath('task.task_id', $tasks['owned']->id); + } +} diff --git a/tests/Feature/LegacyImportedNamespaceVisibilityTest.php b/tests/Feature/LegacyImportedNamespaceVisibilityTest.php new file mode 100644 index 000000000..17c705cb7 --- /dev/null +++ b/tests/Feature/LegacyImportedNamespaceVisibilityTest.php @@ -0,0 +1,123 @@ +createNamespace('default'); + $this->seedImportedRun($instanceNamespace, $path === '/api/activities'); + $before = DB::table('workflow_instances')->where('id', 'legacy-instance')->first(); + $key = $path === '/api/activities' ? 'activities' : 'workflows'; + $expectedCount = $instanceNamespace === 'default' ? 1 : 0; + + $response = $this->getJson($path, $this->apiHeaders()) + ->assertOk()->assertJsonCount($expectedCount, $key); + + if ($expectedCount === 0) { + $this->assertStringNotContainsString('private-owner-key', $response->getContent()); + } else { + $response->assertJsonPath($key.'.0.business_key', 'private-owner-key'); + } + + if ($path === '/api/workflows') { + $this->getJson($path.'?query=private-owner-key', $this->apiHeaders()) + ->assertOk()->assertJsonCount($expectedCount, $key); + } + + $this->assertEquals($before, DB::table('workflow_instances')->where('id', 'legacy-instance')->first()); + } + + public static function listOwners(): iterable + { + foreach (['/api/workflows', '/api/activities'] as $path) { + foreach (['other', 'DEFAULT', null, 'default'] as $namespace) { + yield $path.' / '.($namespace ?? 'embedded null') => [$path, $namespace]; + } + } + } + + #[DataProvider('instanceOwners')] + public function test_run_resolution_requires_the_canonical_instance_namespace(?string $instanceNamespace): void + { + $this->seedImportedRun($instanceNamespace); + $expected = $instanceNamespace === 'default' ? 'legacy-run' : null; + + $this->assertSame($expected, NamespaceWorkflowScope::currentRun('default', 'legacy-instance')?->id); + $this->assertSame($expected, NamespaceWorkflowScope::run('default', 'legacy-instance', 'legacy-run')?->id); + } + + public static function instanceOwners(): iterable + { + foreach (['other', 'DEFAULT', null, 'default'] as $namespace) { + yield $namespace ?? 'embedded null' => [$namespace]; + } + } + + #[DataProvider('changedSummaryIdentity')] + public function test_lists_do_not_trust_a_summary_with_a_different_canonical_run(string $path, string $column): void + { + $this->createNamespace('default'); + $this->seedImportedRun('default', $path === '/api/activities'); + if ($column === 'namespace') { + DB::table('workflow_runs')->where('id', 'legacy-run')->update(['namespace' => 'other']); + } else { + DB::table('workflow_run_summaries')->where('id', 'legacy-run') + ->update(['workflow_instance_id' => 'different-instance']); + } + + $key = $path === '/api/activities' ? 'activities' : 'workflows'; + $this->getJson($path, $this->apiHeaders())->assertOk()->assertJsonCount(0, $key); + } + + public static function changedSummaryIdentity(): iterable + { + foreach (['/api/workflows', '/api/activities'] as $path) { + foreach (['namespace', 'workflow_instance_id'] as $column) { + yield $path.' / '.$column => [$path, $column]; + } + } + } + + private function seedImportedRun(?string $instanceNamespace, bool $activity = false): void + { + $type = $activity ? StandaloneActivityHostType::WORKFLOW_TYPE : 'tests.external-greeting-workflow'; + $instance = new WorkflowInstance([ + 'id' => 'legacy-instance', 'namespace' => $instanceNamespace, + 'workflow_class' => ExternalGreetingWorkflow::class, 'workflow_type' => $type, + 'business_key' => 'private-owner-key', 'run_count' => 1, 'current_run_id' => 'legacy-run', + ]); + $instance->saveQuietly(); + WorkflowRun::query()->create([ + 'id' => 'legacy-run', 'workflow_instance_id' => $instance->id, 'namespace' => 'default', + 'workflow_class' => ExternalGreetingWorkflow::class, 'workflow_type' => $type, + 'status' => 'running', 'run_number' => 1, 'connection' => 'database', + 'queue' => 'default', 'payload_codec' => 'avro', 'started_at' => now(), + ]); + WorkflowRunSummary::query()->create([ + 'id' => 'legacy-run', 'workflow_instance_id' => $instance->id, 'namespace' => 'default', + 'class' => ExternalGreetingWorkflow::class, 'workflow_type' => $type, + 'engine_source' => 'embedded_v2_import', 'projection_schema_version' => 2, + 'business_key' => 'private-owner-key', 'visibility_labels' => ['private-owner-label'], + 'status' => 'running', 'status_bucket' => 'running', 'run_number' => 1, + 'connection' => 'database', 'queue' => 'default', 'started_at' => now(), 'sort_timestamp' => now(), + ]); + } +} diff --git a/tests/Feature/LegacyImportedTaskOwnershipTest.php b/tests/Feature/LegacyImportedTaskOwnershipTest.php new file mode 100644 index 000000000..e766d53a5 --- /dev/null +++ b/tests/Feature/LegacyImportedTaskOwnershipTest.php @@ -0,0 +1,237 @@ +prepareTask('workflow'); + $this->setInstanceOwner($owner); + $before = $this->workflowRows(); + $expected = $owner === 'default' ? $task->id : null; + + $this->assertSame($expected, NamespaceWorkflowScope::taskQuery('default')->value('id')); + $this->assertSame($expected, NamespaceWorkflowScope::task('default', $task->id)?->id); + $this->assertSame($expected, DB::transaction( + fn () => NamespaceWorkflowScope::lockTaskForMutation('default', $task->id)?->id, + )); + $this->assertSame($before, $this->workflowRows()); + } + + #[DataProvider('taskOwners')] + public function test_polling_requires_the_canonical_instance_owner(string $kind, ?string $owner): void + { + $task = $this->prepareTask($kind); + $this->setInstanceOwner($owner); + $before = $this->workflowRows(); + + $response = $this->poll($kind)->assertOk(); + + if ($owner === 'default') { + $response->assertJsonPath('task.task_id', $task->id); + $this->assertSame('leased', $task->fresh()->status->value); + } else { + $response->assertJsonPath('task', null); + $this->assertSame($before, $this->workflowRows()); + } + } + + #[DataProvider('mutationOwners')] + public function test_worker_mutations_require_the_canonical_instance_owner(string $kind, ?string $owner, string $operation): void + { + $task = $this->prepareTask($kind); + $claim = $this->poll($kind)->assertOk()->json('task'); + $this->assertSame($task->id, $claim['task_id']); + $this->setInstanceOwner($owner); + $before = $this->workflowRows(); + $body = ['lease_owner' => $claim['lease_owner']]; + if ($kind === 'workflow') { + $body['workflow_task_attempt'] = $claim['workflow_task_attempt']; + if ($operation === 'complete') { + $body['commands'] = [['type' => 'complete_workflow', 'result' => Serializer::serializeWithCodec('avro', 'done')]]; + } + } else { + $body['activity_attempt_id'] = $claim['activity_attempt_id']; + if ($operation === 'complete') { + $body['result'] = 'done'; + } + } + if ($operation === 'fail') { + $body['failure'] = ['message' => 'worker failure']; + } + + $response = $this->postJson('/api/worker/'.$kind.'-tasks/'.$task->id.'/'.$operation, $body, $this->workerHeaders()); + + if ($owner === 'default') { + $response->assertOk(); + } else { + $response->assertNotFound()->assertJsonPath('reason', 'task_not_found'); + $this->assertSame($before, $this->workflowRows()); + } + } + + #[DataProvider('taskOwners')] + public function test_worker_replacement_only_releases_tasks_of_the_canonical_owner(string $kind, ?string $owner): void + { + $task = $this->prepareTask($kind); + $claim = $this->poll($kind)->assertOk()->json('task'); + $this->assertSame($task->id, $claim['task_id']); + DB::table('workflow_runs')->where('id', $task->workflow_run_id)->update([ + 'sticky_worker_id' => 'legacy-worker', 'sticky_until' => now()->addMinute(), + ]); + DB::table('workflow_tasks')->where('id', $task->id)->update([ + 'sticky_worker_id' => 'legacy-worker', 'sticky_until' => now()->addMinute(), + ]); + $this->setInstanceOwner($owner); + $before = $this->workflowRows(); + + $this->replaceWorker($kind); + + if ($owner !== 'default') { + $this->assertSame($before, $this->workflowRows()); + + return; + } + + $task->refresh(); + $this->assertSame('ready', $task->status->value); + $this->assertNull($task->lease_owner); + $this->assertNull($task->sticky_worker_id); + $this->assertSame(StickyExecution::MODE_FORCED_COLD_REPLAY, $task->sticky_replay_mode); + $this->assertNull($task->run->sticky_worker_id); + if ($kind === 'activity') { + $attempt = ActivityAttempt::query()->findOrFail($claim['activity_attempt_id']); + $this->assertSame('expired', $attempt->status->value); + $this->assertNull($attempt->lease_expires_at); + } + } + + #[DataProvider('copiedActivityReferences')] + public function test_worker_replacement_does_not_expire_an_attempt_from_another_run(string $table): void + { + $task = $this->prepareTask('activity'); + $claim = $this->poll('activity')->assertOk()->json('task'); + $otherRun = $task->run->replicate(); + $otherRun->id = 'other-run'; + $otherRun->run_number = 2; + $otherRun->save(); + DB::table($table)->update(['workflow_run_id' => $otherRun->id]); + $before = DB::table('activity_attempts')->where('id', $claim['activity_attempt_id'])->first(); + + $this->replaceWorker('activity'); + + $this->assertEquals($before, DB::table('activity_attempts')->where('id', $claim['activity_attempt_id'])->first()); + $this->assertSame('ready', $task->fresh()->status->value); + } + + public static function instanceOwners(): iterable + { + foreach (['other', 'DEFAULT', null, 'default'] as $owner) { + yield $owner ?? 'embedded null' => [$owner]; + } + } + + public static function taskOwners(): iterable + { + foreach (['workflow', 'activity'] as $kind) { + foreach (self::instanceOwners() as $label => [$owner]) { + yield $kind.' / '.$label => [$kind, $owner]; + } + } + } + + public static function mutationOwners(): iterable + { + foreach (self::taskOwners() as $label => [$kind, $owner]) { + foreach (['heartbeat', 'complete', 'fail'] as $operation) { + yield $label.' / '.$operation => [$kind, $owner, $operation]; + } + } + } + + public static function copiedActivityReferences(): iterable + { + yield 'execution run' => ['activity_executions']; + yield 'attempt run' => ['activity_attempts']; + } + + private function prepareTask(string $kind): WorkflowTask + { + Queue::fake(); + $this->createNamespace('default'); + $this->configureWorkflowTypes(['tests.external-greeting-workflow' => ExternalGreetingWorkflow::class]); + $this->postJson('/api/worker/register', $this->registration($kind), $this->workerHeaders())->assertCreated(); + $runId = $this->postJson('/api/workflows', [ + 'workflow_id' => 'legacy-instance', 'workflow_type' => 'tests.external-greeting-workflow', + 'task_queue' => 'legacy-workflows', 'input' => ['Ada'], + ], $this->apiHeaders())->assertCreated()->json('run_id'); + if ($kind === 'activity') { + $this->runReadyWorkflowTask($runId); + } + + return WorkflowTask::query()->where('workflow_run_id', $runId) + ->where('task_type', $kind)->where('status', 'ready')->firstOrFail(); + } + + private function setInstanceOwner(?string $owner): void + { + // The old importer attached a default run to an existing foreign instance. + DB::table('workflow_instances')->where('id', 'legacy-instance')->update(['namespace' => $owner]); + } + + private function poll(string $kind): TestResponse + { + return $this->postJson('/api/worker/'.$kind.'-tasks/poll', [ + 'worker_id' => 'legacy-worker', 'task_queue' => $this->queue($kind), + ], $this->workerHeaders()); + } + + private function registration(string $kind): array + { + return [ + 'worker_id' => 'legacy-worker', 'task_queue' => $this->queue($kind), 'runtime' => 'php', + 'capability_manifest' => $this->portableWorkerAffinityRefusalManifest(), + 'supported_workflow_types' => ['tests.external-greeting-workflow'], + 'supported_activity_types' => ['tests.external-greeting-activity'], + ]; + } + + private function replaceWorker(string $kind): void + { + $this->postJson('/api/worker/register', $this->registration($kind), $this->workerHeaders())->assertCreated(); + } + + private function queue(string $kind): string + { + return $kind === 'activity' ? 'external-activities' : 'legacy-workflows'; + } + + private function workflowRows(): array + { + $rows = []; + foreach (['workflow_instances', 'workflow_runs', 'workflow_run_summaries', 'workflow_tasks', 'workflow_history_events', 'activity_executions', 'activity_attempts'] as $table) { + $rows[$table] = DB::table($table)->orderBy('id')->get()->map(static fn ($row) => (array) $row)->all(); + } + + return $rows; + } +}