From 27c6bfa4967cb1e83917f94d7a41aa911e5780c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Fri, 4 Sep 2026 01:56:55 +0200 Subject: [PATCH] fix(bundle): #[AsWorkflow] s'autoconfigure, comme les trois autres attributs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DurableBundle::build()` autoconfigurait AsActivityHandler, AsNexusServiceHandler et FulfilsNexusOperation, mais pas AsWorkflow. Trois briques sur quatre se déclaraient par attribut, la quatrième par convention de dossier, dans le services.yaml de l'application. L'incohérence était dans le bundle, pas dans les applications. L'issue #255 annonçait un piège : un workflow est instancié par réflexion par WorkflowDefinitionLoader, jamais par le conteneur, et son constructeur reçoit un WorkflowEnvironment qui n'est pas un service — baliser sans plus ferait donc échouer la compilation. Éprouvé plutôt que supposé, et ça ne mord pas. Un cas compile réellement un conteneur portant un workflow de cette forme exacte, celle que le guide enseigne. La raison : WorkflowPass ne lit que le nom de classe et n'ajoute aucune référence, donc la définition reste privée et non référencée, et RemoveUnusedDefinitionsPass la retire avant que DefinitionErrorExceptionPass ne rapporte l'échec d'autowiring. Pas de setAutowired(false), qui aurait été du bruit défensif contre un problème qui n'existe pas. Le tag reste lu : une application qui l'écrit continue de marcher, il fait double emploi. Le guide cesse de le faire écrire, et la page des paquets cesse d'annoncer que `#[AsActivity]` enregistre — c'est un attribut de nommage posé sur le contrat. Les fixtures suivent la convention du dépôt, qui n'est pas celle des tests : sous Fixtures/, le namespace suit le chemin et la classe est autochargeable, comme unit\DurablePhpstan\Fixtures. Les classes de test, elles, gardent leur namespace unit\Gplanchat\… que PHPUnit charge par fichier. Suite unit : 1076 tests contre 1073 sur main, mêmes 4 erreurs d'environnement. PHPStan : 2 erreurs, la base de main. Refs: M17 de documentation/audit/, issue #255 Co-Authored-By: Claude Opus 5 (1M context) --- .../user/getting-started/_index.fr.md | 10 +- documentation/user/getting-started/_index.md | 9 +- documentation/user/packages/_index.fr.md | 6 +- documentation/user/packages/_index.md | 5 +- src/DurableBundle/DurableBundle.php | 11 +++ .../AsWorkflowAutoconfigurationTest.php | 98 +++++++++++++++++++ .../DurableBundle/Fixtures/PasUnWorkflow.php | 16 +++ .../Fixtures/WorkflowAvecEnvironnement.php | 26 +++++ .../Fixtures/WorkflowSansRien.php | 21 ++++ 9 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 tests/unit/DurableBundle/DependencyInjection/AsWorkflowAutoconfigurationTest.php create mode 100644 tests/unit/DurableBundle/Fixtures/PasUnWorkflow.php create mode 100644 tests/unit/DurableBundle/Fixtures/WorkflowAvecEnvironnement.php create mode 100644 tests/unit/DurableBundle/Fixtures/WorkflowSansRien.php diff --git a/documentation/user/getting-started/_index.fr.md b/documentation/user/getting-started/_index.fr.md index 0566b3f3..95f52c51 100644 --- a/documentation/user/getting-started/_index.fr.md +++ b/documentation/user/getting-started/_index.fr.md @@ -152,15 +152,21 @@ when@dev: ### Marquer les workflows -Toute classe portant `#[AsWorkflow]` dans votre espace de noms de workflows est enregistrée automatiquement dès que vous marquez le dossier : +Rien à écrire. Une classe portant `#[AsWorkflow]` est enregistrée dès qu'elle est un service — ce +qu'avec l'`autoconfigure: true` par défaut d'une application Symfony elle est déjà. + +Les versions précédentes demandaient de marquer le dossier à la main : ```yaml -# config/services.yaml +# config/services.yaml — désormais inutile App\Workflow\: resource: '../src/Workflow/' tags: [durable.workflow] ``` +La balise fonctionne toujours : une application qui l'écrit continue de marcher, elle fait +simplement double emploi. + ### Déclarer les implémentations d'activité Les classes d'implémentation d'activité sont des services Symfony ordinaires (l'autowiring s'applique). Si vous posez `#[AsActivityHandler]` sur la classe, le bundle les ramasse tout seul dès que le service est marqué. diff --git a/documentation/user/getting-started/_index.md b/documentation/user/getting-started/_index.md index 1703ec75..cb3ec37b 100644 --- a/documentation/user/getting-started/_index.md +++ b/documentation/user/getting-started/_index.md @@ -152,15 +152,20 @@ when@dev: ### Tag workflows -Any class annotated with `#[AsWorkflow]` in your workflow namespace is auto-registered when you tag the folder: +Nothing to write. A class carrying `#[AsWorkflow]` is registered as soon as it is a service, which +with the default `autoconfigure: true` of a Symfony application it already is. + +Earlier versions required tagging the folder by hand: ```yaml -# config/services.yaml +# config/services.yaml — no longer needed App\Workflow\: resource: '../src/Workflow/' tags: [durable.workflow] ``` +The tag still works, so an application that writes it keeps working; it is simply redundant. + ### Register activity implementations Activity implementation classes are registered as normal Symfony services (autowiring applies). If you use `#[AsActivityHandler]` on the class, the bundle picks them up automatically when the service is tagged. diff --git a/documentation/user/packages/_index.fr.md b/documentation/user/packages/_index.fr.md index 919e2bdf..7ef5501d 100644 --- a/documentation/user/packages/_index.fr.md +++ b/documentation/user/packages/_index.fr.md @@ -72,8 +72,10 @@ composer require gplanchat/durable-bundle Ce qu'il fait, et que vous écririez autrement à la main : -- **L'autoconfiguration.** Les classes portant `#[AsWorkflow]` et `#[AsActivity]` s'enregistrent seules ; - vous ne les listez pas dans un fichier de conteneur. +- **L'autoconfiguration.** Les classes portant `#[AsWorkflow]` ou `#[AsActivityHandler]` + s'enregistrent seules ; vous ne les listez pas dans un fichier de conteneur, et vous ne les + balisez pas non plus. `#[AsActivity]` est un attribut de nommage posé sur le contrat, pas + d'enregistrement. - **Le câblage Messenger.** Reprises de workflow et envois d'activité sont routés vers les transports que vous nommez dans `durable.yaml`, si bien qu'un workflow qui se suspend reprend par vos files existantes. diff --git a/documentation/user/packages/_index.md b/documentation/user/packages/_index.md index b6917bfd..c9912c54 100644 --- a/documentation/user/packages/_index.md +++ b/documentation/user/packages/_index.md @@ -68,8 +68,9 @@ composer require gplanchat/durable-bundle What it does that you would otherwise write by hand: -- **Autoconfiguration.** Classes carrying `#[AsWorkflow]` and `#[AsActivity]` are registered on their - own; you do not list them in a container file. +- **Autoconfiguration.** Classes carrying `#[AsWorkflow]` or `#[AsActivityHandler]` register + themselves; you do not list them in a container file, and you do not tag them either. + `#[AsActivity]` is a naming attribute on the contract, not a registration one. - **Messenger wiring.** Workflow resumes and activity dispatches are routed to the transports you name in `durable.yaml`, so a workflow that suspends resumes through your existing queues. - **One console command.** `durable:execution:diagnose ` prints what the engine holds diff --git a/src/DurableBundle/DurableBundle.php b/src/DurableBundle/DurableBundle.php index 2c9feaca..458d2a2c 100644 --- a/src/DurableBundle/DurableBundle.php +++ b/src/DurableBundle/DurableBundle.php @@ -5,6 +5,7 @@ namespace Gplanchat\Durable\Bundle; use Gplanchat\Durable\Attribute\AsActivityHandler; +use Gplanchat\Durable\Attribute\AsWorkflow; use Gplanchat\Durable\Attribute\AsNexusServiceHandler; use Gplanchat\Durable\Attribute\FulfilsNexusOperation; use Gplanchat\Durable\Bundle\DependencyInjection\Compiler\ActivityHandlerPass; @@ -28,6 +29,16 @@ static function (ChildDefinition $definition, AsActivityHandler $attribute, \Ref }, ); + // Le quatrième, et il manquait. `WorkflowDefinitionLoader` lit déjà `#[AsWorkflow]` pour + // nommer le type ; c'est ici que la classe devient trouvable par le registre, sans balise + // écrite à la main dans le `services.yaml` de l'application. + $container->registerAttributeForAutoconfiguration( + AsWorkflow::class, + static function (ChildDefinition $definition, AsWorkflow $_attribute, \Reflector $_reflector): void { + $definition->addTag('durable.workflow'); + }, + ); + $container->registerAttributeForAutoconfiguration( AsNexusServiceHandler::class, static function (ChildDefinition $definition, AsNexusServiceHandler $attribute, \Reflector $_reflector): void { diff --git a/tests/unit/DurableBundle/DependencyInjection/AsWorkflowAutoconfigurationTest.php b/tests/unit/DurableBundle/DependencyInjection/AsWorkflowAutoconfigurationTest.php new file mode 100644 index 00000000..2735e9c8 --- /dev/null +++ b/tests/unit/DurableBundle/DependencyInjection/AsWorkflowAutoconfigurationTest.php @@ -0,0 +1,98 @@ +compileWith([WorkflowSansRien::class]); + + self::assertContains( + WorkflowSansRien::class, + $this->registeredClasses($container), + "l'attribut doit suffire, comme il suffit déjà pour les trois autres", + ); + } + + /** + * Le cœur de la question. Si le piège de #255 mordait, cet appel lèverait à la compilation. + */ + public function testUnWorkflowQuiRecoitLEnvironnementCompileQuandMeme(): void + { + $container = $this->compileWith([WorkflowAvecEnvironnement::class]); + + self::assertContains(WorkflowAvecEnvironnement::class, $this->registeredClasses($container)); + } + + public function testUneClasseSansAttributNeRejointPasLeRegistre(): void + { + $container = $this->compileWith([PasUnWorkflow::class]); + + self::assertNotContains(PasUnWorkflow::class, $this->registeredClasses($container)); + } + + /** + * @param list $classes + */ + private function compileWith(array $classes): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + (new DurableExtension())->load([[]], $container); + + // Ce que FrameworkExtension pose et que ce conteneur synthétique n'a pas : le bus par + // défaut, référencé par le dispatcher de reprise. + $container->register('messenger.default_bus', \stdClass::class)->setPublic(true); + + foreach ($classes as $class) { + $container->register($class, $class) + ->setAutoconfigured(true) + ->setAutowired(true) + ->setPublic(false) + ; + } + + (new DurableBundle())->build($container); + $container->compile(); + + return $container; + } + + /** + * @return list + */ + private function registeredClasses(ContainerBuilder $container): array + { + $registered = []; + foreach ($container->getDefinition(WorkflowRegistry::class)->getMethodCalls() as [$method, $arguments]) { + if ('registerClass' === $method) { + $registered[] = (string) $arguments[0]; + } + } + + return $registered; + } +} diff --git a/tests/unit/DurableBundle/Fixtures/PasUnWorkflow.php b/tests/unit/DurableBundle/Fixtures/PasUnWorkflow.php new file mode 100644 index 00000000..103fa976 --- /dev/null +++ b/tests/unit/DurableBundle/Fixtures/PasUnWorkflow.php @@ -0,0 +1,16 @@ +