ArchitectureDurable audit / scan jobs

Durable audit / scan jobs

Date : 2026-09-10. Pilier : ai-platform. Item : 0592. Prérequis UX : 0591 (preliminary __progress sur les tools chat).

Date : 2026-09-10. Pilier : ai-platform. Item : 0592. Prérequis UX : 0591 (preliminary __progress sur les tools chat).

1. Problème

Deux chemins longs existent aujourd'hui et partagent le même plafond :

SurfaceBudgetCe qui tourne
POST /api/chatmaxDuration = 300runFullStoreAudit / runTrackingScan inline dans le tour modèle
POST /api/tracking/scan~90–130 s côté toolorchestrateur 41 checks + Browserbase éventuel

Conséquences à l'échelle (centaines d'audits / min perçus comme vivants) :

  1. Coupe nette : la fonction meurt à 300 s même si le progress UI (0591) était honnête jusqu'ici.
  2. Pas de reprise : un refresh / network blip tue le travail ; 0566 (resume silencieux) n'a rien de durable à reprendre hors stream Upstash du tour chat.
  3. Pas d'idempotence produit : deux clics = deux scans qui se marchent dessus ; le ledger agent voit deux événements.
  4. OTP / cold path : lancer un audit pendant l'auth sans job = scan orphelin. Politique actuelle : stash prompt + ?initialPrompt= (0590), jamais QStash pendant OTP.

maxDuration = 300 reste le bon plafond pour un tour de chat. Ce n'est pas le plan d'échelle pour le travail d'audit.

2. Ce qui existe déjà (réutiliser, ne pas reinventer)

Le socle jobs est en production :

  • enqueue(type, payload, { deduplicationId, … }) — src/services/jobs/client.ts
  • Registry handlers — src/services/jobs/handlers/index.ts
  • Signature + worker /api/jobs/[type]
  • HEAVY_JOB_TYPES (aujourd'hui intelligence-scan-store) : refuse le fallback inline sur Vercel quand QStash manque
  • Quota / readiness : heavyJobQueueReadiness(), latch 429

Le Spy deep-scan (intelligence-scan-store) est le modèle le plus proche : minutes de travail, déduplication, pas d'inline en prod. Chat audit ≠ Spy pipeline, mais le transport est le même.

0591 a fixé le contrat UI :

  • preliminary yields __progress (kind: audit | scan, phase, %, steps)
  • une seule carte de run, AuditRunCard (barre ui/progress ponderee, etapes traduites, temps ecoule), puis le rapport en AuditCardView (ai-platform/3060)
  • les deux ouvrent sur le MEME en-tete (cards/audit-report-header.tsx : favicon + hote, titre, anneau) ; l'anneau trace la progression pendant le run puis balaie jusqu'au score quand le rapport le remplace. Le rapport : jauge + bande, piliers en barres (pire en tete, non mesure hachure), plan d'action numerote par horizon avec « Lancer » par etape et « Lancer le plan avec @Atlas », constats par pilier en accordeon. Toutes les regles d'ordre vivent dans cards/audit-card-model.ts, teste (ai-platform/3066)
  • Thinking caché dès qu'un tool tourne

Un job durable doit émettre le même shape (ou un miroir KV que la carte lit), pas inventer un second protocole UI.

3. Cible

Operator → @Atlas tool call
         → enqueue("store-audit" | "tracking-scan-durable", …)
         → yield __progress { phase: "queued", … }
         → QStash → /api/jobs/<type>
              → handler runFullStoreAudit / runTrackingScan
              → write progress to KV (jobId)
              → final report → Conversation / agent ledger / blob
         → chat resume (0566) OR tool poll / SSE replay
         → last yield = final report (même contrat 0591)

3.1 Job types (proposition)

JobTypePayload minimaldeduplicationId
store-audit{ orgId, userId, storeId?, url, conversationId, toolCallId }store-audit:${orgId}:${sha256(url)}:${day} ou …:${toolCallId} si force
tracking-scan-durable{ orgId, userId, storeId, url, conversationId, toolCallId }tracking-scan:${storeId}:${sha256(url)}:${hour}

Les deux entrent dans HEAVY_JOB_TYPES.

Idempotence : une clé par (org/store, cible, fenêtre). Un second enqueue dans la fenêtre renvoie le messageId / jobId existant ; le tool chat se rattache au progress existant au lieu de relancer.

3.2 Progress ledger (Upstash KV)

Clé : audit:progress:${jobId} (TTL 24 h).

Valeur : le même objet que AuditScanProgress (0591), plus { status: "queued" | "running" | "succeeded" | "failed", updatedAt }.

Le tool execute async generator :

  1. enqueue → yield queued
  2. poll KV (ou SSE interne) → yield progress
  3. terminal → yield rapport final (ou erreur typée)

Pas de second composant UI : message-tool-part + AuditRunCard (features/ai/chat/runtime/audit-run-part.tsx) restent la surface.

3.3 Concurrence

  • Cap par org (ex. 2 audits lourds simultanés) avant enqueue → 429 métier / message tool clair.
  • Cap global aligné sur le quota QStash (lire heavyJobQueueReadiness avant fan-out).
  • Browserbase / tracking : réutiliser les timeouts existants ; le job QStash timeout doit être ≥ le budget scan (130 s tool aujourd'hui), pas le défaut 3 retries trop courts sans retries / timeout explicites sur enqueue.

3.4 Crédits et auth

  • Estimation / hard cap crédits : avant enqueue (même garde que le chat), pas après coup sur le worker.
  • Cookie de session : ne pas rejouer le cookie dans QStash. Le worker authentifie par signature QStash + payload signé (orgId / userId / storeId déjà validés au moment du tool call). Le bridge Shopify du worker se reconstruit depuis le storeId (même pattern que les autres jobs store-scoped).

3.5 Ce qu'on ne fait pas

  • Audit / scan pendant OTP ou avant isTenantReady (0590).
  • Nouvelle famille de credentials (ADR 0007).
  • Remplacer le Spy intelligence-scan-store par ce job (pipelines distincts ; chat audit ≠ corpus Spy).
  • Gonfler maxDuration de /api/chat au-delà de 300 pour "tenir" les audits : ça déplace le problème.

4. Découpage d'implémentation (PRs suivantes)

PRContenuPilier
AJobType + handler no-op / log + tests registryai-platform (+ platform-ops si vercel.json / worker)
BProgress KV + tool generators branchés enqueueai-platform
CCaps org + readiness messagingai-platform
DResume chat (0566) sur jobIdai-platform

Chaque PR = un item backlog. Celle-ci (0592) ne contient que cette doc.

5. Critères "le monde en parle" à l'échelle

  1. 50 audits démarrés en 1 minute sur des orgs distinctes : tous queued → running sans 5xx chat.
  2. Double-clic même URL : un seul job, deux tool cards accrochées au même progress.
  3. Kill du tab navigateur à 50 % : retour conversation → reprise ou état terminal visible (pas de boîte noire).
  4. QStash quota exhausted : un message opérateur actionnable (pas N enqueue_failed silencieux), chat reste utilisable.

6. Références code

  • Chat budget : src/app/api/chat/route.ts (maxDuration = 300)
  • Progress UI : src/features/ai/store-audit/progress.ts, src/components/patterns/ai-elements/chat/audit-run-card.tsx (AuditRunCard)
  • Tools : src/features/ai/tools/store-audit-tool.ts, src/features/ai/tools/tracking-scan.ts
  • Jobs : src/services/jobs/client.ts, types.ts, handlers/index.ts
  • Heavy precedent : intelligence-scan-store + HEAVY_JOB_TYPES