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. Itembacklog/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 :
- qu'est-ce qui attend une action de ma part, maintenant ?
- qu'est-ce que les agents proposent ?
- 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
| Piece | Chemin | Role |
|---|---|---|
| Service | src/services/store-activity/ | la seule lecture (listStoreActivity) et toutes les ecritures |
| Bloc UI | src/components/patterns/store-activity/ | StoreActivityHub, monte par l'onglet Activite du footer de la scene et par la vue Insights (filtre kinds) |
| Outils agent | src/features/ai/tools/store-activity-tools.ts | listStoreActivity, 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
| Genre | Table | Remarque |
|---|---|---|
task | Task (+ TaskActivity pour le fil) | une proposition d'agent est une Task en WAITING_REVIEW dont plan.proposal nomme l'action en attente |
insight | StoreAlert dont source commence par agent: | une observation d'@Atlas sur les chiffres |
alert | StoreAlert ecrite par un detecteur | un defaut trouve par une sonde, un cron, une regle |
report | StoreReport | un audit (hebdo, post-publication) |
note | StoreNote | une 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. Unviewerlit 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 parcreateTask, qui l'est. - Chaque ecriture laisse un
AuditLog(store.activity.*), auteur = la personne, ousystempour une ecriture de machine (regle decron-audit-author.test.ts), et unTaskActivityquand une tache bouge, pour que le fil d'une tache raconte son histoire. - Aucun id n'est cru sur parole : chaque ecriture recoit le
storeIdET 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 footer | Onglets |
|---|---|
| Vues | Vue d'ensemble, Insights, Intelligence, Systems, Settings (routes) |
| Boutique | Activite, Setup, Store details (Profile, Intelligence, Market, Memory, Index), History |
| DevTools | Console, 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.
| Verbe | Qui | Porte d'autonomie | Effet |
|---|---|---|---|
editTask (renommer = { title }) | membre workspace.write, agent | oui | titre, description, priorite, echeance |
claimTask | soi-meme seulement | oui | assigne a soi, IN_PROGRESS ; refuse si tenue par un autre |
declineTask | agent, sur sa propre prise | non (rend seulement ce qu'il tenait) | desassigne, PENDING, commentaire avec la raison |
archiveTask | membre, agent | oui | plan.archivedFrom = statut d'avant, CANCELLED, archivedAt |
unarchiveTask | humain | non | restaure archivedFrom (sinon PENDING) |
deleteTask | humain : createur, ou owner / admin | non | suppression dure, TaskActivity en cascade, notes et annotations a null, audit avec le titre |
alertToTask / annotationToTask / planActionToTask | membre, agent | oui (sourceToTask) | tache liee a sa source, dedupee |
launchTask | humain | non | evenement 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,taskIddans le contexte d'outil et le ledger) ;backlog/platform-ops/3036:store-alerts-dispatchet le rapport hebdo ignorentsnoozedUntil;backlog/commerce-systems/3037: unStoreReportn'a pas de resume, l'Activite n'en montre que le type ;backlog/design-system/3038: le barrelpatterns/marketingtirenext/fontdans le graphe de tests.