[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CSTVJMKFZ9AWBYD8HFHDVA":3,"skill-same:01M6CSTVJMKFZ9AWBYD8HFHDVA":177},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":39,"linkOnly":40,"markdownContent":41,"markdownBody":42,"frontMatter":43,"refName":120,"commitSha":121,"skillDirectory":122,"forksCount":14,"repositoryHtmlUrl":123,"repositoryHomepageUrl":6,"ownerHtmlUrl":124,"ownerBlogUrl":6,"ownerDescription":125,"updatedAt":126,"install":127,"resources":140},"01M6CSTVJMKFZ9AWBYD8HFHDVA","volontariapp-implement-async-job-flow",null,"Guide pas-à-pas pour concevoir, émettre et consommer un Job d'arrière-plan (BullMQ, BaseWorker, IJobHandler, job_audit loop et Fallbacks).","Volontariapp\u002Fmeta","Volontariapp","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F263673138?v=4","01M6CSQNQ3J4SKRWA512JQF5K7","VolontariApp","01M6CSQNV8T2M4PR5A22DKHY5T",0,"MIT","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002FSKILL.md",[],[19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"implement","volontariapp implement async job flow","flow","async","dispatcher","worker","flows","npm-packages","plan","packages","processor","controller","common","architecture","code","database","loop","messaging","ms","modules","2026-11-10 10:12:30",false,"---\nname: volontariapp-implement-async-job-flow\ndescription: \"Guide pas-à-pas pour concevoir, émettre et consommer un Job d'arrière-plan (BullMQ, BaseWorker, IJobHandler, job_audit loop et Fallbacks).\"\ntype: Agent Skill\ntitle: Implement Async Job Flow\ntags: [async, jobs, bullmq, outbox, workers]\nstatus: stable\npaths:\n  - \"npm-packages\u002Fpackages\u002Fmessaging\u002Fsrc\u002Fjobs\u002F**\"\n  - \"npm-packages\u002Fpackages\u002Fworkers\u002Fsrc\u002F**\"\n  - \"workers-runners\u002F*\u002Fsrc\u002F**\"\nmesh_keys:\n  - workers-runners\n  - outbox-runners\n  - messaging\u002Fsrc\u002Fjobs\n  - fallback_\n  - jobtype\n  - jobmessagingtype\n  - ijobhandler\n  - baseworker\ngenerated:\n  by: claude-code\u002Fclaude-opus-5-5\n  at: \"2026-10-06T08:22:12Z\"\nsources:\n  - id: jobs\n    resource: npm-packages\u002Fpackages\u002Fmessaging\u002Fsrc\u002Fjobs\n    title: Contrats de jobs\n  - id: workers\n    resource: npm-packages\u002Fpackages\u002Fworkers\u002Fsrc\n    title: \"@volontariapp\u002Fworkers\"\n  - id: c3-async\n    resource: docs\u002Farchitecture\u002FC3-Async-Patterns-And-Flows.md\n    title: C3 Async Patterns and Flows\n  - id: base-cmd\n    resource: ms-event\u002Fsrc\u002Fmodules\u002Fevent\u002Fcontrollers\u002Fcommands\u002Fbase.command.controller.ts\n    title: BaseCommandController.withFallback (ms-event)\n  - id: dispatcher\n    resource: npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002Fdispatchers\u002Foutbox.dispatcher.ts\n    title: \"OutboxDispatcher : COMPLETED après le push\"\n  - id: pp-success\n    resource: npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcommon\u002Fjob-outbox-success.post-processor.ts\n    title: JobOutboxSuccessPostProcessor\n  - id: pp-failed\n    resource: npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcommon\u002Fjob-outbox-failed.post-processor.ts\n    title: JobOutboxFailedPostProcessor\n  - id: base-worker\n    resource: npm-packages\u002Fpackages\u002Fworkers\u002Fsrc\u002Fcore\u002Fbase.worker.ts\n    title: \"BaseWorker (garde d'idempotence job_audit)\"\n  - id: catalogue\n    resource: .agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Fscripts\u002Fjob_catalogue.py\n    title: job_catalogue.py\nverified:\n  - by: claude-code\u002Fclaude-sonnet-5-5\n    at: \"2026-10-30T09:05:05Z\"\n    digest: 978e78b31657a44d\n  - by: claude-code\u002Fagent\n    at: \"2026-10-30T11:33:19Z\"\n    digest: 00c291e87f9873ad\n  - by: claude-code\u002Fagent\n    at: \"2026-10-30T12:24:35Z\"\n    digest: dc8bb6efca7844fe\n  - by: claude-code\u002Fagent\n    at: \"2026-11-01T16:23:07Z\"\n    digest: f6acbaac1164ff2e\n  - by: claude-code\u002Fagent\n    at: \"2026-11-08T12:02:23Z\"\n    digest: 732d459afafa5938\n---\n\n# Guide : Implémenter un Job Asynchrone End-to-End\n\nDans Volontariapp, un **Job** représente une opération asynchrone **1 : 1** (garantie qu'un seul worker l'exécutera). Il passe par le **Transactional Outbox**, une file **BullMQ**, un **Worker Runner** (`BaseWorker` + `IJobHandler`), puis est audité via un **Trigger SQL PostgreSQL** avant son nettoyage automatique.\n\n---\n\n## Vue d'Ensemble du Cycle de Vie d'un Job\n\n```\n[1. Contrat SSOT]      npm-packages\u002Fpackages\u002Fmessaging (JobMessagingType, JobRegistry, Queues)\n         ↓\n[2. Émetteur]          ms-* \u002F domain-* (JobsOutboxEntity.createJob dans jobs_outbox)\n         ↓\n[3. Démon Outbox]      outbox-* runner (Pousse vers BullMQ Queue Redis)\n         ↓\n[4. Worker Runner]     workers-runners\u002Fworker-* (BaseWorker + IJobHandler)\n         ↓\n[5. Table job_audit]   Worker met à jour le statut (PROCESSING -> COMPLETED \u002F FAILED)\n         ↓\n[6. SQL Trigger DB]    Déclencheur auto sur job_audit -> insère dans event_queue\n         ↓\n[7. Post-Processor]    Nettoyage automatique (Hard Delete de jobs_outbox)\n```\n\n---\n\n> [!TIP]\n> **Guides Approfondis (Deep-Dive) :**\n> Si vous êtes bloqué sur une étape précise ou avez besoin d'exemples de code exhaustifs, consultez les fiches dédiées dans `references\u002F` :\n> - 01-job-messaging-contracts.md ([`references\u002F01-job-messaging-contracts.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F01-job-messaging-contracts.md)) (Enums, Registres, Queues, Payloads)\n> - 02-emitting-jobs-and-fallbacks.md ([`references\u002F02-emitting-jobs-and-fallbacks.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F02-emitting-jobs-and-fallbacks.md)) (Jobs classiques vs `withFallback` en controller)\n> - 03-worker-and-handlers.md ([`references\u002F03-worker-and-handlers.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F03-worker-and-handlers.md)) (BaseWorker, IJobHandler, Idempotence)\n> - 04-audit-loop-and-cleanup.md ([`references\u002F04-audit-loop-and-cleanup.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F04-audit-loop-and-cleanup.md)) (SQL Trigger, job_audit, nettoyage automatique)\n\n---\n\n## Étape 1 : Définir le Job dans `npm-packages\u002Fpackages\u002Fmessaging` (SSOT)\n> 📖 *Détails complets :* 01-job-messaging-contracts.md ([`references\u002F01-job-messaging-contracts.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F01-job-messaging-contracts.md))\n\n1. **Ajouter la constante de Job** dans `packages\u002Fmessaging\u002Fsrc\u002Fjobs\u002F\u003Cdomaine>\u002Fpayloads.ts` :\n   ```typescript\n   export const EventsJobType = {\n     \u002F\u002F ...\n     SYNC_EXTERNAL_CALENDAR: 'event.sync_external_calendar',\n   } as const;\n   ```\n\n2. **Définir l'interface du Payload** dans le même fichier :\n   ```typescript\n   export interface ISyncExternalCalendarPayload {\n     eventId: string;\n     calendarUrl: string;\n     syncRequestedBy: string;\n   }\n   ```\n\n3. **Enregistrer dans `JobRegistry`** (`packages\u002Fmessaging\u002Fsrc\u002Fjobs\u002Findex.ts`) :\n   ```typescript\n   export interface JobRegistry {\n     \u002F\u002F ...\n     [JobMessagingType.SYNC_EXTERNAL_CALENDAR]: ISyncExternalCalendarPayload;\n   }\n   ```\n\n4. **Vérifier ou définir la Queue BullMQ** (`packages\u002Fmessaging\u002Fsrc\u002Fjobs\u002F\u003Cdomaine>\u002Fqueue.ts`) :\n   ```typescript\n   export enum EventsQueue {\n     EVENTS = 'events-queue',\n     FALLBACK_EVENTS = 'fallback-events-queue',\n   }\n   ```\n\n> [!CAUTION]\n> ### 🛑 POINT DE BLOCAGE CRITIQUE : LE STOP IMMÉDIAT\n> **Tu viens de modifier `npm-packages\u002Fpackages\u002Fmessaging` ? TU DOIS T'ARRÊTER.**\n> 1. Valide la compilation locale dans `npm-packages` : `yarn build && yarn test`.\n> 2. Génère le changeset et le bump : `yarn changeset add` puis `yarn changeset version` (une seule fois par branche).\n> 3. **STOP TOTAL :** Interdiction formelle de passer à l'Étape 2 (`ms-*`) ou l'Étape 3 (`workers-runners`) immédiatement !\n> 4. Passe la main au Lead Dev pour qu'il pousse sur une PR et que la CI publie la version snapshot `\u003Cversion>-snap-\u003Csha court>`, publiée sous le dist-tag `next` et listée en commentaire de la PR (ex: `@volontariapp\u002Fmessaging@2.17.1-snap-a1b2c3d`).\n> 5. **Ce n'est qu'après publication par la CI** que tu pourras lancer `yarn up @volontariapp\u002Fmessaging` dans les microservices et continuer les étapes ci-dessous.\n\n---\n\n## Étape 2 : Émettre le Job en Base (`ms-*` ou `domain-*`)\n> 📖 *Détails complets :* 02-emitting-jobs-and-fallbacks.md ([`references\u002F02-emitting-jobs-and-fallbacks.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F02-emitting-jobs-and-fallbacks.md))\n\nLe job est inséré dans la table `jobs_outbox` lors de la transaction PostgreSQL.\n\n### Option A : Job Standard (Opération de fond planifiée)\n```typescript\nimport { JobsOutboxEntity, JobsOutboxModel } from '@volontariapp\u002Fdatabase';\nimport { JobsOutboxRepository } from '@volontariapp\u002Foutbox';\nimport { EventsQueue, JobMessagingType } from '@volontariapp\u002Fmessaging';\n\nconst job = JobsOutboxEntity.createJob\u003Ctypeof JobMessagingType.SYNC_EXTERNAL_CALENDAR>({\n  type: JobMessagingType.SYNC_EXTERNAL_CALENDAR,\n  emitter: 'ms-event',\n  emitterId: userId,\n  scheduledAt: new Date(),\n  target: EventsQueue.EVENTS,\n  payload: {\n    eventId,\n    calendarUrl,\n    syncRequestedBy: userId,\n  },\n});\n\nconst jobsRepo = new JobsOutboxRepository(queryRunner.manager.getRepository(JobsOutboxModel));\nawait jobsRepo.create(job);\n```\n\n### Option B : Fallback Job (En cas d'erreur dans un Command Controller)\nDans les contrôleurs héritant de `BaseCommandController` :\n```typescript\nreturn await this.withFallback(\n  JobMessagingType.FALLBACK_SYNC_CALENDAR,\n  userId,\n  { eventId, calendarUrl },\n  async () => {\n    return await this.calendarService.sync(eventId);\n  },\n);\n```\n\n> [!NOTE]\n> **Le Runner Outbox est 100% transparent :** `outbox-runners\u002Foutbox-\u003Cdomaine>` scrute `jobs_outbox` avec `SELECT ... FOR UPDATE SKIP LOCKED` et pousse le job dans la queue BullMQ correspondante sur Redis. Aucun code supplémentaire n'est nécessaire dans l'outbox runner !\n\n---\n\n## Étape 3 : Créer le Handler Unitaire dans `workers-runners`\n> 📖 *Détails complets :* 03-worker-and-handlers.md ([`references\u002F03-worker-and-handlers.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F03-worker-and-handlers.md))\n\nEmplacement : `workers-runners\u002Fworker-\u003Cdomaine>\u002Fsrc\u002Fworkers\u002Fhandlers\u002F\u003Cnom-du-job>.handler.ts`.\n\nChaque job possède sa propre classe implémentant `IJobHandler` :\n\n```typescript\nimport { Injectable } from '@nestjs\u002Fcommon';\nimport { Logger } from '@volontariapp\u002Flogger';\nimport { JobMessagingType, JobRegistry } from '@volontariapp\u002Fmessaging';\nimport type { JobOf } from '@volontariapp\u002Fworkers';\nimport type { IJobHandler } from '.\u002Finterfaces\u002Fjob-handler.interface.js';\n\n@Injectable()\nexport class SyncExternalCalendarHandler implements IJobHandler\u003Ctypeof JobMessagingType.SYNC_EXTERNAL_CALENDAR> {\n  private readonly logger = new Logger({ context: SyncExternalCalendarHandler.name });\n  \n  readonly jobType = JobMessagingType.SYNC_EXTERNAL_CALENDAR;\n\n  async handle(job: JobOf\u003Ctypeof JobMessagingType.SYNC_EXTERNAL_CALENDAR>): Promise\u003C{\n    originalPayload: JobRegistry[typeof JobMessagingType.SYNC_EXTERNAL_CALENDAR];\n  }> {\n    const { eventId, calendarUrl } = job.data.payload;\n    this.logger.info(`Syncing external calendar for event ${eventId}`, { calendarUrl });\n\n    \u002F\u002F Exécution du traitement lourd (ex: parsing iCal, appel réseau)\n    await this.performSync(eventId, calendarUrl);\n\n    this.logger.info(`Calendar synced successfully for ${eventId}`);\n    \n    \u002F\u002F Renvoyer obligatoirement le payload d'origine pour l'enregistrement d'audit\n    return { originalPayload: job.data.payload };\n  }\n\n  private async performSync(eventId: string, url: string): Promise\u003Cvoid> {\n    \u002F\u002F Logique métier\n  }\n}\n```\n\n---\n\n## Étape 4 : Raccorder le Handler au Worker NestJS\n\nDans le Worker principal du domaine (`workers-runners\u002Fworker-\u003Cdomaine>\u002Fsrc\u002Fworkers\u002F\u003Cdomaine>.worker.ts`) :\n\n1. **Injecter le nouveau handler dans la `handlerMap`** :\n   ```typescript\n   @Injectable()\n   @Processor(EventsQueue.EVENTS)\n   export class EventWorker extends BaseWorker\u003CJobMessagingType> {\n     private readonly handlerMap: Map\u003CJobMessagingType, IJobHandler>;\n\n     constructor(\n       @Inject(JobAuditRepository) auditRepo: JobAuditRepository,\n       @Inject(PublishEventHandler) publishHandler: PublishEventHandler,\n       @Inject(SyncExternalCalendarHandler) syncHandler: SyncExternalCalendarHandler, \u002F\u002F ← Nouveau\n     ) {\n       super(auditRepo);\n       const handlers: IJobHandler[] = [publishHandler, syncHandler];\n       this.handlerMap = new Map(handlers.map((h) => [h.jobType, h]));\n     }\n\n     protected async processJob(job: JobOf\u003CJobMessagingType>) {\n       const handler = this.handlerMap.get(job.name as JobMessagingType);\n       if (!handler) {\n         throw new InternalServerError(`Unhandled job type: ${job.name}`, 'UNHANDLED_JOB_TYPE', { jobName: job.name });\n       }\n       return handler.handle(job);\n     }\n   }\n   ```\n\n2. **Déclarer le handler comme Provider** dans `app.module.ts` de ce worker.\n\n---\n\n## Étape 5 : La Boucle d'Audit et le Nettoyage Automatique\n> 📖 *Détails complets :* 04-audit-loop-and-cleanup.md ([`references\u002F04-audit-loop-and-cleanup.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F04-audit-loop-and-cleanup.md))\n\nVous n'avez **aucun code de nettoyage manuel à écrire** :\n1. `BaseWorker` passe le job en statut `PROCESSING` dans la table `job_audit` dès qu'il le prend en charge.\n2. Dès que le handler retourne son résultat, `BaseWorker` passe le statut à `COMPLETED` (ou `FAILED` en cas d'erreur).\n3. Le **Trigger SQL PostgreSQL** `job_audit_status_trigger` se déclenche automatiquement sur `job_audit` et insère un événement `\u003Cdomaine>:job:outbox:success` (ou `:failure`) dans la table `event_queue`.\n4. L'outbox-runner pousse cet événement dans Redis Stream.\n5. Un post-processor central écoute ce stream et exécute le **Hard Delete** de la ligne d'origine dans `jobs_outbox`.\n\n---\n\n## Faits vérifiés dans le code (2026-10-06)\n\n| Sujet | Réalité | Conséquence |\n| :--- | :--- | :--- |\n| `IJobHandler` | Interface **locale à chaque worker**, pas exportée par `@volontariapp\u002Fworkers` : `worker-user\u002Fsrc\u002Fhandlers\u002Finterfaces\u002F`, `worker-post` et `worker-social\u002Fsrc\u002Fworkers\u002Fjob-handler.interface.ts` | Importer celle du worker concerné. Un handler vit sous `src\u002Fhandlers\u002F` dans `worker-user`, sous `src\u002Fworkers\u002Fhandlers\u002F` ailleurs. |\n| `withFallback` | Dupliqué dans chaque `ms-*` (`BaseCommandController`), aucun job sur une erreur 400\u002F404\u002F409, sinon job `FALLBACK_*` vers la queue `FALLBACK_*` puis `FALLBACK_ACTIVATED`[^base-cmd] | Les queries n'ont pas de fallback. Le payload est `{ userId, payload }`, posé avec un `@ts-expect-error` existant : dette à ne pas recopier. Piège : un `ForbiddenError` (403) crée un job puis renvoie 206 dans tout `ms-*` dont `withFallback` ne l'exclut pas (corrigé dans `ms-event`, vérifié par test ; non vérifié dans les autres `ms-*`). Un job rejoué doit re-vérifier les droits : `ms-event` passe `extraPayload` à `withFallback` (ex. `userRole` dans `IFallbackDeleteEventJobPayload`). |\n| Statut de `jobs_outbox` | `PROCESSING` au pull, `COMPLETED` dès le push BullMQ[^dispatcher] | La ligne reste en base jusqu'au `DELETE` de `JobOutboxSuccessPostProcessor`[^pp-success]. `COMPLETED` ne veut pas dire « exécuté ». |\n| Échec d'un job | Le trigger émet `\u003Cdomaine>:job:outbox:failure`, `JobOutboxFailedPostProcessor` n'accepte que `:job:outbox:failed`, et son `UPDATE ... WHERE status = PENDING` ne toucherait pas une ligne déjà `COMPLETED`[^pp-failed] | **Bug P4** : aucun feedback d'échec de job sur la plateforme. Ne pas s'appuyer sur ce chemin. |\n| Idempotence | `BaseWorker` saute un job déjà `COMPLETED` dans `job_audit`[^base-worker] | Un rejeu BullMQ d'un job terminé est sans effet ; un job interrompu en `PROCESSING` est rejoué. |\n| Workers existants | `throw new Error` et cast `as Promise\u003C...>` dans `processJob` | Dette : utiliser `InternalServerError` (`@volontariapp\u002Ferrors`) comme dans l'exemple ci-dessus. |\n| Handlers sans producteur | 8 handlers (`FALLBACK_GET_*`, `FALLBACK_SIGN_UP`) n'ont aucun producteur ; 5 jobs (`PUBLISH_EVENT`, `SEND_WELCOME_EMAIL`...) ne sont produits que par `ms-user\u002F...\u002Fcontrollers\u002Ftests\u002Fuser.test.controller.ts` | Vérifier avec le catalogue avant de supposer qu'un job tourne en production. |\n| `worker-ranking` (2026-10-28) | Trois workers `BaseWorker` dans `src\u002Fworkers\u002F` : `ranking.worker.ts` (`RankingQueue.RANKING`, handlers `ComputeUserRankingHandler` et `FanoutRankingHandler`), `ranking-feed.worker.ts` (`RankingQueue.FEED`, `ComputeUserFeedHandler`) et `fallback-ranking.worker.ts` (`RankingQueue.FALLBACK_RANKING`), chacun avec `concurrency: config.ranking.workerConcurrency`. Handlers sous `src\u002Fworkers\u002Fhandlers\u002F`, types de job dans `handlers\u002Ftypes\u002F`. | Plusieurs queues pour un même domaine : un worker par queue, pas une `handlerMap` unique. |\n| Producteurs de jobs ranking (2026-10-28) | `job_catalogue.py` affiche « aucun » producteur pour `COMPUTE_USER_RANKING`, `COMPUTE_USER_FEED` et `FANOUT_RANKING`, alors qu'ils sont écrits dans `jobs_outbox` : le type vient d'une table `[RankingListKind] -> JobMessagingType` (`ms-ranking\u002F...\u002Franking-job.constants.ts`, `post-processor-ranking\u002Fsrc\u002Fdomain\u002Fconstants\u002Franking-job.constants.ts`, `worker-ranking\u002Fsrc\u002Fdomain\u002Fconstants\u002Franking-job.constants.ts`) et `FANOUT_RANKING` par `post-processor-ranking\u002Fsrc\u002Fdomain\u002Franking-job-writer.ts:85`. | Le script ne résout pas un type de job indirect (R3 d'AGENTS.md) : « aucun producteur » du catalogue n'est pas une preuve d'absence pour ces jobs. |\n| Drapeau Jev (2026-10-28) | `RANKING_JEV_ENABLED` (`worker-ranking\u002Fsrc\u002Fconfig\u002Fhelpers\u002Fboolean-flag.helpers.ts`, `scoring\u002Fadapters\u002Fjev-ranking-scorer.ts`, disjoncteur `circuit-breaker.ts`) ; activé par défaut dans `deploy\u002Fapps\u002Fbase\u002Fms-ranking\u002Fdeployment-worker.yaml`. | Un handler peut dépendre d'un adaptateur externe derrière un drapeau et un disjoncteur : prévoir le repli structurel. |\n\nCatalogue à jour, calculé depuis le code[^catalogue] :\n\n```bash\npython3 .agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Fscripts\u002Fjob_catalogue.py            # tous les jobs\npython3 .agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Fscripts\u002Fjob_catalogue.py --orphans  # sans producteur ou sans handler\n```\n\n## Contexte de trace des jobs\n\n`JobsOutboxEntity.createJob` capture le contexte OpenTelemetry actif dans `traceparent` ; `JobsOutboxPusher` le place dans l'enveloppe BullMQ (`JobEnvelope.traceparent?`, absent sans span actif), en push unitaire et en bulk. Les handlers lisent `JobEnvelope`, pas la ligne outbox. `BaseWorker.process` exécute le job dans un span `job \u003Ctype>` enfant de ce `traceparent` (`runInTraceContext`) : un handler n'a rien à faire pour s'y rattacher. Un écrivain qui reconstruit lui-même la ligne outbox (`RankingJobWriter` dans `worker-ranking`) doit recopier `traceparent` du job source (`traceparent: job.traceparent`, workers-runners#45), sinon la trace est coupée au job suivant. Détails : skill `volontariapp-trace-async-flow`.\n\n## Règles d'Or & Checklist Anti-Bugs\n\n- [ ] **1 Job = 1 Exécution :** Si vous avez besoin de notifier plusieurs services, créez un **Event** (`EventQueueEntity`), pas un Job.\n- [ ] **Toujours retourner `{ originalPayload: job.data.payload }`** à la fin du handler pour que `BaseWorker` puisse auditer l'exécution.\n- [ ] **Pas d'appel Redis direct :** Toujours créer le job via `JobsOutboxEntity.createJob()` dans la transaction PostgreSQL.\n- [ ] **Idempotence :** Un worker peut redémarrer en cours de traitement ; concevez le handler pour qu'il puisse être rejoué sans corrompre les données.\n\n[^base-cmd]: BaseCommandController.withFallback (ms-event)\n[^dispatcher]: OutboxDispatcher : COMPLETED après le push\n[^pp-success]: JobOutboxSuccessPostProcessor\n[^pp-failed]: JobOutboxFailedPostProcessor\n[^base-worker]: BaseWorker (garde d'idempotence job_audit)\n[^catalogue]: job_catalogue.py\n","# Guide : Implémenter un Job Asynchrone End-to-End\n\nDans Volontariapp, un **Job** représente une opération asynchrone **1 : 1** (garantie qu'un seul worker l'exécutera). Il passe par le **Transactional Outbox**, une file **BullMQ**, un **Worker Runner** (`BaseWorker` + `IJobHandler`), puis est audité via un **Trigger SQL PostgreSQL** avant son nettoyage automatique.\n\n---\n\n## Vue d'Ensemble du Cycle de Vie d'un Job\n\n```\n[1. Contrat SSOT]      npm-packages\u002Fpackages\u002Fmessaging (JobMessagingType, JobRegistry, Queues)\n         ↓\n[2. Émetteur]          ms-* \u002F domain-* (JobsOutboxEntity.createJob dans jobs_outbox)\n         ↓\n[3. Démon Outbox]      outbox-* runner (Pousse vers BullMQ Queue Redis)\n         ↓\n[4. Worker Runner]     workers-runners\u002Fworker-* (BaseWorker + IJobHandler)\n         ↓\n[5. Table job_audit]   Worker met à jour le statut (PROCESSING -> COMPLETED \u002F FAILED)\n         ↓\n[6. SQL Trigger DB]    Déclencheur auto sur job_audit -> insère dans event_queue\n         ↓\n[7. Post-Processor]    Nettoyage automatique (Hard Delete de jobs_outbox)\n```\n\n---\n\n> [!TIP]\n> **Guides Approfondis (Deep-Dive) :**\n> Si vous êtes bloqué sur une étape précise ou avez besoin d'exemples de code exhaustifs, consultez les fiches dédiées dans `references\u002F` :\n> - 01-job-messaging-contracts.md ([`references\u002F01-job-messaging-contracts.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F01-job-messaging-contracts.md)) (Enums, Registres, Queues, Payloads)\n> - 02-emitting-jobs-and-fallbacks.md ([`references\u002F02-emitting-jobs-and-fallbacks.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F02-emitting-jobs-and-fallbacks.md)) (Jobs classiques vs `withFallback` en controller)\n> - 03-worker-and-handlers.md ([`references\u002F03-worker-and-handlers.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F03-worker-and-handlers.md)) (BaseWorker, IJobHandler, Idempotence)\n> - 04-audit-loop-and-cleanup.md ([`references\u002F04-audit-loop-and-cleanup.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F04-audit-loop-and-cleanup.md)) (SQL Trigger, job_audit, nettoyage automatique)\n\n---\n\n## Étape 1 : Définir le Job dans `npm-packages\u002Fpackages\u002Fmessaging` (SSOT)\n> 📖 *Détails complets :* 01-job-messaging-contracts.md ([`references\u002F01-job-messaging-contracts.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F01-job-messaging-contracts.md))\n\n1. **Ajouter la constante de Job** dans `packages\u002Fmessaging\u002Fsrc\u002Fjobs\u002F\u003Cdomaine>\u002Fpayloads.ts` :\n   ```typescript\n   export const EventsJobType = {\n     \u002F\u002F ...\n     SYNC_EXTERNAL_CALENDAR: 'event.sync_external_calendar',\n   } as const;\n   ```\n\n2. **Définir l'interface du Payload** dans le même fichier :\n   ```typescript\n   export interface ISyncExternalCalendarPayload {\n     eventId: string;\n     calendarUrl: string;\n     syncRequestedBy: string;\n   }\n   ```\n\n3. **Enregistrer dans `JobRegistry`** (`packages\u002Fmessaging\u002Fsrc\u002Fjobs\u002Findex.ts`) :\n   ```typescript\n   export interface JobRegistry {\n     \u002F\u002F ...\n     [JobMessagingType.SYNC_EXTERNAL_CALENDAR]: ISyncExternalCalendarPayload;\n   }\n   ```\n\n4. **Vérifier ou définir la Queue BullMQ** (`packages\u002Fmessaging\u002Fsrc\u002Fjobs\u002F\u003Cdomaine>\u002Fqueue.ts`) :\n   ```typescript\n   export enum EventsQueue {\n     EVENTS = 'events-queue',\n     FALLBACK_EVENTS = 'fallback-events-queue',\n   }\n   ```\n\n> [!CAUTION]\n> ### 🛑 POINT DE BLOCAGE CRITIQUE : LE STOP IMMÉDIAT\n> **Tu viens de modifier `npm-packages\u002Fpackages\u002Fmessaging` ? TU DOIS T'ARRÊTER.**\n> 1. Valide la compilation locale dans `npm-packages` : `yarn build && yarn test`.\n> 2. Génère le changeset et le bump : `yarn changeset add` puis `yarn changeset version` (une seule fois par branche).\n> 3. **STOP TOTAL :** Interdiction formelle de passer à l'Étape 2 (`ms-*`) ou l'Étape 3 (`workers-runners`) immédiatement !\n> 4. Passe la main au Lead Dev pour qu'il pousse sur une PR et que la CI publie la version snapshot `\u003Cversion>-snap-\u003Csha court>`, publiée sous le dist-tag `next` et listée en commentaire de la PR (ex: `@volontariapp\u002Fmessaging@2.17.1-snap-a1b2c3d`).\n> 5. **Ce n'est qu'après publication par la CI** que tu pourras lancer `yarn up @volontariapp\u002Fmessaging` dans les microservices et continuer les étapes ci-dessous.\n\n---\n\n## Étape 2 : Émettre le Job en Base (`ms-*` ou `domain-*`)\n> 📖 *Détails complets :* 02-emitting-jobs-and-fallbacks.md ([`references\u002F02-emitting-jobs-and-fallbacks.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F02-emitting-jobs-and-fallbacks.md))\n\nLe job est inséré dans la table `jobs_outbox` lors de la transaction PostgreSQL.\n\n### Option A : Job Standard (Opération de fond planifiée)\n```typescript\nimport { JobsOutboxEntity, JobsOutboxModel } from '@volontariapp\u002Fdatabase';\nimport { JobsOutboxRepository } from '@volontariapp\u002Foutbox';\nimport { EventsQueue, JobMessagingType } from '@volontariapp\u002Fmessaging';\n\nconst job = JobsOutboxEntity.createJob\u003Ctypeof JobMessagingType.SYNC_EXTERNAL_CALENDAR>({\n  type: JobMessagingType.SYNC_EXTERNAL_CALENDAR,\n  emitter: 'ms-event',\n  emitterId: userId,\n  scheduledAt: new Date(),\n  target: EventsQueue.EVENTS,\n  payload: {\n    eventId,\n    calendarUrl,\n    syncRequestedBy: userId,\n  },\n});\n\nconst jobsRepo = new JobsOutboxRepository(queryRunner.manager.getRepository(JobsOutboxModel));\nawait jobsRepo.create(job);\n```\n\n### Option B : Fallback Job (En cas d'erreur dans un Command Controller)\nDans les contrôleurs héritant de `BaseCommandController` :\n```typescript\nreturn await this.withFallback(\n  JobMessagingType.FALLBACK_SYNC_CALENDAR,\n  userId,\n  { eventId, calendarUrl },\n  async () => {\n    return await this.calendarService.sync(eventId);\n  },\n);\n```\n\n> [!NOTE]\n> **Le Runner Outbox est 100% transparent :** `outbox-runners\u002Foutbox-\u003Cdomaine>` scrute `jobs_outbox` avec `SELECT ... FOR UPDATE SKIP LOCKED` et pousse le job dans la queue BullMQ correspondante sur Redis. Aucun code supplémentaire n'est nécessaire dans l'outbox runner !\n\n---\n\n## Étape 3 : Créer le Handler Unitaire dans `workers-runners`\n> 📖 *Détails complets :* 03-worker-and-handlers.md ([`references\u002F03-worker-and-handlers.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F03-worker-and-handlers.md))\n\nEmplacement : `workers-runners\u002Fworker-\u003Cdomaine>\u002Fsrc\u002Fworkers\u002Fhandlers\u002F\u003Cnom-du-job>.handler.ts`.\n\nChaque job possède sa propre classe implémentant `IJobHandler` :\n\n```typescript\nimport { Injectable } from '@nestjs\u002Fcommon';\nimport { Logger } from '@volontariapp\u002Flogger';\nimport { JobMessagingType, JobRegistry } from '@volontariapp\u002Fmessaging';\nimport type { JobOf } from '@volontariapp\u002Fworkers';\nimport type { IJobHandler } from '.\u002Finterfaces\u002Fjob-handler.interface.js';\n\n@Injectable()\nexport class SyncExternalCalendarHandler implements IJobHandler\u003Ctypeof JobMessagingType.SYNC_EXTERNAL_CALENDAR> {\n  private readonly logger = new Logger({ context: SyncExternalCalendarHandler.name });\n  \n  readonly jobType = JobMessagingType.SYNC_EXTERNAL_CALENDAR;\n\n  async handle(job: JobOf\u003Ctypeof JobMessagingType.SYNC_EXTERNAL_CALENDAR>): Promise\u003C{\n    originalPayload: JobRegistry[typeof JobMessagingType.SYNC_EXTERNAL_CALENDAR];\n  }> {\n    const { eventId, calendarUrl } = job.data.payload;\n    this.logger.info(`Syncing external calendar for event ${eventId}`, { calendarUrl });\n\n    \u002F\u002F Exécution du traitement lourd (ex: parsing iCal, appel réseau)\n    await this.performSync(eventId, calendarUrl);\n\n    this.logger.info(`Calendar synced successfully for ${eventId}`);\n    \n    \u002F\u002F Renvoyer obligatoirement le payload d'origine pour l'enregistrement d'audit\n    return { originalPayload: job.data.payload };\n  }\n\n  private async performSync(eventId: string, url: string): Promise\u003Cvoid> {\n    \u002F\u002F Logique métier\n  }\n}\n```\n\n---\n\n## Étape 4 : Raccorder le Handler au Worker NestJS\n\nDans le Worker principal du domaine (`workers-runners\u002Fworker-\u003Cdomaine>\u002Fsrc\u002Fworkers\u002F\u003Cdomaine>.worker.ts`) :\n\n1. **Injecter le nouveau handler dans la `handlerMap`** :\n   ```typescript\n   @Injectable()\n   @Processor(EventsQueue.EVENTS)\n   export class EventWorker extends BaseWorker\u003CJobMessagingType> {\n     private readonly handlerMap: Map\u003CJobMessagingType, IJobHandler>;\n\n     constructor(\n       @Inject(JobAuditRepository) auditRepo: JobAuditRepository,\n       @Inject(PublishEventHandler) publishHandler: PublishEventHandler,\n       @Inject(SyncExternalCalendarHandler) syncHandler: SyncExternalCalendarHandler, \u002F\u002F ← Nouveau\n     ) {\n       super(auditRepo);\n       const handlers: IJobHandler[] = [publishHandler, syncHandler];\n       this.handlerMap = new Map(handlers.map((h) => [h.jobType, h]));\n     }\n\n     protected async processJob(job: JobOf\u003CJobMessagingType>) {\n       const handler = this.handlerMap.get(job.name as JobMessagingType);\n       if (!handler) {\n         throw new InternalServerError(`Unhandled job type: ${job.name}`, 'UNHANDLED_JOB_TYPE', { jobName: job.name });\n       }\n       return handler.handle(job);\n     }\n   }\n   ```\n\n2. **Déclarer le handler comme Provider** dans `app.module.ts` de ce worker.\n\n---\n\n## Étape 5 : La Boucle d'Audit et le Nettoyage Automatique\n> 📖 *Détails complets :* 04-audit-loop-and-cleanup.md ([`references\u002F04-audit-loop-and-cleanup.md`](\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F04-audit-loop-and-cleanup.md))\n\nVous n'avez **aucun code de nettoyage manuel à écrire** :\n1. `BaseWorker` passe le job en statut `PROCESSING` dans la table `job_audit` dès qu'il le prend en charge.\n2. Dès que le handler retourne son résultat, `BaseWorker` passe le statut à `COMPLETED` (ou `FAILED` en cas d'erreur).\n3. Le **Trigger SQL PostgreSQL** `job_audit_status_trigger` se déclenche automatiquement sur `job_audit` et insère un événement `\u003Cdomaine>:job:outbox:success` (ou `:failure`) dans la table `event_queue`.\n4. L'outbox-runner pousse cet événement dans Redis Stream.\n5. Un post-processor central écoute ce stream et exécute le **Hard Delete** de la ligne d'origine dans `jobs_outbox`.\n\n---\n\n## Faits vérifiés dans le code (2026-10-06)\n\n| Sujet | Réalité | Conséquence |\n| :--- | :--- | :--- |\n| `IJobHandler` | Interface **locale à chaque worker**, pas exportée par `@volontariapp\u002Fworkers` : `worker-user\u002Fsrc\u002Fhandlers\u002Finterfaces\u002F`, `worker-post` et `worker-social\u002Fsrc\u002Fworkers\u002Fjob-handler.interface.ts` | Importer celle du worker concerné. Un handler vit sous `src\u002Fhandlers\u002F` dans `worker-user`, sous `src\u002Fworkers\u002Fhandlers\u002F` ailleurs. |\n| `withFallback` | Dupliqué dans chaque `ms-*` (`BaseCommandController`), aucun job sur une erreur 400\u002F404\u002F409, sinon job `FALLBACK_*` vers la queue `FALLBACK_*` puis `FALLBACK_ACTIVATED`[^base-cmd] | Les queries n'ont pas de fallback. Le payload est `{ userId, payload }`, posé avec un `@ts-expect-error` existant : dette à ne pas recopier. Piège : un `ForbiddenError` (403) crée un job puis renvoie 206 dans tout `ms-*` dont `withFallback` ne l'exclut pas (corrigé dans `ms-event`, vérifié par test ; non vérifié dans les autres `ms-*`). Un job rejoué doit re-vérifier les droits : `ms-event` passe `extraPayload` à `withFallback` (ex. `userRole` dans `IFallbackDeleteEventJobPayload`). |\n| Statut de `jobs_outbox` | `PROCESSING` au pull, `COMPLETED` dès le push BullMQ[^dispatcher] | La ligne reste en base jusqu'au `DELETE` de `JobOutboxSuccessPostProcessor`[^pp-success]. `COMPLETED` ne veut pas dire « exécuté ». |\n| Échec d'un job | Le trigger émet `\u003Cdomaine>:job:outbox:failure`, `JobOutboxFailedPostProcessor` n'accepte que `:job:outbox:failed`, et son `UPDATE ... WHERE status = PENDING` ne toucherait pas une ligne déjà `COMPLETED`[^pp-failed] | **Bug P4** : aucun feedback d'échec de job sur la plateforme. Ne pas s'appuyer sur ce chemin. |\n| Idempotence | `BaseWorker` saute un job déjà `COMPLETED` dans `job_audit`[^base-worker] | Un rejeu BullMQ d'un job terminé est sans effet ; un job interrompu en `PROCESSING` est rejoué. |\n| Workers existants | `throw new Error` et cast `as Promise\u003C...>` dans `processJob` | Dette : utiliser `InternalServerError` (`@volontariapp\u002Ferrors`) comme dans l'exemple ci-dessus. |\n| Handlers sans producteur | 8 handlers (`FALLBACK_GET_*`, `FALLBACK_SIGN_UP`) n'ont aucun producteur ; 5 jobs (`PUBLISH_EVENT`, `SEND_WELCOME_EMAIL`...) ne sont produits que par `ms-user\u002F...\u002Fcontrollers\u002Ftests\u002Fuser.test.controller.ts` | Vérifier avec le catalogue avant de supposer qu'un job tourne en production. |\n| `worker-ranking` (2026-10-28) | Trois workers `BaseWorker` dans `src\u002Fworkers\u002F` : `ranking.worker.ts` (`RankingQueue.RANKING`, handlers `ComputeUserRankingHandler` et `FanoutRankingHandler`), `ranking-feed.worker.ts` (`RankingQueue.FEED`, `ComputeUserFeedHandler`) et `fallback-ranking.worker.ts` (`RankingQueue.FALLBACK_RANKING`), chacun avec `concurrency: config.ranking.workerConcurrency`. Handlers sous `src\u002Fworkers\u002Fhandlers\u002F`, types de job dans `handlers\u002Ftypes\u002F`. | Plusieurs queues pour un même domaine : un worker par queue, pas une `handlerMap` unique. |\n| Producteurs de jobs ranking (2026-10-28) | `job_catalogue.py` affiche « aucun » producteur pour `COMPUTE_USER_RANKING`, `COMPUTE_USER_FEED` et `FANOUT_RANKING`, alors qu'ils sont écrits dans `jobs_outbox` : le type vient d'une table `[RankingListKind] -> JobMessagingType` (`ms-ranking\u002F...\u002Franking-job.constants.ts`, `post-processor-ranking\u002Fsrc\u002Fdomain\u002Fconstants\u002Franking-job.constants.ts`, `worker-ranking\u002Fsrc\u002Fdomain\u002Fconstants\u002Franking-job.constants.ts`) et `FANOUT_RANKING` par `post-processor-ranking\u002Fsrc\u002Fdomain\u002Franking-job-writer.ts:85`. | Le script ne résout pas un type de job indirect (R3 d'AGENTS.md) : « aucun producteur » du catalogue n'est pas une preuve d'absence pour ces jobs. |\n| Drapeau Jev (2026-10-28) | `RANKING_JEV_ENABLED` (`worker-ranking\u002Fsrc\u002Fconfig\u002Fhelpers\u002Fboolean-flag.helpers.ts`, `scoring\u002Fadapters\u002Fjev-ranking-scorer.ts`, disjoncteur `circuit-breaker.ts`) ; activé par défaut dans `deploy\u002Fapps\u002Fbase\u002Fms-ranking\u002Fdeployment-worker.yaml`. | Un handler peut dépendre d'un adaptateur externe derrière un drapeau et un disjoncteur : prévoir le repli structurel. |\n\nCatalogue à jour, calculé depuis le code[^catalogue] :\n\n```bash\npython3 .agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Fscripts\u002Fjob_catalogue.py            # tous les jobs\npython3 .agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Fscripts\u002Fjob_catalogue.py --orphans  # sans producteur ou sans handler\n```\n\n## Contexte de trace des jobs\n\n`JobsOutboxEntity.createJob` capture le contexte OpenTelemetry actif dans `traceparent` ; `JobsOutboxPusher` le place dans l'enveloppe BullMQ (`JobEnvelope.traceparent?`, absent sans span actif), en push unitaire et en bulk. Les handlers lisent `JobEnvelope`, pas la ligne outbox. `BaseWorker.process` exécute le job dans un span `job \u003Ctype>` enfant de ce `traceparent` (`runInTraceContext`) : un handler n'a rien à faire pour s'y rattacher. Un écrivain qui reconstruit lui-même la ligne outbox (`RankingJobWriter` dans `worker-ranking`) doit recopier `traceparent` du job source (`traceparent: job.traceparent`, workers-runners#45), sinon la trace est coupée au job suivant. Détails : skill `volontariapp-trace-async-flow`.\n\n## Règles d'Or & Checklist Anti-Bugs\n\n- [ ] **1 Job = 1 Exécution :** Si vous avez besoin de notifier plusieurs services, créez un **Event** (`EventQueueEntity`), pas un Job.\n- [ ] **Toujours retourner `{ originalPayload: job.data.payload }`** à la fin du handler pour que `BaseWorker` puisse auditer l'exécution.\n- [ ] **Pas d'appel Redis direct :** Toujours créer le job via `JobsOutboxEntity.createJob()` dans la transaction PostgreSQL.\n- [ ] **Idempotence :** Un worker peut redémarrer en cours de traitement ; concevez le handler pour qu'il puisse être rejoué sans corrompre les données.\n\n[^base-cmd]: BaseCommandController.withFallback (ms-event)\n[^dispatcher]: OutboxDispatcher : COMPLETED après le push\n[^pp-success]: JobOutboxSuccessPostProcessor\n[^pp-failed]: JobOutboxFailedPostProcessor\n[^base-worker]: BaseWorker (garde d'idempotence job_audit)\n[^catalogue]: job_catalogue.py\n",{"name":5,"tags":44,"type":49,"paths":50,"title":54,"status":55,"sources":56,"verified":90,"generated":108,"mesh_keys":111,"description":7},[22,45,46,47,48],"jobs","bullmq","outbox","workers","Agent Skill",[51,52,53],"npm-packages\u002Fpackages\u002Fmessaging\u002Fsrc\u002Fjobs\u002F**","npm-packages\u002Fpackages\u002Fworkers\u002Fsrc\u002F**","workers-runners\u002F*\u002Fsrc\u002F**","Implement Async Job Flow","stable",[57,60,63,67,71,74,78,82,86],{"id":45,"title":58,"resource":59},"Contrats de jobs","npm-packages\u002Fpackages\u002Fmessaging\u002Fsrc\u002Fjobs",{"id":48,"title":61,"resource":62},"@volontariapp\u002Fworkers","npm-packages\u002Fpackages\u002Fworkers\u002Fsrc",{"id":64,"title":65,"resource":66},"c3-async","C3 Async Patterns and Flows","docs\u002Farchitecture\u002FC3-Async-Patterns-And-Flows.md",{"id":68,"title":69,"resource":70},"base-cmd","BaseCommandController.withFallback (ms-event)","ms-event\u002Fsrc\u002Fmodules\u002Fevent\u002Fcontrollers\u002Fcommands\u002Fbase.command.controller.ts",{"id":23,"title":72,"resource":73},"OutboxDispatcher : COMPLETED après le push","npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002Fdispatchers\u002Foutbox.dispatcher.ts",{"id":75,"title":76,"resource":77},"pp-success","JobOutboxSuccessPostProcessor","npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcommon\u002Fjob-outbox-success.post-processor.ts",{"id":79,"title":80,"resource":81},"pp-failed","JobOutboxFailedPostProcessor","npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcommon\u002Fjob-outbox-failed.post-processor.ts",{"id":83,"title":84,"resource":85},"base-worker","BaseWorker (garde d'idempotence job_audit)","npm-packages\u002Fpackages\u002Fworkers\u002Fsrc\u002Fcore\u002Fbase.worker.ts",{"id":87,"title":88,"resource":89},"catalogue","job_catalogue.py",".agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Fscripts\u002Fjob_catalogue.py",[91,95,99,102,105],{"at":92,"by":93,"digest":94},"2026-10-30T09:05:05Z","claude-code\u002Fclaude-sonnet-5-5","978e78b31657a44d",{"at":96,"by":97,"digest":98},"2026-10-30T11:33:19Z","claude-code\u002Fagent","00c291e87f9873ad",{"at":100,"by":97,"digest":101},"2026-10-30T12:24:35Z","dc8bb6efca7844fe",{"at":103,"by":97,"digest":104},"2026-11-01T16:23:07Z","f6acbaac1164ff2e",{"at":106,"by":97,"digest":107},"2026-11-08T12:02:23Z","732d459afafa5938",{"at":109,"by":110},"2026-10-06T08:22:12Z","claude-code\u002Fclaude-opus-5-5",[112,113,114,115,116,117,118,119],"workers-runners","outbox-runners","messaging\u002Fsrc\u002Fjobs","fallback_","jobtype","jobmessagingtype","ijobhandler","baseworker","main","6b8f03b57018fa66da4666071dc17c1577ff45e8",".agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta","https:\u002F\u002Fgithub.com\u002FVolontariapp","Volontariapp — Application Web\u002FMobile scalabelle construite sur une architecture microservices événementielle (Event-Driven Architecture), implémentant le Trans","2026-11-10 23:40:22",{"source":8,"skillName":5,"commands":128,"prompt":138,"zipAvailable":139},[129,132,135],{"tool":130,"command":131},"npx","npx skills add Volontariapp\u002Fmeta --skill volontariapp-implement-async-job-flow",{"tool":133,"command":134},"bunx","bunx skills add Volontariapp\u002Fmeta --skill volontariapp-implement-async-job-flow",{"tool":136,"command":137},"pnpm","pnpm dlx skills add Volontariapp\u002Fmeta --skill volontariapp-implement-async-job-flow","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002FSKILL.md",true,[141,147,151,155,159,163,167,171],{"path":142,"resourceType":143,"resourceTypeDesc":144,"sizeBytes":145,"sourceHtmlUrl":146},"index.md","REFERENCE","参考文档",1579,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Findex.md",{"path":148,"resourceType":143,"resourceTypeDesc":144,"sizeBytes":149,"sourceHtmlUrl":150},"log.md",6707,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Flog.md",{"path":152,"resourceType":143,"resourceTypeDesc":144,"sizeBytes":153,"sourceHtmlUrl":154},"references\u002F01-job-messaging-contracts.md",2875,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F01-job-messaging-contracts.md",{"path":156,"resourceType":143,"resourceTypeDesc":144,"sizeBytes":157,"sourceHtmlUrl":158},"references\u002F02-emitting-jobs-and-fallbacks.md",4083,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F02-emitting-jobs-and-fallbacks.md",{"path":160,"resourceType":143,"resourceTypeDesc":144,"sizeBytes":161,"sourceHtmlUrl":162},"references\u002F03-worker-and-handlers.md",5115,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F03-worker-and-handlers.md",{"path":164,"resourceType":143,"resourceTypeDesc":144,"sizeBytes":165,"sourceHtmlUrl":166},"references\u002F04-audit-loop-and-cleanup.md",6245,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002F04-audit-loop-and-cleanup.md",{"path":168,"resourceType":143,"resourceTypeDesc":144,"sizeBytes":169,"sourceHtmlUrl":170},"references\u002Flessons.md",6484,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Freferences\u002Flessons.md",{"path":172,"resourceType":173,"resourceTypeDesc":174,"sizeBytes":175,"sourceHtmlUrl":176},"scripts\u002Fjob_catalogue.py","SCRIPT","脚本",2522,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-job-flow\u002Fscripts\u002Fjob_catalogue.py",[178,201,230,257,274,294],{"skillNo":179,"name":180,"nameZh":6,"description":181,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":182,"categories":183,"keywords":184,"publishedAt":39},"01M6CSW3Y5ZHVBF5BX7S5BAVS0","volontariapp-user-account-deletion","Suppression de compte en deux temps (PENDING_DELETION): requestDeletionWithUserDeleted, finalizeDeletion, fan-out de user.deleted vers les domaines et gather ws. A utiliser pour tout changement de domain-user touchant la suppression, le statut ou le cycle de vie d'un user.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-user-account-deletion\u002FSKILL.md",[],[185,186,187,188,189,190,191,192,193,33,194,195,196,197,198,199,200,26,28],"volontariapp user account deletion","user","account deletion","deletion","cycle","gather","sonnet","pending","de","claude-code","reference","npm","user-account-deletion","src","en","domain",{"skillNo":202,"name":203,"nameZh":6,"description":204,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":205,"categories":206,"keywords":212,"publishedAt":39},"01M6CSVC1ZW30DDFZ8VMEYAAK6","volontariapp-proto-contract-evolution","Modifier un contrat Protobuf de proto-registry (champ, message, enum, RPC) sans casser la compatibilité wire, puis suivre la cascade proto-registry, PR automatique dans npm-packages, publication, consommateurs. À utiliser avant toute édition d'un .proto.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-proto-contract-evolution\u002FSKILL.md",[207],{"categoryNo":208,"categoryName":209,"taxonomyType":210,"taxonomyTypeDesc":211},"01JBSEEDCAT000000000000037","律师与法务","PROFESSION","适用职业",[213,214,215,216,33,217,218,28,219,220,221,222,223,196,224,225,226,227,228,229],"evolution","volontariapp proto contract evolution","grpc","lint","workflows","analyze","rpc","ci","pr","protobuf","message","registry","sans","sync","yaml","playbook","github",{"skillNo":231,"name":232,"nameZh":6,"description":233,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":234,"categories":235,"keywords":241,"publishedAt":39},"01M6CSV99ET2Z9W4W4DAK4CMQP","volontariapp-meta-scripts","Utiliser et faire evoluer la CLI Node de meta (setup, doctor, migrate, certs, repos, bump) et le manifeste repos.json, source unique des repos et unites du workspace.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-meta-scripts\u002FSKILL.md",[236],{"categoryNo":237,"categoryName":238,"taxonomyType":239,"taxonomyTypeDesc":240},"01JBSEEDCAT000000000000023","Skill与智能体开发","SCENARIO","使用场景",[242,243,244,33,245,194,246,247,248,249,250,251,252,253,254,255,256],"Meta","volontariapp meta scripts","repos","migrate","setup","meta-scripts","json","node","workspace","shell","bump","cli","doctor","draft","install",{"skillNo":258,"name":259,"nameZh":6,"description":260,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":261,"categories":262,"keywords":266,"publishedAt":39},"01M6CSTPHNTGE0WF5CPE8W7ZYQ","volontariapp-implement-async-event-flow","Guide pas-à-pas pour concevoir et implémenter un flux asynchrone complet (messaging -> outbox -> post-processors -> ws-service -> client).","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-event-flow\u002FSKILL.md",[263],{"categoryNo":264,"categoryName":265,"taxonomyType":239,"taxonomyTypeDesc":240},"01JBSEEDCAT000000000000009","前端开发",[22,19,267,21,268,36,198,269,270,224,271,25,26,272,47,28,29,32,33,273],"volontariapp implement async event flow","event","mapping","flux","streams","shared","runner",{"skillNo":275,"name":276,"nameZh":6,"description":277,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":278,"categories":279,"keywords":283,"publishedAt":39},"01M6CSRQHS0T9955M8CTA5BYJQ","research","Investigate a question against high-trust primary sources and capture the findings as a Markdown file in the repo. Use when the user wants a topic researched, docs or API facts gathered, or reading legwork delegated to a background agent.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fplugins\u002Fmatt-pocock\u002Fskills\u002Fresearch\u002FSKILL.md",[280],{"categoryNo":281,"categoryName":282,"taxonomyType":239,"taxonomyTypeDesc":240},"01JBSEEDCAT000000000000021","资料检索与研究",[276,284,186,285,286,287,288,289,290,291,292,293],"api","file","repo","markdown","docs","capture","use","question","trust","investigate",{"skillNo":295,"name":296,"nameZh":6,"description":297,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":298,"categories":299,"keywords":303,"publishedAt":39},"01M6CSRMZ82M1YK96Z4SW1STTD","prototype","Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fplugins\u002Fmatt-pocock\u002Fskills\u002Fprototype\u002FSKILL.md",[300],{"categoryNo":301,"categoryName":302,"taxonomyType":210,"taxonomyTypeDesc":211},"01JBSEEDCAT000000000000032","设计师",[296,304,186,305,290,306,291,307,308,309,310,311],"check","ui","look","design","explore","model","build","state"]