From 68da666a2b4e892d682661a1a5abe3d167036ed3 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Fri, 10 Jul 2026 14:32:56 +0200 Subject: [PATCH 01/10] Support dynamic (catch-all) workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `#[WorkflowMethod(dynamic: true)]` to declare a dynamic (catch-all) workflow — invoked when the worker receives a workflow whose type name is not statically registered. WorkflowReader flags the prototype; StartWorkflow falls back to the dynamic prototype when no named workflow matches; and the GetWorkerInfo handshake advertises `dynamic` so the RoadRunner temporal plugin can register a Go dynamic-workflow proxy for it. As in the other SDKs (Go panics, Python raises), at most one dynamic workflow may be registered per worker — WorkflowCollection enforces this. Enables Dynamic Workflow support in the PHP SDK (PHP and TypeScript are the only SDKs without it). End-to-end dispatch also requires temporalio/roadrunner-temporal#784 (register the dynamic proxy) and temporalio/sdk-go#2449 (execute a factory-registered dynamic workflow). --- .../Prototype/WorkflowCollection.php | 31 ++++++++++++++++++- .../Prototype/WorkflowPrototype.php | 15 +++++++++ .../Declaration/Reader/WorkflowReader.php | 5 ++- .../Transport/Router/GetWorkerInfo.php | 3 ++ .../Transport/Router/StartWorkflow.php | 16 +++++++++- src/Workflow/WorkflowMethod.php | 14 ++++++++- .../Fixture/WorkflowWithAnotherDynamic.php | 26 ++++++++++++++++ .../Fixture/WorkflowWithDynamic.php | 26 ++++++++++++++++ .../WorkflowDeclarationTestCase.php | 30 ++++++++++++++++++ 9 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php create mode 100644 tests/Unit/Declaration/Fixture/WorkflowWithDynamic.php diff --git a/src/Internal/Declaration/Prototype/WorkflowCollection.php b/src/Internal/Declaration/Prototype/WorkflowCollection.php index 7c31d603d..0d2c4c69a 100644 --- a/src/Internal/Declaration/Prototype/WorkflowCollection.php +++ b/src/Internal/Declaration/Prototype/WorkflowCollection.php @@ -12,8 +12,37 @@ namespace Temporal\Internal\Declaration\Prototype; use Temporal\Internal\Repository\ArrayRepository; +use Temporal\Internal\Repository\Identifiable; /** * @template-extends ArrayRepository */ -final class WorkflowCollection extends ArrayRepository {} +final class WorkflowCollection extends ArrayRepository +{ + /** + * A dynamic (catch-all) workflow is the single fallback used when no + * statically registered workflow matches the requested type. As in the + * other SDKs (Go panics, Python raises), at most one may be registered per + * worker — a second would make dispatch ambiguous. + */ + public function add(Identifiable $entry, bool $overwrite = false): void + { + if ($entry instanceof WorkflowPrototype && $entry->isDynamic()) { + foreach ($this as $existing) { + if ($existing instanceof WorkflowPrototype + && $existing->isDynamic() + && $existing->getID() !== $entry->getID() + ) { + throw new \LogicException(\sprintf( + 'Cannot register dynamic workflow "%s": a dynamic (catch-all) workflow "%s" is ' + . 'already registered. At most one dynamic workflow is allowed per worker.', + $entry->getID(), + $existing->getID(), + )); + } + } + } + + parent::add($entry, $overwrite); + } +} diff --git a/src/Internal/Declaration/Prototype/WorkflowPrototype.php b/src/Internal/Declaration/Prototype/WorkflowPrototype.php index cf47d7fae..0934dcee8 100644 --- a/src/Internal/Declaration/Prototype/WorkflowPrototype.php +++ b/src/Internal/Declaration/Prototype/WorkflowPrototype.php @@ -44,6 +44,7 @@ final class WorkflowPrototype extends Prototype private ?MethodRetry $methodRetry = null; private ?ReturnType $returnType = null; private bool $hasInitializer = false; + private bool $dynamic = false; private VersioningBehavior $versioningBehavior; /** @@ -72,6 +73,20 @@ public function setHasInitializer(bool $hasInitializer): void $this->hasInitializer = $hasInitializer; } + /** + * Whether this is the dynamic (catch-all) workflow, invoked when no + * statically registered workflow matches the requested type name. + */ + public function isDynamic(): bool + { + return $this->dynamic; + } + + public function setDynamic(bool $dynamic): void + { + $this->dynamic = $dynamic; + } + public function getCronSchedule(): ?CronSchedule { return $this->cronSchedule; diff --git a/src/Internal/Declaration/Reader/WorkflowReader.php b/src/Internal/Declaration/Reader/WorkflowReader.php index d97877a22..55dc116eb 100644 --- a/src/Internal/Declaration/Reader/WorkflowReader.php +++ b/src/Internal/Declaration/Reader/WorkflowReader.php @@ -432,6 +432,9 @@ private function findProto( $name = $info->name ?? $interface->getShortName(); - return new WorkflowPrototype($name, $handler, $class); + $prototype = new WorkflowPrototype($name, $handler, $class); + $prototype->setDynamic($info->dynamic); + + return $prototype; } } diff --git a/src/Internal/Transport/Router/GetWorkerInfo.php b/src/Internal/Transport/Router/GetWorkerInfo.php index 596c214d4..ac435962e 100644 --- a/src/Internal/Transport/Router/GetWorkerInfo.php +++ b/src/Internal/Transport/Router/GetWorkerInfo.php @@ -51,6 +51,9 @@ private function workerToArray(WorkerInterface $worker): array 'queries' => \array_keys($workflow->getQueryHandlers()), 'signals' => \array_keys($workflow->getSignalHandlers()), 'versioning_behavior' => $workflow->getVersioningBehavior()->value, + // Advertise the dynamic (catch-all) workflow so the RoadRunner + // temporal plugin registers a Go dynamic-workflow proxy for it. + 'dynamic' => $workflow->isDynamic(), ]; $activityMap = static fn(ActivityPrototype $activity): array => [ diff --git a/src/Internal/Transport/Router/StartWorkflow.php b/src/Internal/Transport/Router/StartWorkflow.php index 14e12083e..3d0cc0d70 100644 --- a/src/Internal/Transport/Router/StartWorkflow.php +++ b/src/Internal/Transport/Router/StartWorkflow.php @@ -91,7 +91,21 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred private function findWorkflowOrFail(WorkflowInfo $info): WorkflowPrototype { - return $this->services->workflows->find($info->type->name) ?? throw new \OutOfRangeException( + $found = $this->services->workflows->find($info->type->name); + + if ($found instanceof WorkflowPrototype) { + return $found; + } + + // Fall back to the dynamic (catch-all) workflow, if one is registered. + // The handler reads the real type name via Workflow::getInfo()->type. + foreach ($this->services->workflows as $prototype) { + if ($prototype instanceof WorkflowPrototype && $prototype->isDynamic()) { + return $prototype; + } + } + + throw new \OutOfRangeException( \sprintf(self::ERROR_NOT_FOUND, $info->type->name), ); } diff --git a/src/Workflow/WorkflowMethod.php b/src/Workflow/WorkflowMethod.php index 4195f84b4..12e07d585 100644 --- a/src/Workflow/WorkflowMethod.php +++ b/src/Workflow/WorkflowMethod.php @@ -35,11 +35,23 @@ final class WorkflowMethod #[Immutable] public ?string $name = null; + /** + * Marks this as a dynamic (catch-all) workflow: it is invoked when the + * worker receives a workflow whose type name is not statically registered. + * At most one dynamic workflow may be registered per worker. The handler + * reads the actual type name via {@see \Temporal\Workflow::getInfo()} and + * receives the raw arguments (declare a {@see \Temporal\DataConverter\ValuesInterface} + * parameter to access them). + */ + #[Immutable] + public bool $dynamic = false; + /** * @param non-empty-string|null $name */ - public function __construct(?string $name = null) + public function __construct(?string $name = null, bool $dynamic = false) { $this->name = $name; + $this->dynamic = $dynamic; } } diff --git a/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php b/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php new file mode 100644 index 000000000..fc5c390af --- /dev/null +++ b/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php @@ -0,0 +1,26 @@ +assertNull($prototype->getHandler()); } + /** + * @param WorkflowReader $reader + * @throws \ReflectionException + */ + #[TestDox("Reading a dynamic (catch-all) workflow sets the dynamic flag")] + #[DataProvider('workflowReaderDataProvider')] + public function testDynamicWorkflow(WorkflowReader $reader): void + { + $this->assertTrue($reader->fromClass(WorkflowWithDynamic::class)->isDynamic()); + $this->assertFalse($reader->fromClass(SimpleWorkflow::class)->isDynamic()); + } + + /** + * @param WorkflowReader $reader + * @throws \ReflectionException + */ + #[TestDox("At most one dynamic workflow may be registered per worker")] + #[DataProvider('workflowReaderDataProvider')] + public function testMultipleDynamicWorkflowsAreRejected(WorkflowReader $reader): void + { + $collection = new WorkflowCollection(); + $collection->add($reader->fromClass(WorkflowWithDynamic::class)); + + $this->expectException(\LogicException::class); + $collection->add($reader->fromClass(WorkflowWithAnotherDynamic::class)); + } + /** * @param WorkflowReader $reader * @throws \ReflectionException From 7a3fa1c4a99bca390d6e5ff4c6bed5b4f38a3750 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sat, 8 Aug 2026 10:55:05 +0200 Subject: [PATCH 02/10] Handle dynamic workflow arguments --- src/Internal/Workflow/Process/Process.php | 9 +- tests/Unit/Framework/WorkerTestCase.php | 103 ++++++++++++++++++-- tests/Unit/Router/GetWorkerInfoTestCase.php | 63 ++++++++++++ tests/Unit/Router/StartWorkflowTestCase.php | 18 ++++ 4 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 tests/Unit/Router/GetWorkerInfoTestCase.php diff --git a/src/Internal/Workflow/Process/Process.php b/src/Internal/Workflow/Process/Process.php index ecbea469a..35ff6cbdc 100644 --- a/src/Internal/Workflow/Process/Process.php +++ b/src/Internal/Workflow/Process/Process.php @@ -186,6 +186,7 @@ public function initAndStart( bool $deferred, ): void { $handler = $instance->getHandler(); + $dynamic = $instance->getPrototype()->isDynamic(); $instance = $context->getWorkflowInstance(); $arguments = null; $values = []; @@ -220,9 +221,13 @@ public function initAndStart( $this->services->interceptorProvider ->getPipeline(WorkflowInboundCallsInterceptor::class) ->with( - function (WorkflowInput $input) use ($context, $arguments, $handler, $deferred): void { + function (WorkflowInput $input) use ($context, $arguments, $handler, $deferred, $dynamic): void { // Prepare typed input if values have been changed - if ($arguments === null || $input->arguments !== $context->getInput()) { + if ($dynamic) { + // Dynamic workflows receive the untouched argument collection so + // they can interpret types that are unknown at registration time. + $arguments = EncodedValues::fromValues([$input->arguments]); + } elseif ($arguments === null || $input->arguments !== $context->getInput()) { $arguments = EncodedValues::fromValues($handler->resolveArguments($input->arguments)); } diff --git a/tests/Unit/Framework/WorkerTestCase.php b/tests/Unit/Framework/WorkerTestCase.php index 63e2e51f6..353dabeba 100644 --- a/tests/Unit/Framework/WorkerTestCase.php +++ b/tests/Unit/Framework/WorkerTestCase.php @@ -4,6 +4,7 @@ namespace Temporal\Tests\Unit\Framework; +use Temporal\DataConverter\ValuesInterface; use Temporal\Tests\Unit\AbstractUnit; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; @@ -18,17 +19,10 @@ final class WorkerTestCase extends AbstractUnit { private WorkerFactoryInterface $factory; + /** @var WorkerMock|WorkerInterface */ private $worker; - protected function setUp(): void - { - $this->factory = WorkerFactoryMock::create(); - $this->worker = $this->factory->newWorker(); - - parent::setUp(); - } - public function testRunWorker(): void { $this->worker->registerWorkflowObject( @@ -38,15 +32,104 @@ class { #[WorkflowMethod(name: 'SimpleWorkflow')] public function handler(): iterable { - $result = yield Workflow::awaitWithTimeout(5, fn() => false); + $result = yield Workflow::awaitWithTimeout(5, static fn() => false); assertFalse($result); return $result; } - } + }, ); $this->worker->runWorkflow('SimpleWorkflow'); $this->worker->expectTimer(5); $this->factory->run($this->worker); } + + public function testDynamicWorkflowReceivesActualTypeAndRawArguments(): void + { + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'DynamicWorkflow', dynamic: true)] + public function handler(ValuesInterface $arguments): array + { + return [ + Workflow::getInfo()->type->name, + $arguments->getValues(), + ]; + } + }, + ); + + $this->worker->runWorkflow('RuntimeDefinedWorkflow', 'alpha', 42); + $this->worker->assertWorkflowReturns(['RuntimeDefinedWorkflow', ['alpha', 42]]); + + $this->factory->run($this->worker); + self::assertCount(1, $this->worker->getWorkflows()); + } + + public function testNamedWorkflowTakesPrecedenceOverDynamicWorkflow(): void + { + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'DynamicWorkflow', dynamic: true)] + public function handler(ValuesInterface $arguments): string + { + return 'dynamic'; + } + }, + ); + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'NamedWorkflow')] + public function handler(): string + { + return 'named'; + } + }, + ); + + $this->worker->runWorkflow('NamedWorkflow'); + $this->worker->assertWorkflowReturns('named'); + + $this->factory->run($this->worker); + self::assertCount(2, $this->worker->getWorkflows()); + } + + public function testEachWorkerCanRegisterOneDynamicWorkflow(): void + { + $secondWorker = $this->factory->newWorker('other-task-queue'); + + $this->worker->registerWorkflowObject( + new + #[Workflow\WorkflowInterface] + class { + #[WorkflowMethod(name: 'DynamicWorkflow', dynamic: true)] + public function handler(): void {} + }, + ); + $secondWorker->registerWorkflowTypes(PerWorkerDynamicWorkflow::class); + + self::assertCount(1, $this->worker->getWorkflows()); + self::assertCount(1, $secondWorker->getWorkflows()); + } + + protected function setUp(): void + { + $this->factory = WorkerFactoryMock::create(); + $this->worker = $this->factory->newWorker(); + + parent::setUp(); + } +} + +#[Workflow\WorkflowInterface] +final class PerWorkerDynamicWorkflow +{ + #[WorkflowMethod(name: 'DynamicWorkflow', dynamic: true)] + public function handler(): void {} } diff --git a/tests/Unit/Router/GetWorkerInfoTestCase.php b/tests/Unit/Router/GetWorkerInfoTestCase.php new file mode 100644 index 000000000..159c21064 --- /dev/null +++ b/tests/Unit/Router/GetWorkerInfoTestCase.php @@ -0,0 +1,63 @@ +createMock(WorkerInterface::class); + $options = WorkerOptions::new(); + + $worker->method('getID')->willReturn('test-queue'); + $worker->method('getOptions')->willReturn($options); + $worker->method('getWorkflows')->willReturn([ + $reader->fromClass(WorkflowWithDynamic::class), + $reader->fromClass(SimpleWorkflow::class), + ]); + $worker->method('getActivities')->willReturn([]); + + $marshaller = $this->createMock(MarshallerInterface::class); + $marshaller->expects($this->once()) + ->method('marshal') + ->with($options) + ->willReturn([]); + + $router = new GetWorkerInfo( + new ArrayRepository([$worker]), + $marshaller, + ServiceCredentials::create(), + new PluginRegistry(), + ); + $resolver = new Deferred(); + $response = null; + $resolver->promise()->then(static function (EncodedValues $values) use (&$response): void { + $response = $values->getValues(); + }); + + $router->handle(new Request(), [], $resolver); + + self::assertNotNull($response); + self::assertSame(true, $response[0]['Workflows'][0]['dynamic']); + self::assertSame(false, $response[0]['Workflows'][1]['dynamic']); + } +} diff --git a/tests/Unit/Router/StartWorkflowTestCase.php b/tests/Unit/Router/StartWorkflowTestCase.php index a42f54c8c..0a49520a6 100644 --- a/tests/Unit/Router/StartWorkflowTestCase.php +++ b/tests/Unit/Router/StartWorkflowTestCase.php @@ -86,6 +86,24 @@ public function testStartingAlreadyRunningWorkflow(): void $this->router->handle($request, [], new Deferred()); } + public function testUnknownWorkflowWithoutDynamicWorkflowThrows(): void + { + $request = new Request($runId = Uuid::v4(), 'UnknownWorkflow', EncodedValues::fromValues([])); + + $workflowInfo = new WorkflowInfo(); + $workflowInfo->type->name = 'UnknownWorkflow'; + $workflowInfo->execution = new WorkflowExecution('123', $runId); + + $this->marshaller->expects($this->once()) + ->method('unmarshal') + ->willReturn(new Input($workflowInfo)); + + $this->expectException(\OutOfRangeException::class); + $this->expectExceptionMessage('Workflow with the specified name "UnknownWorkflow" was not registered'); + + $this->router->handle($request, [], new Deferred()); + } + protected function setUp(): void { $workflow = new \stdClass(); From 433f11e8ac840134b0f6c8d907e1a9dc389cc434 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Tue, 25 Aug 2026 11:47:24 +0200 Subject: [PATCH 03/10] Address dynamic workflow review feedback --- src/Workflow/WorkflowMethod.php | 5 +---- .../Fixture/WorkflowWithAnotherDynamic.php | 2 -- .../Fixture/WorkflowWithDynamic.php | 2 -- .../WorkflowDeclarationTestCase.php | 19 +++++++------------ 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/Workflow/WorkflowMethod.php b/src/Workflow/WorkflowMethod.php index 12e07d585..a336ca096 100644 --- a/src/Workflow/WorkflowMethod.php +++ b/src/Workflow/WorkflowMethod.php @@ -38,10 +38,7 @@ final class WorkflowMethod /** * Marks this as a dynamic (catch-all) workflow: it is invoked when the * worker receives a workflow whose type name is not statically registered. - * At most one dynamic workflow may be registered per worker. The handler - * reads the actual type name via {@see \Temporal\Workflow::getInfo()} and - * receives the raw arguments (declare a {@see \Temporal\DataConverter\ValuesInterface} - * parameter to access them). + * At most one dynamic workflow may be registered per worker. */ #[Immutable] public bool $dynamic = false; diff --git a/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php b/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php index fc5c390af..cb37b71b5 100644 --- a/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php +++ b/tests/Unit/Declaration/Fixture/WorkflowWithAnotherDynamic.php @@ -14,11 +14,9 @@ use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; -/** @WorkflowInterface */ #[WorkflowInterface] class WorkflowWithAnotherDynamic { - /** @WorkflowMethod(dynamic=true) */ #[WorkflowMethod(dynamic: true)] public function handler(): void { diff --git a/tests/Unit/Declaration/Fixture/WorkflowWithDynamic.php b/tests/Unit/Declaration/Fixture/WorkflowWithDynamic.php index 20bb260d6..7a1da2628 100644 --- a/tests/Unit/Declaration/Fixture/WorkflowWithDynamic.php +++ b/tests/Unit/Declaration/Fixture/WorkflowWithDynamic.php @@ -14,11 +14,9 @@ use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; -/** @WorkflowInterface */ #[WorkflowInterface] class WorkflowWithDynamic { - /** @WorkflowMethod(dynamic=true) */ #[WorkflowMethod(dynamic: true)] public function handler(): void { diff --git a/tests/Unit/Declaration/WorkflowDeclarationTestCase.php b/tests/Unit/Declaration/WorkflowDeclarationTestCase.php index a7f742e46..f26a56b22 100644 --- a/tests/Unit/Declaration/WorkflowDeclarationTestCase.php +++ b/tests/Unit/Declaration/WorkflowDeclarationTestCase.php @@ -54,26 +54,21 @@ public function testWorkflowWithoutHandler(WorkflowReader $reader): void $this->assertNull($prototype->getHandler()); } - /** - * @param WorkflowReader $reader - * @throws \ReflectionException - */ + /** @throws \ReflectionException */ #[TestDox("Reading a dynamic (catch-all) workflow sets the dynamic flag")] - #[DataProvider('workflowReaderDataProvider')] - public function testDynamicWorkflow(WorkflowReader $reader): void + public function testDynamicWorkflow(): void { + $reader = new WorkflowReader(new AttributeReader()); + $this->assertTrue($reader->fromClass(WorkflowWithDynamic::class)->isDynamic()); $this->assertFalse($reader->fromClass(SimpleWorkflow::class)->isDynamic()); } - /** - * @param WorkflowReader $reader - * @throws \ReflectionException - */ + /** @throws \ReflectionException */ #[TestDox("At most one dynamic workflow may be registered per worker")] - #[DataProvider('workflowReaderDataProvider')] - public function testMultipleDynamicWorkflowsAreRejected(WorkflowReader $reader): void + public function testMultipleDynamicWorkflowsAreRejected(): void { + $reader = new WorkflowReader(new AttributeReader()); $collection = new WorkflowCollection(); $collection->add($reader->fromClass(WorkflowWithDynamic::class)); From 8e7350dd4fa95cfa4d5162dfbe3dbf84bec161c9 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Wed, 21 Oct 2026 08:44:18 +0200 Subject: [PATCH 04/10] test: cover dynamic workflows in acceptance tests --- tests/Acceptance/App/TaskQueueResolver.php | 1 + .../Extra/Workflow/DynamicWorkflowTest.php | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php diff --git a/tests/Acceptance/App/TaskQueueResolver.php b/tests/Acceptance/App/TaskQueueResolver.php index 399ab18fc..11c670e95 100644 --- a/tests/Acceptance/App/TaskQueueResolver.php +++ b/tests/Acceptance/App/TaskQueueResolver.php @@ -14,6 +14,7 @@ final class TaskQueueResolver * @var list */ private const SHARED_QUEUE_EXCLUSIONS = [ + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflow\DynamicWorkflowTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\WorkflowA\WorkflowATest::class, \Temporal\Tests\Acceptance\Extra\Workflow\WorkflowB\WorkflowBTest::class, \Temporal\Tests\Acceptance\Harness\Activity\RetryOnError\RetryOnErrorTest::class, diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php new file mode 100644 index 000000000..d67433d7f --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php @@ -0,0 +1,76 @@ + 'Extra_Workflow_Dynamic_Empty', + 'args' => [], + ], $stub->getResult('array')); + } + + #[Test] + public function scalarParameters( + #[Stub('Extra_Workflow_Dynamic_Scalars', args: ['hello', 42])] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Scalars', + 'args' => ['hello', 42], + ], $stub->getResult('array')); + } + + #[Test] + public function structuredParameters( + #[Stub('Extra_Workflow_Dynamic_Structured', args: [ + 'pipeline', 0, ['steps' => ['publish', 'notify'], 'enabled' => false], + ])] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Structured', + 'args' => ['pipeline', 0, ['steps' => ['publish', 'notify'], 'enabled' => false]], + ], $stub->getResult('array')); + } + + #[Test] + public function mixedParameters( + #[Stub('Extra_Workflow_Dynamic_Mixed', args: ['', 0, null, false, 3.5, ['first', 'second'], null])] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Mixed', + 'args' => ['', 0, null, false, 3.5, ['first', 'second'], null], + ], $stub->getResult('array')); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(ValuesInterface $args): array + { + return [ + 'type' => Workflow::getInfo()->type->name, + 'args' => $args->getValues(), + ]; + } +} From ea63f285143ba2d05ef6974a038cadd99894f2ce Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Wed, 21 Oct 2026 10:07:32 +0200 Subject: [PATCH 05/10] ci: test dynamic workflow compatibility across RoadRunner versions --- .github/workflows/testing.yml | 31 +++++++++ .../Extra/Workflow/DynamicWorkflowTest.php | 67 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index acf641f17..f6de5bc86 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -58,3 +58,34 @@ jobs: fail-fast: false test-command: composer test:accept-fast download-binaries: true + + dynamic-workflow-compatibility: + name: Dynamic Workflow Compatibility + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + roadrunner: ['3.0.0', '2025.1.15'] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TEMPORAL_HEADLESS: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: '8.5' + extensions: dom, sockets, grpc, curl + + - uses: ramsey/composer-install@5c2bcf28d7b060ef3c601d7b476d5430a7b46c27 # v4 + + - name: Download binaries + run: vendor/bin/dload get 'rr:${{ matrix.roadrunner }}' temporal --no-interaction + + - name: Test dynamic workflow compatibility + run: >- + vendor/bin/phpunit tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php + --filter=roadRunnerVersionCompatibility --testdox + env: + TEMPORAL_TRANSCRIPT_DUMP_ON_FAIL: '1' diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php index d67433d7f..36642c863 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowTest.php @@ -5,16 +5,73 @@ namespace Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflow; use PHPUnit\Framework\Attributes\Test; +use Spiral\RoadRunner\Environment; +use Temporal\Api\Enums\V1\EventType; +use Temporal\Client\WorkflowClientInterface; +use Temporal\Client\WorkflowOptions; use Temporal\Client\WorkflowStubInterface; use Temporal\DataConverter\ValuesInterface; use Temporal\Tests\Acceptance\App\Attribute\Stub; +use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\TestCase; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; +use Temporal\Workflow\WorkflowExecutionStatus; use Temporal\Workflow\WorkflowMethod; class DynamicWorkflowTest extends TestCase { + #[Test] + public function roadRunnerVersionCompatibility( + #[Stub('Extra_Workflow_Dynamic_Version')] + WorkflowStubInterface $versionStub, + WorkflowClientInterface $client, + Feature $feature, + ): void { + $version = $versionStub->getResult('string'); + self::assertMatchesRegularExpression('/^v?(3\.|2025\.)/', $version); + + $stub = $client->newUntypedWorkflowStub( + 'Extra_Workflow_Dynamic_Compatibility', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('15 seconds'), + ); + $client->start($stub, 'hello', 42); + + try { + if (\str_starts_with(\ltrim($version, 'v'), '2025.')) { + // Older RoadRunner retries unknown types without invoking the PHP handler. + $deadline = \microtime(true) + 5; + do { + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + if ($event->getEventType() !== EventType::EVENT_TYPE_WORKFLOW_TASK_FAILED) { + continue; + } + + $message = $event->getWorkflowTaskFailedEventAttributes()->getFailure()->getMessage(); + self::assertStringContainsString('unable to find workflow type:', $message); + self::assertStringContainsString('Extra_Workflow_Dynamic_Compatibility', $message); + return; + } + + \usleep(100_000); + } while (\microtime(true) < $deadline); + + self::fail('Expected RoadRunner v2025 to reject the unregistered workflow type.'); + } + + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_Compatibility', + 'args' => ['hello', 42], + ], $stub->getResult('array', 5)); + } finally { + if ($stub->describe()->info->status === WorkflowExecutionStatus::Running) { + $stub->terminate('test-end'); + } + } + } + #[Test] public function noParameters( #[Stub('Extra_Workflow_Dynamic_Empty')] @@ -62,6 +119,16 @@ public function mixedParameters( } } +#[WorkflowInterface] +class VersionWorkflow +{ + #[WorkflowMethod('Extra_Workflow_Dynamic_Version')] + public function handle(): string + { + return Environment::fromGlobals()->getVersion(); + } +} + #[WorkflowInterface] class TestWorkflow { From dc4547f159401e02f5be45b1a009848286cdfc47 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Fri, 23 Oct 2026 13:05:03 +0200 Subject: [PATCH 06/10] test: cover dynamic workflow handler signatures and failures --- .../App/DynamicWorkflowFailureTestCase.php | 80 +++++++++++++++++++ tests/Acceptance/App/TaskQueueResolver.php | 2 + .../DynamicWorkflowArrayParameterTest.php | 39 +++++++++ .../DynamicWorkflowNoParameterTest.php | 34 ++++++++ .../DynamicWorkflowRequiredParameterTest.php | 40 ++++++++++ .../DynamicWorkflowScalarParameterTest.php | 39 +++++++++ .../DynamicWorkflowUntypedParameterTest.php | 54 +++++++++++++ 7 files changed, 288 insertions(+) create mode 100644 tests/Acceptance/App/DynamicWorkflowFailureTestCase.php create mode 100644 tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php create mode 100644 tests/Acceptance/Extra/Workflow/DynamicWorkflowNoParameterTest.php create mode 100644 tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php create mode 100644 tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php create mode 100644 tests/Acceptance/Extra/Workflow/DynamicWorkflowUntypedParameterTest.php diff --git a/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php new file mode 100644 index 000000000..795862fc7 --- /dev/null +++ b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php @@ -0,0 +1,80 @@ +failure !== null) { + $cause = $input->failure->getPrevious(); + Workflow::getLogger()->error('Dynamic workflow handler failed', [ + 'exception' => $input->failure::class, + 'cause' => $cause === null ? null : $cause::class, + 'message' => $input->failure->getMessage(), + ]); + } + + return $next($input); + } + }, + ]); + } + + protected function assertHandlerFailure( + WorkflowStubInterface $stub, + WorkflowClientInterface $client, + ClientLogger $logger, + string $message, + string $cause = \TypeError::class, + ): void { + try { + $stub->getResult(timeout: 5); + self::fail('An invalid dynamic handler must fail the workflow.'); + } catch (WorkflowFailedException $exception) { + $failure = $exception->getPrevious(); + self::assertInstanceOf(ApplicationFailure::class, $failure); + self::assertSame(InvalidArgumentException::class, $failure->getType()); + self::assertStringContainsString('TestWorkflow::handle()', $failure->getOriginalMessage()); + self::assertStringContainsString($message, $failure->getOriginalMessage()); + self::assertInstanceOf(ApplicationFailure::class, $failure->getPrevious()); + self::assertSame($cause, $failure->getPrevious()->getType()); + } + + $records = $logger->findByMessage('/^Dynamic workflow handler failed$/'); + self::assertCount(1, $records); + self::assertSame('error', $records[0]->level); + self::assertSame(InvalidArgumentException::class, $records[0]->context['exception']); + self::assertSame($cause, $records[0]->context['cause']); + self::assertSame($failure->getOriginalMessage(), $records[0]->context['message']); + + $events = []; + foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { + $events[] = $event->getEventType(); + } + self::assertContains(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_FAILED, $events); + self::assertNotContains(EventType::EVENT_TYPE_WORKFLOW_TASK_FAILED, $events); + } +} diff --git a/tests/Acceptance/App/TaskQueueResolver.php b/tests/Acceptance/App/TaskQueueResolver.php index 11c670e95..38cb08985 100644 --- a/tests/Acceptance/App/TaskQueueResolver.php +++ b/tests/Acceptance/App/TaskQueueResolver.php @@ -15,6 +15,8 @@ final class TaskQueueResolver */ private const SHARED_QUEUE_EXCLUSIONS = [ \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflow\DynamicWorkflowTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowNoParameter\DynamicWorkflowNoParameterTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowUntypedParameter\DynamicWorkflowUntypedParameterTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\WorkflowA\WorkflowATest::class, \Temporal\Tests\Acceptance\Extra\Workflow\WorkflowB\WorkflowBTest::class, \Temporal\Tests\Acceptance\Harness\Activity\RetryOnError\RetryOnErrorTest::class, diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php new file mode 100644 index 000000000..63cbcf6ef --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php @@ -0,0 +1,39 @@ +assertHandlerFailure($stub, $client, $logger, 'must be of type array'); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(array $values): array + { + return $values; + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowNoParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowNoParameterTest.php new file mode 100644 index 000000000..cc8fe5f5b --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowNoParameterTest.php @@ -0,0 +1,34 @@ +getResult('string', 5)); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(): string + { + return Workflow::getInfo()->type->name; + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php new file mode 100644 index 000000000..9aa9a1a1a --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php @@ -0,0 +1,40 @@ +assertHandlerFailure($stub, $client, $logger, 'Too few arguments', \ArgumentCountError::class); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(ValuesInterface $values, string $required): string + { + return $required; + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php new file mode 100644 index 000000000..fbd24e0fa --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php @@ -0,0 +1,39 @@ +assertHandlerFailure($stub, $client, $logger, 'must be of type string'); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle(string $value): string + { + return $value; + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowUntypedParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowUntypedParameterTest.php new file mode 100644 index 000000000..f85517528 --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowUntypedParameterTest.php @@ -0,0 +1,54 @@ + 'Extra_Workflow_Dynamic_UntypedEmpty', + 'args' => [], + 'optional' => 'default', + ], $stub->getResult('array', 5)); + } + + #[Test] + public function untypedParameterReceivesOneCollectionAndKeepsOptionalDefault( + #[Stub('Extra_Workflow_Dynamic_UntypedMixed', args: ['hello', 42, null, false, ['nested' => true]])] + WorkflowStubInterface $stub, + ): void { + self::assertSame([ + 'type' => 'Extra_Workflow_Dynamic_UntypedMixed', + 'args' => ['hello', 42, null, false, ['nested' => true]], + 'optional' => 'default', + ], $stub->getResult('array', 5)); + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(dynamic: true)] + public function handle($values, string $optional = 'default'): array + { + return [ + 'type' => Workflow::getInfo()->type->name, + 'args' => $values->getValues(), + 'optional' => $optional, + ]; + } +} From d8d252f92714a43d1ca5fb8265d1f106711e65ce Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 23 Oct 2026 15:55:17 +0400 Subject: [PATCH 07/10] test: assert full dynamic workflow failure messages --- tests/Acceptance/App/DynamicWorkflowFailureTestCase.php | 3 +-- .../Extra/Workflow/DynamicWorkflowArrayParameterTest.php | 2 +- .../Extra/Workflow/DynamicWorkflowRequiredParameterTest.php | 2 +- .../Extra/Workflow/DynamicWorkflowScalarParameterTest.php | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php index 795862fc7..542499b91 100644 --- a/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php +++ b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php @@ -57,8 +57,7 @@ protected function assertHandlerFailure( $failure = $exception->getPrevious(); self::assertInstanceOf(ApplicationFailure::class, $failure); self::assertSame(InvalidArgumentException::class, $failure->getType()); - self::assertStringContainsString('TestWorkflow::handle()', $failure->getOriginalMessage()); - self::assertStringContainsString($message, $failure->getOriginalMessage()); + self::assertSame($message, $failure->getOriginalMessage()); self::assertInstanceOf(ApplicationFailure::class, $failure->getPrevious()); self::assertSame($cause, $failure->getPrevious()->getType()); } diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php index 63cbcf6ef..80869711e 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php @@ -24,7 +24,7 @@ public function arrayParameterFailsWithSdkException( WorkflowClientInterface $client, ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, 'must be of type array'); + $this->assertHandlerFailure($stub, $client, $logger, TestWorkflow::class . '::handle(): Argument #1 ($values) must be of type array, Temporal\\DataConverter\\EncodedValues given'); } } diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php index 9aa9a1a1a..ec77c86ff 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php @@ -25,7 +25,7 @@ public function extraRequiredParameterFailsWithSdkException( WorkflowClientInterface $client, ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, 'Too few arguments', \ArgumentCountError::class); + $this->assertHandlerFailure($stub, $client, $logger, 'Too few arguments to function ' . TestWorkflow::class . '::handle(), 1 passed and exactly 2 expected', \ArgumentCountError::class); } } diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php index fbd24e0fa..a86cba38c 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php @@ -24,7 +24,7 @@ public function scalarParameterFailsWithSdkException( WorkflowClientInterface $client, ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, 'must be of type string'); + $this->assertHandlerFailure($stub, $client, $logger, TestWorkflow::class . '::handle(): Argument #1 ($value) must be of type string, Temporal\\DataConverter\\EncodedValues given'); } } From 52ad625a94f32d057cf483d6163905919c10256b Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 23 Oct 2026 16:04:13 +0400 Subject: [PATCH 08/10] test: check the worker keeps serving workflows after a dynamic handler failure --- .../App/DynamicWorkflowFailureTestCase.php | 10 ++++++++++ .../Workflow/DynamicWorkflowArrayParameterTest.php | 13 ++++++++++++- .../DynamicWorkflowRequiredParameterTest.php | 13 ++++++++++++- .../Workflow/DynamicWorkflowScalarParameterTest.php | 13 ++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php index 542499b91..94ea1b881 100644 --- a/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php +++ b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php @@ -7,6 +7,7 @@ use React\Promise\PromiseInterface; use Temporal\Api\Enums\V1\EventType; use Temporal\Client\WorkflowClientInterface; +use Temporal\Client\WorkflowOptions; use Temporal\Client\WorkflowStubInterface; use Temporal\Exception\Client\WorkflowFailedException; use Temporal\Exception\Failure\ApplicationFailure; @@ -34,6 +35,7 @@ public function complete(CompleteInput $input, callable $next): PromiseInterface 'exception' => $input->failure::class, 'cause' => $cause === null ? null : $cause::class, 'message' => $input->failure->getMessage(), + 'pid' => \getmypid(), ]); } @@ -47,6 +49,7 @@ protected function assertHandlerFailure( WorkflowStubInterface $stub, WorkflowClientInterface $client, ClientLogger $logger, + string $probeType, string $message, string $cause = \TypeError::class, ): void { @@ -75,5 +78,12 @@ protected function assertHandlerFailure( } self::assertContains(EventType::EVENT_TYPE_WORKFLOW_EXECUTION_FAILED, $events); self::assertNotContains(EventType::EVENT_TYPE_WORKFLOW_TASK_FAILED, $events); + + $probe = $client->newUntypedWorkflowStub( + $probeType, + WorkflowOptions::new()->withTaskQueue($stub->getOptions()->taskQueue), + ); + $client->start($probe); + self::assertSame($records[0]->context['pid'], $probe->getResult('int', timeout: 5)); } } diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php index 80869711e..444aeb6a4 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php @@ -11,6 +11,7 @@ use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\DynamicWorkflowFailureTestCase; use Temporal\Tests\Acceptance\App\Logger\ClientLogger; +use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; @@ -24,7 +25,7 @@ public function arrayParameterFailsWithSdkException( WorkflowClientInterface $client, ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, TestWorkflow::class . '::handle(): Argument #1 ($values) must be of type array, Temporal\\DataConverter\\EncodedValues given'); + $this->assertHandlerFailure($stub, $client, $logger, 'Extra_Workflow_Dynamic_ArrayParameter_Probe', TestWorkflow::class . '::handle(): Argument #1 ($values) must be of type array, Temporal\\DataConverter\\EncodedValues given'); } } @@ -37,3 +38,13 @@ public function handle(array $values): array return $values; } } + +#[WorkflowInterface] +class ProbeWorkflow +{ + #[WorkflowMethod('Extra_Workflow_Dynamic_ArrayParameter_Probe')] + public function handle() + { + return yield Workflow::sideEffect(static fn(): int => \getmypid()); + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php index ec77c86ff..b4628d3fc 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php @@ -12,6 +12,7 @@ use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\DynamicWorkflowFailureTestCase; use Temporal\Tests\Acceptance\App\Logger\ClientLogger; +use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; @@ -25,7 +26,7 @@ public function extraRequiredParameterFailsWithSdkException( WorkflowClientInterface $client, ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, 'Too few arguments to function ' . TestWorkflow::class . '::handle(), 1 passed and exactly 2 expected', \ArgumentCountError::class); + $this->assertHandlerFailure($stub, $client, $logger, 'Extra_Workflow_Dynamic_RequiredParameter_Probe', 'Too few arguments to function ' . TestWorkflow::class . '::handle(), 1 passed and exactly 2 expected', \ArgumentCountError::class); } } @@ -38,3 +39,13 @@ public function handle(ValuesInterface $values, string $required): string return $required; } } + +#[WorkflowInterface] +class ProbeWorkflow +{ + #[WorkflowMethod('Extra_Workflow_Dynamic_RequiredParameter_Probe')] + public function handle() + { + return yield Workflow::sideEffect(static fn(): int => \getmypid()); + } +} diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php index a86cba38c..c72f1fcbd 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php @@ -11,6 +11,7 @@ use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\DynamicWorkflowFailureTestCase; use Temporal\Tests\Acceptance\App\Logger\ClientLogger; +use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; @@ -24,7 +25,7 @@ public function scalarParameterFailsWithSdkException( WorkflowClientInterface $client, ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, TestWorkflow::class . '::handle(): Argument #1 ($value) must be of type string, Temporal\\DataConverter\\EncodedValues given'); + $this->assertHandlerFailure($stub, $client, $logger, 'Extra_Workflow_Dynamic_ScalarParameter_Probe', TestWorkflow::class . '::handle(): Argument #1 ($value) must be of type string, Temporal\\DataConverter\\EncodedValues given'); } } @@ -37,3 +38,13 @@ public function handle(string $value): string return $value; } } + +#[WorkflowInterface] +class ProbeWorkflow +{ + #[WorkflowMethod('Extra_Workflow_Dynamic_ScalarParameter_Probe')] + public function handle() + { + return yield Workflow::sideEffect(static fn(): int => \getmypid()); + } +} From d8baeb0517814ea8cf4b824e3315de2a6dd64ae4 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 23 Oct 2026 16:12:58 +0400 Subject: [PATCH 09/10] test: read the worker PID from the transcript instead of a custom interceptor --- .../App/DynamicWorkflowFailureTestCase.php | 47 ++++--------------- tests/Acceptance/App/TaskQueueResolver.php | 3 ++ .../DynamicWorkflowArrayParameterTest.php | 6 +-- .../DynamicWorkflowRequiredParameterTest.php | 6 +-- .../DynamicWorkflowScalarParameterTest.php | 6 +-- 5 files changed, 14 insertions(+), 54 deletions(-) diff --git a/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php index 94ea1b881..d06f1a614 100644 --- a/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php +++ b/tests/Acceptance/App/DynamicWorkflowFailureTestCase.php @@ -4,7 +4,6 @@ namespace Temporal\Tests\Acceptance\App; -use React\Promise\PromiseInterface; use Temporal\Api\Enums\V1\EventType; use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowOptions; @@ -12,43 +11,13 @@ use Temporal\Exception\Client\WorkflowFailedException; use Temporal\Exception\Failure\ApplicationFailure; use Temporal\Exception\InvalidArgumentException; -use Temporal\Interceptor\SimplePipelineProvider; -use Temporal\Interceptor\Trait\WorkflowOutboundCallsInterceptorTrait; -use Temporal\Interceptor\WorkflowOutboundCalls\CompleteInput; -use Temporal\Interceptor\WorkflowOutboundCallsInterceptor; -use Temporal\Tests\Acceptance\App\Logger\ClientLogger; -use Temporal\Workflow; +use Temporal\Testing\Transcript\TranscriptLine; abstract class DynamicWorkflowFailureTestCase extends TestCase { - public static function pipelineProvider(): SimplePipelineProvider - { - return new SimplePipelineProvider([ - new class implements WorkflowOutboundCallsInterceptor { - use WorkflowOutboundCallsInterceptorTrait; - - public function complete(CompleteInput $input, callable $next): PromiseInterface - { - if ($input->failure !== null) { - $cause = $input->failure->getPrevious(); - Workflow::getLogger()->error('Dynamic workflow handler failed', [ - 'exception' => $input->failure::class, - 'cause' => $cause === null ? null : $cause::class, - 'message' => $input->failure->getMessage(), - 'pid' => \getmypid(), - ]); - } - - return $next($input); - } - }, - ]); - } - protected function assertHandlerFailure( WorkflowStubInterface $stub, WorkflowClientInterface $client, - ClientLogger $logger, string $probeType, string $message, string $cause = \TypeError::class, @@ -65,12 +34,12 @@ protected function assertHandlerFailure( self::assertSame($cause, $failure->getPrevious()->getType()); } - $records = $logger->findByMessage('/^Dynamic workflow handler failed$/'); - self::assertCount(1, $records); - self::assertSame('error', $records[0]->level); - self::assertSame(InvalidArgumentException::class, $records[0]->context['exception']); - self::assertSame($cause, $records[0]->context['cause']); - self::assertSame($failure->getOriginalMessage(), $records[0]->context['message']); + $starts = \array_values(\array_filter( + $this->readCurrentTestTranscript(), + static fn(TranscriptLine $line): bool => $line->getAttribute('event') === 'workflow_execute_start' + && $line->getAttribute('workflow_id') === $stub->getExecution()->getID(), + )); + self::assertCount(1, $starts); $events = []; foreach ($client->getWorkflowHistory($stub->getExecution()) as $event) { @@ -84,6 +53,6 @@ protected function assertHandlerFailure( WorkflowOptions::new()->withTaskQueue($stub->getOptions()->taskQueue), ); $client->start($probe); - self::assertSame($records[0]->context['pid'], $probe->getResult('int', timeout: 5)); + self::assertSame($starts[0]->processId, $probe->getResult('int', timeout: 5)); } } diff --git a/tests/Acceptance/App/TaskQueueResolver.php b/tests/Acceptance/App/TaskQueueResolver.php index 435b2f99c..d4bc7305c 100644 --- a/tests/Acceptance/App/TaskQueueResolver.php +++ b/tests/Acceptance/App/TaskQueueResolver.php @@ -15,7 +15,10 @@ final class TaskQueueResolver */ private const SHARED_QUEUE_EXCLUSIONS = [ \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflow\DynamicWorkflowTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowArrayParameter\DynamicWorkflowArrayParameterTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowNoParameter\DynamicWorkflowNoParameterTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowRequiredParameter\DynamicWorkflowRequiredParameterTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowScalarParameter\DynamicWorkflowScalarParameterTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowUntypedParameter\DynamicWorkflowUntypedParameterTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\WorkflowA\WorkflowATest::class, \Temporal\Tests\Acceptance\Extra\Workflow\WorkflowB\WorkflowBTest::class, diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php index 444aeb6a4..e12122065 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowArrayParameterTest.php @@ -8,14 +8,11 @@ use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowStubInterface; use Temporal\Tests\Acceptance\App\Attribute\Stub; -use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\DynamicWorkflowFailureTestCase; -use Temporal\Tests\Acceptance\App\Logger\ClientLogger; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; -#[Worker(pipelineProvider: [DynamicWorkflowFailureTestCase::class, 'pipelineProvider'])] class DynamicWorkflowArrayParameterTest extends DynamicWorkflowFailureTestCase { #[Test] @@ -23,9 +20,8 @@ public function arrayParameterFailsWithSdkException( #[Stub('Extra_Workflow_Dynamic_ArrayParameter', args: [['hello', 42]])] WorkflowStubInterface $stub, WorkflowClientInterface $client, - ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, 'Extra_Workflow_Dynamic_ArrayParameter_Probe', TestWorkflow::class . '::handle(): Argument #1 ($values) must be of type array, Temporal\\DataConverter\\EncodedValues given'); + $this->assertHandlerFailure($stub, $client, 'Extra_Workflow_Dynamic_ArrayParameter_Probe', TestWorkflow::class . '::handle(): Argument #1 ($values) must be of type array, Temporal\\DataConverter\\EncodedValues given'); } } diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php index b4628d3fc..550783dbf 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowRequiredParameterTest.php @@ -9,14 +9,11 @@ use Temporal\Client\WorkflowStubInterface; use Temporal\DataConverter\ValuesInterface; use Temporal\Tests\Acceptance\App\Attribute\Stub; -use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\DynamicWorkflowFailureTestCase; -use Temporal\Tests\Acceptance\App\Logger\ClientLogger; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; -#[Worker(pipelineProvider: [DynamicWorkflowFailureTestCase::class, 'pipelineProvider'])] class DynamicWorkflowRequiredParameterTest extends DynamicWorkflowFailureTestCase { #[Test] @@ -24,9 +21,8 @@ public function extraRequiredParameterFailsWithSdkException( #[Stub('Extra_Workflow_Dynamic_RequiredParameter', args: ['hello', 'world'])] WorkflowStubInterface $stub, WorkflowClientInterface $client, - ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, 'Extra_Workflow_Dynamic_RequiredParameter_Probe', 'Too few arguments to function ' . TestWorkflow::class . '::handle(), 1 passed and exactly 2 expected', \ArgumentCountError::class); + $this->assertHandlerFailure($stub, $client, 'Extra_Workflow_Dynamic_RequiredParameter_Probe', 'Too few arguments to function ' . TestWorkflow::class . '::handle(), 1 passed and exactly 2 expected', \ArgumentCountError::class); } } diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php index c72f1fcbd..8452b941e 100644 --- a/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowScalarParameterTest.php @@ -8,14 +8,11 @@ use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowStubInterface; use Temporal\Tests\Acceptance\App\Attribute\Stub; -use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\DynamicWorkflowFailureTestCase; -use Temporal\Tests\Acceptance\App\Logger\ClientLogger; use Temporal\Workflow; use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; -#[Worker(pipelineProvider: [DynamicWorkflowFailureTestCase::class, 'pipelineProvider'])] class DynamicWorkflowScalarParameterTest extends DynamicWorkflowFailureTestCase { #[Test] @@ -23,9 +20,8 @@ public function scalarParameterFailsWithSdkException( #[Stub('Extra_Workflow_Dynamic_ScalarParameter', args: ['hello'])] WorkflowStubInterface $stub, WorkflowClientInterface $client, - ClientLogger $logger, ): void { - $this->assertHandlerFailure($stub, $client, $logger, 'Extra_Workflow_Dynamic_ScalarParameter_Probe', TestWorkflow::class . '::handle(): Argument #1 ($value) must be of type string, Temporal\\DataConverter\\EncodedValues given'); + $this->assertHandlerFailure($stub, $client, 'Extra_Workflow_Dynamic_ScalarParameter_Probe', TestWorkflow::class . '::handle(): Argument #1 ($value) must be of type string, Temporal\\DataConverter\\EncodedValues given'); } } From b6dd51ccb4f521d8e4c5728d923bd3a16f5a3d9e Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Tue, 27 Oct 2026 05:26:25 +0200 Subject: [PATCH 10/10] fix: pass raw arguments to dynamic workflow initializers - Preserve dynamic workflow arguments for `#[WorkflowInit]` constructors - Add acceptance coverage for initializer and handler arguments --- src/Internal/Workflow/Process/Process.php | 25 +++++---- tests/Acceptance/App/TaskQueueResolver.php | 1 + .../Workflow/DynamicWorkflowInitTest.php | 55 +++++++++++++++++++ 3 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 tests/Acceptance/Extra/Workflow/DynamicWorkflowInitTest.php diff --git a/src/Internal/Workflow/Process/Process.php b/src/Internal/Workflow/Process/Process.php index 35ff6cbdc..b44f1a706 100644 --- a/src/Internal/Workflow/Process/Process.php +++ b/src/Internal/Workflow/Process/Process.php @@ -196,17 +196,20 @@ public function initAndStart( // // Resolve arguments if #[WorkflowInit] is used if ($instance->getPrototype()->hasInitializer()) { - // Resolve args - $values = $handler->resolveArguments($context->getInput()); - $arguments = EncodedValues::fromValues($values); - /** @psalm-suppress InaccessibleProperty */ - $context = $context->withInput( - new Input( - $context->getInfo(), - $arguments, - $context->getHeader(), - ), - ); + if ($dynamic) { + $values = [$context->getInput()]; + } else { + $values = $handler->resolveArguments($context->getInput()); + $arguments = EncodedValues::fromValues($values); + /** @psalm-suppress InaccessibleProperty */ + $context = $context->withInput( + new Input( + $context->getInfo(), + $arguments, + $context->getHeader(), + ), + ); + } } $this->setContext($context); diff --git a/tests/Acceptance/App/TaskQueueResolver.php b/tests/Acceptance/App/TaskQueueResolver.php index d4bc7305c..422b8f935 100644 --- a/tests/Acceptance/App/TaskQueueResolver.php +++ b/tests/Acceptance/App/TaskQueueResolver.php @@ -15,6 +15,7 @@ final class TaskQueueResolver */ private const SHARED_QUEUE_EXCLUSIONS = [ \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflow\DynamicWorkflowTest::class, + \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowInit\DynamicWorkflowInitTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowArrayParameter\DynamicWorkflowArrayParameterTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowNoParameter\DynamicWorkflowNoParameterTest::class, \Temporal\Tests\Acceptance\Extra\Workflow\DynamicWorkflowRequiredParameter\DynamicWorkflowRequiredParameterTest::class, diff --git a/tests/Acceptance/Extra/Workflow/DynamicWorkflowInitTest.php b/tests/Acceptance/Extra/Workflow/DynamicWorkflowInitTest.php new file mode 100644 index 000000000..5aaaaba18 --- /dev/null +++ b/tests/Acceptance/Extra/Workflow/DynamicWorkflowInitTest.php @@ -0,0 +1,55 @@ +newUntypedWorkflowStub( + 'Extra_Workflow_Dynamic_Init', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withWorkflowExecutionTimeout('15 seconds'), + ); + $client->start($stub, ...$arguments); + + self::assertSame([ + 'constructor' => $arguments, + 'handler' => $arguments, + ], $stub->getResult('array', 5)); + } + } +} + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowInit] + public function __construct(private ValuesInterface $arguments) {} + + #[WorkflowMethod(dynamic: true)] + public function handle(ValuesInterface $arguments): array + { + return [ + 'constructor' => $this->arguments->getValues(), + 'handler' => $arguments->getValues(), + ]; + } +}