ArchitectureActivite de la boutique : un service, un bloc, un jeu d'outils

Activite de la boutique : un service, un bloc, un jeu d'outils

Pilier app-shell. Item backlog/app-shell/3033. Audit d'origine : docs/audits/2026-09-26-store-activity-duplicates.md.

Pilier app-shell. Item backlog/app-shell/3033. Audit d'origine : docs/audits/2026-09-26-store-activity-duplicates.md.

La question a laquelle ce bloc repond

Trois questions d'un marchand, et d'@Atlas, sur une boutique :

  1. qu'est-ce qui attend une action de ma part, maintenant ?
  2. qu'est-ce que les agents proposent ?
  3. qu'est-ce qui s'est passe ?

Elles etaient reparties entre l'onglet Issues (alertes), l'onglet Work (taches, notes, rapports), la timeline d'anomalies de la vue Insights et le cycle proactif qui ecrivait derriere tout le monde. Il n'y a plus qu'une reponse : l'Activite.

Les trois pieces

PieceCheminRole
Servicesrc/services/store-activity/la seule lecture (listStoreActivity) et toutes les ecritures
Bloc UIsrc/components/patterns/store-activity/StoreActivityHub, monte par l'onglet Activite du footer de la scene et par la vue Insights (filtre kinds)
Outils agentsrc/features/ai/tools/store-activity-tools.tslistStoreActivity, createTask, updateTaskStatus, acknowledgeAlert, addNote

Les server actions du bloc (services/store-activity/actions.ts), la route POST /api/stores/[storeId]/tasks (le composer du chat), la file ~/me, les outils et le cycle proactif @Atlas appellent tous le MEME service. Aucun n'ecrit une de ces tables a cote.

Le modele : cinq genres, aucune table nouvelle

GenreTableRemarque
taskTask (+ TaskActivity pour le fil)une proposition d'agent est une Task en WAITING_REVIEW dont plan.proposal nomme l'action en attente
insightStoreAlert dont source commence par agent:une observation d'@Atlas sur les chiffres
alertStoreAlert ecrite par un detecteurun defaut trouve par une sonde, un cron, une regle
reportStoreReportun audit (hebdo, post-publication)
noteStoreNoteune note d'equipe, epinglable a une tache

Une seule colonne ajoutee : StoreAlert.snoozedUntil (optionnelle, donc sans risque pour le schema guard). « Pas maintenant » n'est ni acquitte ni resolu : l'alerte revient seule a l'echeance.

Pourquoi pas une table StoreActivity unique : les producteurs de StoreAlert (quatre detecteurs, le mailer store-alerts-dispatch, le rapport hebdo, l'historique) et de Task (~/me, le chat, le setup pack) dependent de leur table et de ses index ; les fusionner deplacerait chaque producteur pour un gain nul a la lecture, que le service normalise deja en un seul type (ActivityItem).

Les portes, decidees une fois

  • RBAC, cote serveur, a chaque appel (access.ts) : lire = workspace.read (owner, admin, member, viewer) ; ecrire = workspace.write (owner, admin, member) ; approuver une proposition = agent.approve_tools. Un viewer lit tout et n'ecrit rien. Un non-membre ne lit rien.
  • Un agent ne tient jamais plus que la personne qui l'a appele : dans un tour de chat, son RBAC est celui de ctx.userId (onBehalfOf). Seul le cron proactif agit sans personne derriere.
  • Porte d'autonomie : sous le niveau 3 (getEffectiveLevel), toute ecriture d'un agent (createTask, updateTaskStatus, alerte, note) devient une proposition en attente ; un membre l'approuve ou la refuse depuis le bloc, et l'action s'applique alors AU NOM de l'agent, une seule fois. Au niveau 3 elle s'applique directement. Les outils le disent au modele (status: "proposed"), qui ne doit pas annoncer « fait ».
  • Une observation n'est pas une action : upsertInsight (le cycle proactif qui note une baisse de revenu) n'est pas gate. Ce qu'il faut en faire passe par createTask, qui l'est.
  • Chaque ecriture laisse un AuditLog (store.activity.*), auteur = la personne, ou system pour une ecriture de machine (regle de cron-audit-author.test.ts), et un TaskActivity quand une tache bouge, pour que le fil d'une tache raconte son histoire.
  • Aucun id n'est cru sur parole : chaque ecriture recoit le storeId ET l'id de la ligne, et refuse une ligne d'une autre boutique.

Ce que le bloc montre

En tete, quatre chiffres (PanelMetricGrid) : a traiter, en attente d'approbation, taches ouvertes, alertes et insights ouverts. Puis un filtre (tout, a traiter, et un par genre), « inclure les elements clos », un compositeur (tache ou note) pour qui peut ecrire, et la liste, classee par ce qui attend quelqu'un (rank dans normalize.ts) : propositions, alertes critiques, travail en revue, alertes, taches, le reste.

Chaque ligne dit qui l'a creee et qui la tient (membres par leur nom, agents en @Nom, detecteurs en « Detecteur »), son statut, son echeance, son report, son blocage et son nombre de commentaires. Ouverte, elle s'affiche dans la colonne Details du centre quand l'hote en a une (details, app-shell/2763), en place sinon, avec : acquitter, reporter 24 h, resoudre, en faire une tache, demarrer, terminer, rouvrir, annuler, assigner, modifier, commenter, supprimer une note, approuver ou refuser une proposition, demander a @Atlas.

La scene, apres nettoyage

Famille du footerOnglets
VuesVue d'ensemble, Insights, Intelligence, Systems, Settings (routes)
BoutiqueActivite, Setup, Store details (Profile, Intelligence, Market, Memory, Index), History
DevToolsConsole, Network, Elements

Supprimes, parce qu'une autre surface de la scene repondait deja a la meme question : les onglets Issues et Work, la famille Health, l'onglet Sources, les onglets Algorithm, Metrics, Rules et Plans de l'inspecteur, la timeline d'anomalies de la vue Insights, les routes /api/stores/[storeId]/alerts*, features/notes et features/reports, et les ecritures de features/ai/tasks/actions.ts (il n'y reste que les lecteurs de ~/me, qui portent sur toute l'organisation).

Le hub unifie (lot 1, app-shell/3078)

Contrat partage : src/services/store-activity/hub-contract.ts (SOT store-pillars, task-sources, activity-hub-verbs), pur, importe par normalize.ts et par le hub client. origin / source d'un item disent toujours QUI l'a cree ; le nouveau champ from dit D'OU vient le travail.

Sixieme genre, annotation. Les notes de page (StoreAnnotation) entrent dans le flux (50 au plus). Rang 5, jamais dans « a traiter » : le badge du footer ne bouge pas. Verbes : resoudre, rouvrir, en faire une tache, editer, supprimer, filtres par les droits (rights, booleens calcules cote serveur).

Ce que « ouverte » veut dire, partout : status notIn [DONE, CANCELLED] et archivedAt: null. Chaque lecteur de Task filtre archivedAt: null ; seul scope: "archived" montre les archivees.

Garde des lignes de proposition. Editer, prendre, rendre, archiver, desarchiver et supprimer refusent une tache qui porte plan.proposal (sauf une proposition createTask / sourceToTask approuvee, devenue la tache elle-meme), et refusent un agent sur une tache WAITING_REVIEW. Un agent ne reecrit donc jamais sa propre proposition.

VerbeQuiPorte d'autonomieEffet
editTask (renommer = { title })membre workspace.write, agentouititre, description, priorite, echeance
claimTasksoi-meme seulementouiassigne a soi, IN_PROGRESS ; refuse si tenue par un autre
declineTaskagent, sur sa propre prisenon (rend seulement ce qu'il tenait)desassigne, PENDING, commentaire avec la raison
archiveTaskmembre, agentouiplan.archivedFrom = statut d'avant, CANCELLED, archivedAt
unarchiveTaskhumainnonrestaure archivedFrom (sinon PENDING)
deleteTaskhumain : createur, ou owner / adminnonsuppression dure, TaskActivity en cascade, notes et annotations a null, audit avec le titre
alertToTask / annotationToTask / planActionToTaskmembre, agentoui (sourceToTask)tache liee a sa source, dedupee
launchTaskhumainnonevenement launched, audit, prompt texte localise

Un agent n'a pas d'outil de suppression : son « supprimer » est archiveTask, par la porte. assignTask reste interdit aux agents.

Dedupe des taches sourcees. Transaction Serializable, verrou pg_advisory_xact_lock(hashtext(storeId:sourceKind:sourceId)), puis recherche d'une tache OUVERTE de meme source : elle est rendue (deduped: true) au lieu d'en creer une. Une source qui revient apres une tache DONE ou archivee ouvre une nouvelle tache, volontairement. Une action du plan est lue par loadStorePlan (un cache manque la recalcule : action potentiellement lente) ; un id disparu du plan repond stale.

decideProposal est un switch exhaustif (default: never). createTask et sourceToTask : la ligne de proposition devient la tache (PENDING), sourceToTask ecrit en plus le lien d'annotation. editTask, claimTask, archiveTask : la cible est relue, stale si elle a disparu, si updatedAt differe du baseUpdatedAt de la proposition ou si elle est devenue une proposition ; sinon re-application en agent niveau 3. Un echec annule la proposition (decision.error) et ne touche rien d'autre.

from est resolu a la lecture, chaque table source relue avec { id: { in }, storeId } : une source supprimee ou d'un autre store repond available: false.

launchTask (action serveur, humain) n'ecrit PAS conversationId : aucun tour n'est encore lie a la tache (3080). Il rend { prompt, conversationId: null } ; le client poste le prompt par boostecom:chat-prompt { text, autoSubmit: true } dans le chat monte. La langue est lue cote serveur (getLocale()), jamais envoyee.

Ledger. AgentAction.taskId (sans FK : le ledger est en ajout seul) n'est garde par emitAgentAction que si la tache appartient au store ; les outils passent l'id rendu par le service, jamais celui du modele.

Outils. listMyTasks (lecture, assignee resolu sur l'acteur), claimTask, declineTask, editTask, commentTask, archiveTask, alertToTask, annotationToTask, planActionToTask (tous safe_write). Les six agents ont les six premiers et annotationToTask ; @Atlas, @Otis et @Faye ont aussi archiveTask, alertToTask et planActionToTask.

Cycle proactif. Toujours pur, sans appel de modele (ADR 0034) : une proposition porte sourceKind: "insight" et l'id de la ligne d'insight que upsertInsight rend.

Les signaux des piliers (lot 2, commerce-systems/3081)

Les mesures planifiees des piliers ecrivent leurs degradations dans le hub par un seul ecrivain, emitPillarSignals (src/services/pillar-signals/emit.ts), avec StoreAlert.source = cron:signal:<source>. upsertSignalInsight est la regle de transition (pas de reouverture dans les 7 jours apres un geste humain, rien en snooze) ; upsertInsight reste inchange pour ses appelants. alertToTask prend { ownerAgent, forceProposal } et porte le signal (pillar, metricKey = kind, baseline.metricId, baseline.scope). Le fil accepte ActivityQuery.pillar, et loadPillarPanel sert les pages systems/*. Detail : pillar-signals.md.

Suites (backlog)

  • backlog/ai-platform/3040 : la liste de taches du composer du chat est une seconde lecture des taches ;
  • backlog/ai-platform/3080 : lier le tour de chat a la tache lancee (conversationId, taskId dans le contexte d'outil et le ledger) ;
  • backlog/platform-ops/3036 : store-alerts-dispatch et le rapport hebdo ignorent snoozedUntil ;
  • backlog/commerce-systems/3037 : un StoreReport n'a pas de resume, l'Activite n'en montre que le type ;
  • backlog/design-system/3038 : le barrel patterns/marketing tire next/font dans le graphe de tests.