feat(queue): retention, delete en clear in /queue/messages #67

Merged
janpeter merged 12 commits from feat/queue-purge-parity into main 2026-07-17 06:38:24 +02:00
Owner

Maakt /queue/messages het enige queue-dashboard, zodat de Messages-pagina van Ops-dashboard eruit kan (volgende PR) zonder functieverlies.

Waarom dit moet

De berichtenqueue agent_message verhuist naar de scrum4me-DB. Ops-dashboard schrijft erop via zijn eigen Prisma-client op zijn eigen database — na de verhuizing zou die stil naar een verlaten tabel blijven schrijven: je stuurt een taak, hij verdwijnt, geen foutmelding. Prisma kan geen twee databases in één client, dus meeverhuizen kan niet. Die feature gaat er dus uit, en deze repo moet eerst alles kunnen.

Geen van beide dashboards was een superset. Ops kon delete, clear, previewPurge en purgeOlderThan; deze repo kon reply en max2/jp adresseren (Ops kent alleen mac/scrum4me-server × claude/codex). Onderweg bleken er nog twee gaten, die er ook in zitten.

Wat er in zit

Geporteerd uit Ops (semantiek 1-op-1, harde delete, archief ongemoeid): previewPurgeOlderThan, purgeOlderThan, delete, clear.

Twee pariteitsgaten gedicht (2f8ef68):

  • cancel verscheen alleen bij pending, terwijl Ops hem toont bij pending óf claimed — de actie ondersteunde het al, de UI liep achter. Zonder fix verlies je "annuleer een geclaimd verzoek".
  • de cancel-SQL miste Ops' type-guard, waardoor je een reply kon cancellen naar een status die Ops juist blokkeert.

Eén bewuste structurele afwijking: de bron dupliceert de retention-CTE in preview én purge. Dat is een drift-val — wijzigt iemand er één, dan telt de dry-run iets anders dan de purge verwijdert, precies wanneer je hem nodig hebt. Hier is het één gedeelde CLOSED_THREAD_TARGETS_CTE; divergeren kan niet meer.

Wat de reviews opleverden

Vier dingen die de code beter maakten dan een 1-op-1-port:

De delete-knop faalde op de meeste rijen waar hij stond. Een verzoek mét replies is niet te verwijderen — reply_link_matches_type blokkeert de SET NULL die de FK wil doen. En omdat done --reply de normale flow is, hébben de meeste gesloten threads een reply. Ops gooit daar vandaag een rauwe Postgres-fout; hier vertaalt deleteAgentMessage 23514 op die constraint naar "Dit verzoek heeft antwoorden; ruim de hele thread op met de purge."

clear bevestigde op proza terwijl purge op cijfers bevestigde — precies verkeerd om, want clear sloopt óók pending/claimed, dus lopend agent-werk. Nu toont de dialoog rows, open verzoeken en ongelezen antwoorden apart. Gesplitst omdat het twee verschillende verliezen zijn: openstaande verzoeken zijn agent-werk dat sneuvelt, ongelezen antwoorden staan nog ongeackt in iemands inbox. Eén getal dat ze optelt zegt geen van beide.

De open-telling zat er 4× naast. Replies dragen default status='pending', dus COUNT(*) FILTER (WHERE status IN ('pending','claimed')) telde ze mee: de dialoog zei "4 openstaande verzoeken" waar er één liep. Erger nog: de test asserteerde open: 4 mét een comment die de replies als verwacht opsomde — de guard codeerde de bug. Veertien rode mutaties vonden het daarom niet; een mutatietest bewijst dat de code doet wat de test zegt, niet dat de test iets waars zegt.

De bevestigingsdialoog draait nu op Base UI's alert-dialog, die ongebruikt in package.json zat: focus-trap, Escape, aria-labelledby/aria-describedby. Backdrop-click krijg je bewust niet — Base UI sluit pointer-dismissal structureel uit op de alert-variant (Omit<DialogRoot.Props, 'modal' | 'disablePointerDismissal' | …>), want dat is het WAI-ARIA-patroon: een keuze die rijen definitief verwijdert hoor je te beantwoorden, niet per ongeluk weg te klikken.

Bewijs

De mocks bewijzen de structuur van de queries. Wat een mock niet kan checken: of WITH RECURSIVE + een data-modifying CTE (DELETE … RETURNING) in één statement geldig is én de juiste rijen raakt. Daarom staat de echte run nu in de repo als describe.skipIf(!process.env.OPS_TEST_DATABASE_URL) (patroon uit __tests__/mcp-tester/schema-snapshot.test.ts), tegen een fixture die een met diff geverifieerde letterlijke kopie van Ops' migratie is — inclusief FK en alle vier de CHECKs.

Dat committen betaalde zich meteen terug: met het volledige schema blijkt type='result' + in_reply_to IS NULL illegaal. Een eerdere validatie draaide op een DDL die uit het Prisma-model was afgeleid — en Prisma-modellen dragen geen CHECKs — en testte dus een "losse reply-wortel" die niet kan bestaan. Die val staat nu als eerste test in het bestand.

Uitkomsten: deletedRows === targetRows (de dry-run liegt niet), geneste replies gaan mee als thread, open verzoeken blijven, done zónder finished_at blijft, de <-grens klopt (3-dagen-oude wortel valt bij 1d wél en bij 7d niet), tweede purge is een no-op, clear overleeft de self-FK.

De real-DB-test heeft een guard die de database-naam op wegwerpbaarheid controleert vóór hij iets aanraakt — URL-parsing, geen string-compare, dus ?sslmode=require omzeilt hem niet.

Verificatie

  • npx vitest run711 passed | 10 skipped (nulmeting op main: 665 | 2). Mét OPS_TEST_DATABASE_URL → 8 extra passed.
  • npm run typecheck → 0 errors
  • npm run lint → 0 errors (1 pre-existing warning in components/jobs/job-card.tsx, niet aangeraakt)
  • Rood-bewijs: 22 mutaties over de rondes, telkens op de implementatie gemuteerd, niet op de asserties. Een test zonder aantoonbaar rood bewijst niets.

Volgorde

Deze PR moet gemerged zijn vóór de volgende (Ops-dashboard opruimen) — anders verdwijnt de retention. Daarna pas de data-cutover.

🤖 Generated with Claude Code

Maakt `/queue/messages` het enige queue-dashboard, zodat de Messages-pagina van **Ops-dashboard** eruit kan (volgende PR) zonder functieverlies. ## Waarom dit moet De berichtenqueue `agent_message` verhuist naar de scrum4me-DB. Ops-dashboard schrijft erop via zijn **eigen Prisma-client op zijn eigen database** — na de verhuizing zou die stil naar een verlaten tabel blijven schrijven: je stuurt een taak, hij verdwijnt, geen foutmelding. Prisma kan geen twee databases in één client, dus meeverhuizen kan niet. Die feature gaat er dus uit, en deze repo moet eerst alles kunnen. **Geen van beide dashboards was een superset.** Ops kon `delete`, `clear`, `previewPurge` en `purgeOlderThan`; deze repo kon `reply` en `max2`/`jp` adresseren (Ops kent alleen `mac`/`scrum4me-server` × `claude`/`codex`). Onderweg bleken er nog twee gaten, die er ook in zitten. ## Wat er in zit **Geporteerd uit Ops** (semantiek 1-op-1, harde delete, archief ongemoeid): `previewPurgeOlderThan`, `purgeOlderThan`, `delete`, `clear`. **Twee pariteitsgaten gedicht** (`2f8ef68`): - cancel verscheen alleen bij `pending`, terwijl Ops hem toont bij `pending` óf `claimed` — de actie ondersteunde het al, de UI liep achter. Zonder fix verlies je "annuleer een geclaimd verzoek". - de cancel-SQL miste Ops' type-guard, waardoor je een **reply** kon cancellen naar een status die Ops juist blokkeert. **Eén bewuste structurele afwijking:** de bron dupliceert de retention-CTE in preview én purge. Dat is een drift-val — wijzigt iemand er één, dan telt de dry-run iets anders dan de purge verwijdert, precies wanneer je hem nodig hebt. Hier is het één gedeelde `CLOSED_THREAD_TARGETS_CTE`; divergeren kan niet meer. ## Wat de reviews opleverden Vier dingen die de code beter maakten dan een 1-op-1-port: **De delete-knop faalde op de meeste rijen waar hij stond.** Een verzoek mét replies is niet te verwijderen — `reply_link_matches_type` blokkeert de `SET NULL` die de FK wil doen. En omdat `done --reply` de normale flow is, hébben de meeste gesloten threads een reply. Ops gooit daar vandaag een rauwe Postgres-fout; hier vertaalt `deleteAgentMessage` `23514` op die constraint naar "Dit verzoek heeft antwoorden; ruim de hele thread op met de purge." **`clear` bevestigde op proza terwijl `purge` op cijfers bevestigde** — precies verkeerd om, want clear sloopt óók `pending`/`claimed`, dus lopend agent-werk. Nu toont de dialoog `rows`, `open verzoeken` en `ongelezen antwoorden` apart. Gesplitst omdat het twee verschillende verliezen zijn: openstaande verzoeken zijn agent-werk dat sneuvelt, ongelezen antwoorden staan nog ongeackt in iemands inbox. Eén getal dat ze optelt zegt geen van beide. **De open-telling zat er 4× naast.** Replies dragen default `status='pending'`, dus `COUNT(*) FILTER (WHERE status IN ('pending','claimed'))` telde ze mee: de dialoog zei "4 openstaande verzoeken" waar er **één** liep. Erger nog: de test asserteerde `open: 4` mét een comment die de replies als verwacht opsomde — de guard codeerde de bug. Veertien rode mutaties vonden het daarom niet; een mutatietest bewijst dat de code doet wat de test zegt, niet dat de test iets waars zegt. **De bevestigingsdialoog draait nu op Base UI's `alert-dialog`**, die ongebruikt in `package.json` zat: focus-trap, Escape, `aria-labelledby`/`aria-describedby`. Backdrop-click krijg je bewust niet — Base UI sluit pointer-dismissal structureel uit op de alert-variant (`Omit<DialogRoot.Props, 'modal' | 'disablePointerDismissal' | …>`), want dat is het WAI-ARIA-patroon: een keuze die rijen definitief verwijdert hoor je te beantwoorden, niet per ongeluk weg te klikken. ## Bewijs De mocks bewijzen de structuur van de queries. Wat een mock **niet** kan checken: of `WITH RECURSIVE` + een data-modifying CTE (`DELETE … RETURNING`) in één statement geldig is én de juiste rijen raakt. Daarom staat de echte run nu in de repo als `describe.skipIf(!process.env.OPS_TEST_DATABASE_URL)` (patroon uit `__tests__/mcp-tester/schema-snapshot.test.ts`), tegen een fixture die een met `diff` geverifieerde **letterlijke kopie** van Ops' migratie is — inclusief FK en alle vier de CHECKs. Dat committen betaalde zich meteen terug: met het volledige schema blijkt `type='result'` + `in_reply_to IS NULL` **illegaal**. Een eerdere validatie draaide op een DDL die uit het Prisma-model was afgeleid — en Prisma-modellen dragen geen CHECKs — en testte dus een "losse reply-wortel" die niet kan bestaan. Die val staat nu als eerste test in het bestand. Uitkomsten: `deletedRows === targetRows` (de dry-run liegt niet), geneste replies gaan mee als thread, open verzoeken blijven, `done` zónder `finished_at` blijft, de `<`-grens klopt (3-dagen-oude wortel valt bij 1d wél en bij 7d niet), tweede purge is een no-op, `clear` overleeft de self-FK. De real-DB-test heeft een guard die de database-naam op wegwerpbaarheid controleert vóór hij iets aanraakt — URL-parsing, geen string-compare, dus `?sslmode=require` omzeilt hem niet. ## Verificatie - `npx vitest run` → **711 passed | 10 skipped** (nulmeting op main: 665 | 2). Mét `OPS_TEST_DATABASE_URL` → 8 extra passed. - `npm run typecheck` → 0 errors - `npm run lint` → 0 errors (1 pre-existing warning in `components/jobs/job-card.tsx`, niet aangeraakt) - Rood-bewijs: 22 mutaties over de rondes, telkens op de **implementatie** gemuteerd, niet op de asserties. Een test zonder aantoonbaar rood bewijst niets. ## Volgorde Deze PR moet gemerged zijn vóór de volgende ([Ops-dashboard opruimen](https://git.jp-visser.nl/janpeter/Ops-dashboard)) — anders verdwijnt de retention. Daarna pas de data-cutover. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Porteert de vier ontbrekende queue-acties uit Ops-dashboard naar workers,
zodat workers het enige berichten-dashboard kan worden zonder functieverlies
zodra `agent_message` naar de scrum4me-DB verhuist.

Herschreven van `prisma.$queryRaw` naar de pool-vorm van `lib/queue/ops-db.ts`;
semantiek 1:1 met de bron (recursieve CTE over hele threads, harde delete,
archief blijft ongemoeid).

Eén bewuste afwijking: de bron heeft twee losse kopieën van de
`closed_requests`/`target_ids`-CTE — één in de preview, één in de purge. Dat
is een drift-val: wijzigt er één, dan telt de dry-run iets anders dan de purge
verwijdert, precies wanneer je op de dry-run leunt. Hier is het één gedeelde
`CLOSED_THREAD_TARGETS_CTE` waar beide op bouwen; de cutoff bindt in allebei
als `$1`, dus delen kost geen parameter-gymnastiek.

Preview en purge gooien nu ook bij een ontbrekende `OPS_DATABASE_URL` i.p.v.
0 te melden via de `[]`-teruggevende `query()`-helper — een dry-run die "0
rijen" zegt omdat de DB wegviel, is een leugen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Server-actions in de vorm van de bestaande vijf, elk achter
`requireWorkersAdmin()`. `retentionDays` komt binnen als `unknown` en gaat
door `parseRetentionDays` — een server-action is een HTTP-endpoint, dus de
waarde is onvertrouwd tot hij gevalideerd is.

UI neemt de preview-vóór-purge-flow van de bron over: eerst tellen, dan pas
bevestigen. De dialoog toont cutoff + closed_requests + target_rows uit de
dry-run, zodat je op cijfers bevestigt en niet op vertrouwen. Delete en clear
lopen door dezelfde bevestiging.

Clear krijgt hier wél een knop; Ops-dashboard had `clearQueue` alleen als
API-case zonder bediening. Zonder knop was de geporteerde actie onbereikbaar
en dus dood.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Volgt de mock-conventie van `__tests__/lib/worker-insights/ops-db.test.ts`
(`vi.mock('pg')`), dus geen echte DB.

Kern is de structurele garantie: `CLOSED_THREAD_TARGETS_CTE` moet letterlijk
in zowel de preview- als de purge-SQL zitten. Wie daar een eigen kopie
inlijnt en de twee laat divergeren, breekt de test — dat is precies de
drift-val die de bron nog open heeft staan.

Verder: preview bevat geen DELETE, purge wél; de cutoff bindt als parameter
en staat niet in de SQL-tekst; delete/clear raken alleen `agent_message`;
`parseRetentionDays` laat 1 en 7 door en gooit op 0/3/30/'abc'/undefined.

Elke case van de gooi-tabel heeft een expliciet label. Kaal `%j` noemde de
case `[1]` namelijk "gooit op 1", wat leest als de geldige waarde 1 — en `%p`
bestaat niet eens in vitest (jest-ism) en bleef letterlijk in de naam staan,
waardoor `-t` er niet op kon filteren.

Elke assertie is rood bewezen door de implementatie te muteren: gesplitste
CTE, DELETE in de preview, geinterpoleerde cutoff, WHERE-loze delete, clear
op de verkeerde tabel, uren i.p.v. dagen, en een CTE zonder recursie of
status-filter — 12 mutaties, allemaal rood.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Twee gaten die de Ops-pagina onverwijderbaar maakten: zonder deze fixes is
"geen functieverlies" aantoonbaar onwaar.

1. De cancel-knop verscheen alleen bij `pending`, terwijl Ops hem ook bij
   `claimed` toont — en `cancelAgentMessage` het allang ondersteunt
   (`status IN ('pending','claimed')`). De UI liep dus achter op zijn eigen
   backend, en juist een verzoek dat al bij een agent ligt wil je kunnen
   intrekken. Nu via `isCancellable`, dat de SQL-voorwaarde spiegelt.

2. De cancel-SQL miste Ops' type-guard. Replies krijgen bij insert de default
   `status='pending'`, dus workers kon een antwoord op `cancelled` zetten —
   een status die voor een reply niets betekent en die de s4m-queue CLI nooit
   produceert. Ops blokkeert dat; workers niet.

Beide zijn rood bewezen door de implementatie te muteren, niet de assertie.
De knopconditie krijgt een render-test: een test op alleen het predicaat zou
groen blijven als de knop `isCancellable` negeert, en dat is precies de
regressie die hier hoort te vallen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
De mock-tests bewijzen de structuur van de queries, niet dat `WITH RECURSIVE`
plus een data-modifying CTE geldig is en de juiste rijen raakt — dat kan een
mock principieel niet. Dat is eenmalig tegen een wegwerp-Postgres gedraaid;
het scenario en de uitkomst staan nu bij de tests die ze aanvullen, zodat het
bewijs niet met een scratchpad verdwijnt.

Niet gecommit als test: hardcoded poort + container-credentials zouden CI en
ieders lokale run breken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
De comment beweerde dat replies na een parent-delete blijven bestaan zonder
parent-verwijzing. Dat is onwaar, en het beschreef een toestand die per
constructie onmogelijk is.

De FK `in_reply_to` staat inderdaad op ON DELETE SET NULL, maar
`reply_link_matches_type` (CHECK: type IN ('result','data','reviewed') <=>
in_reply_to IS NOT NULL) verbiedt een reply zonder parent. Die SET NULL is dus
illegaal en Postgres weigert de héle delete — er verdwijnt niets. Nagemeten
tegen het volledige Ops-schema (FK + alle vier CHECKs):

  delete verzoek MET replies    -> ERROR reply_link_matches_type, 0 rijen weg
    CONTEXT: UPDATE ONLY agent_message SET in_reply_to = NULL WHERE ...
  delete verzoek ZONDER replies -> DELETE 1
  delete reply zelf             -> DELETE 1
  purge hele thread             -> 3 rijen weg
  clear met threads erin        -> 3 rijen weg

De laatste twee bevestigen meteen dat purge en clear niet op deze constraint
stuiten: die nemen wortel en replies in één statement mee, waardoor de SET
NULL alleen al-verwijderde rijen raakt en een no-op is.

Pariteit met de bron is intact en dat blijft de kern: Ops zet geen
`relationMode`, dus Prisma stuurt dezelfde kale DELETE naar dezelfde
constraint en krijgt dezelfde violation. Alleen de verklaring klopte niet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
De comment claimde een run "op een `agent_message` conform het Ops-schema".
Dat was te ruim: die DDL was uit het Prisma-model afgeleid en miste daardoor
alle vier de CHECKs — waaronder `reply_link_matches_type`, precies degene die
het delete-gedrag bepaalt. Een bewijs-comment die zijn eigen dekking
overdrijft leest als geruststelling en is daarmee schadelijker dan geen
comment.

Nu staat de gezaghebbende run erbij: die van de review, met FK plus alle vier
de CHECKs uit de migratie-SQL letterlijk gedraaid, met de echte geïmporteerde
functies. De eigen uitkomsten blijven staan — de reviewer kwam onafhankelijk
op hetzelfde uit — maar expliciet mét de kanttekening dat die run de CHECKs
niet had.

Plus de les die algemener is dan deze tabel: een testfixture uit een
Prisma-model afleiden geeft géén CHECKs, dus wie constraint-gedrag wil
bewijzen moet de migratie-SQL draaien. Anders is de verificatie zelf
onbewezen en plant de blinde vlek zich voort in alles wat erop leunt. Dat was
hier de val, niet de constraint.

En de reden waarom purge en clear er niet over struikelen terwijl een losse
delete dat wél doet: zij nemen wortel en replies in één statement mee, dus de
SET NULL raakt alleen al-verwijderde rijen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Twee gaten die de UI iets lieten beloven wat de DB weigert of verzwijgt.

1. `deleteAgentMessage` liet de rauwe `violates check constraint
   "reply_link_matches_type"` doorlekken. Dat geval is niet zeldzaam maar de
   norm: `done --reply` is de gewone flow, dus de meeste gesloten threads
   hébben een antwoord en zijn niet los te verwijderen. Nu een fout die zegt
   wat je wél moet doen: de purge. Alleen 23514 op die ene constraint wordt
   vertaald; andere DB-fouten gaan onaangeroerd door.

2. `countAgentMessages` voor de clear-dry-run. Het getal dat telt is `open`
   (pending/claimed): dat is wat een clear — anders dan de purge — aan lopend
   agent-werk sloopt, en dus wat je vóór het bevestigen wilt zien.

Plus: de comment op `deleteAgentMessage` verwees naar "de purge hieronder",
die hierbóven staat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
De dialog was een derde eigen modal-implementatie zonder Escape, zonder
focus-trap en zonder ARIA-bedrading. `@base-ui/react` zat al in de repo en
ship't een ongebruikte alert-dialog — precies de primitive hiervoor. Nu levert
die focus-trap, Escape, aria-labelledby (Title) en aria-describedby
(Description).

Bewust de alert- en niet de dialog-variant: Base UI laat op
`AlertDialog.Root` geen `modal`/`disablePointerDismissal` toe, dus klikken
naast de dialog sluit hem niet. Bij een keuze die rijen definitief verwijdert
is dat de bedoeling — die hoort beantwoord te worden, niet weggeklikt. Escape
en Annuleren zijn de uitgangen. (mcp-tester's gelijknamige dialog blijft
ongemoeid; consolideren is een aparte taak.)

De queue-specifieke copy is eruit: `warning` komt nu van de aanroeper, zodat
het component niets van de queue weet en de tekst per actie kan verschillen.

Verder:
- Clear bevestigt op cijfers (`rows=`, `waarvan open (pending/claimed)=`) en
  waarschuwt expliciet als er lopend werk sneuvelt. Het was de meest
  destructieve actie met de zwakste bevestiging: de purge (die alleen gesloten
  threads raakt) toonde exacte aantallen, clear alleen proza.
- Clear staat niet langer in de "Opruimen — gesloten threads ouder dan…"-box.
  Daar leest hij als "hetzelfde, maar dan alles", terwijl hij als enige
  openstaande verzoeken sloopt. Eigen blok, eigen styling, eerlijk label:
  "Leeg queue (ook openstaande verzoeken)".
- `runAction` wist ook de notice; een stale "12 berichten verwijderd" overleefde
  anders een volgende push of cancel.
- `aria-label="Verwijder bericht"` weg van de delete-knop: de zichtbare tekst
  hoort in de accessible name (WCAG 2.5.3). Overgenomen uit Ops, waar die knop
  icon-only is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
De vorige comment verwees naar een niet-gecommitte, niet-herhaalbare handeling
("gezaghebbend is de review-run"). Niets hield dat waar; over een maand staat
er stellig proza dat niemand kan narekenen. Nu draait het als test.

`describe.skipIf(!process.env.OPS_TEST_DATABASE_URL)`, conform
`__tests__/mcp-tester/schema-snapshot.test.ts`: zonder env-var skippen we en
blijft verify groen; mét env-var draait de echte selectie-semantiek.

Het schema komt uit `fixtures/agent_message.sql`, een geverifieerd letterlijke
kopie van Ops' migratie — niet uit het Prisma-model afgeleid. Dat was de val:
Prisma-modellen dragen geen CHECKs, dus mijn eerdere fixture miste er vier,
waaronder juist `reply_link_matches_type`. Meteen zichtbaar in wat nu wél kan:
de test bewijst dat een reply zonder parent niet eens te insert'en is — een
rij die in de oude fixture gewoon geldig leek, en die ik daar als scenario had
staan.

Guard: staat OPS_TEST_DATABASE_URL gelijk aan OPS_DATABASE_URL, dan gooit het
bestand. Deze test DROPt agent_message; daar stil-en-groen op skippen zou de
verkeerde kant op falen.

Preamble van `ops-db-retention.test.ts` teruggebracht tot wat er hoort: mocks
bewijzen structuur, niet uitkomst, en de gedeelde CTE is de garantie. Het
review-narratief is weg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`countAgentMessages` filterde `open` alleen op status, niet op type. Replies
dragen bij insert de default `status='pending'` — precies het feit waarvoor de
type-guard in `cancelAgentMessage` bestaat. Op onze eigen fixture nagemeten:
total=9, "open"=4, werkelijk lopende verzoeken=1. De bevestiging waarschuwde
dus voor 4× zoveel verlies als er was, en de doc-comment zei "verzoeken die
nog lopen" terwijl de SQL dat niet zei.

Elke andere open-verzoek-check hier paart status mét type (cancelAgentMessage,
isCancellable, CLOSED_THREAD_TARGETS_CTE, DetailPanel.canReply). Deze was de
enige die het niet deed, en de nieuwste. Uitgerekend het getal waarvoor de
dry-run bestond: een cijfer dat vertrouwd wordt en er 4× naast zit is erger
dan proza, want proza wordt niet geloofd.

Gesplitst in plaats van alleen gefilterd. Lopend werk en ongelezen post zijn
verschillende verliezen: `openRequests` is agent-werk dat sneuvelt,
`unreadReplies` is wat er nog in iemands inbox staat (niet geackt met
`s4m-queue done`). Eén getal dat ze optelt zegt geen van beide, en `rows` minus
`openRequests` zou de ongelezen antwoorden stilzwijgend wegmoffelen onder
"verder alleen afgesloten rijen". Nu:

  rows=142
  waarvan open verzoeken=7
  waarvan ongelezen antwoorden=12

De waarschuwing noemt alleen `lopend agent-werk` bij openstaande verzoeken.

De echte fout zat in de test. `ops-db-realdb.test.ts` asserteerde
`{ total: 9, open: 4 }` en somde in de comment de drie replies op als verwacht:
de test pinde de bug vast in plaats van hem te vangen. Daarom vonden de veertien
rode mutaties dit niet — een mutatietest bewijst dat de code doet wat de test
zegt, niet dat de test iets waars zegt. Nu `openRequests: 1, unreadReplies: 3`,
met de reden erbij.

Plus de wegwerp-guard: de vergelijking met OPS_DATABASE_URL is vacuüm in een
gewone `OPS_TEST_DATABASE_URL=… npx vitest run` (setup.ts laadt geen .env) en
was een rauwe string-compare, dus `?sslmode=require` liep eromheen. Nu eerst
een positieve bevestiging op de databasenaam via URL-parsing; de oude check
blijft als tweede linie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(queue): repareer een flaky cutoff-assertie
Some checks failed
CI / Verify (pull_request) Failing after 2m23s
289233a5dd
De volledige suite viel één keer om vlak na de mutatie-run; vier runs erna
waren groen. Niet als ruis weggezet maar uitgezocht, en het was mijn eigen
test.

`bindt de cutoff als parameter` deed:

  const before = Date.now()
  const { cutoff } = await countClosed…(1)
  expect(before - cutoff.getTime()).toBeGreaterThanOrEqual(86_400_000)

`retentionCutoff` leest de klok zélf, ná `before`. Dus
`before - cutoff = DAG - (inner - before)`, en dat is alleen >= DAG als beide
Date.now()-calls in dezelfde milliseconde vallen. Eén tik ertussen en de
assertie valt. Deterministisch aangetoond met een gestubde klok: delta wordt
86399999. De test slaagde dus op geluk, en onder de load van de mutatie-run
was dat geluk op.

Nu een exacte bracket: de functie leest de klok ergens tussen `before` en
`after`, dus de cutoff ligt tussen `before - dag` en `after - dag`. Geen
wall-clock-marge meer (die 10s was ook maar een gok).

Vangt nog steeds waarvoor hij bestaat — geverifieerd tegen de mutaties "uren
i.p.v. dagen" en "cutoff geïnterpoleerd i.p.v. gebonden", allebei rood — en
20/20 stabiel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
s4m-codex-reviewer left a comment

Verdict: REQUEST_CHANGES

geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden.

Findings

  • ERRORlib/queue/ops-db.ts:475clearAgentMessageQueue() gebruikt DELETE FROM agent_message op een tabel met een self-referential FK ON DELETE SET NULL plus de CHECK reply_link_matches_type. De patch erkent elders dat een parent-delete met replies daardoor faalt, omdat de FK-actie replies tijdelijk parentloos maakt. Een volledige queue met threads bevat precies zulke parent/reply-relaties; daardoor kan “Leeg queue” falen zodra er replies bestaan, terwijl de UI dit als gegarandeerde destructive clear aanbiedt. Gebruik hier een aanpak die de self-FK/CHECK niet triggert, bijvoorbeeld eerst tellen en daarna TRUNCATE agent_message in een transactie, of een bewezen child-first strategie die ook voor geneste replies werkt.

Opmerkingen

De server-actions behouden de admin-guard en de UI gebruikt een expliciete confirmation flow met dry-run cijfers. De retentie-selectie en cancel-typeguard zijn goed afgedekt met unit-tests; de blokkade zit specifiek in de clear-implementatie rond databaseconstraints.

# Verdict: REQUEST_CHANGES geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. ## Findings - **ERROR** — `lib/queue/ops-db.ts:475` — `clearAgentMessageQueue()` gebruikt `DELETE FROM agent_message` op een tabel met een self-referential FK `ON DELETE SET NULL` plus de CHECK `reply_link_matches_type`. De patch erkent elders dat een parent-delete met replies daardoor faalt, omdat de FK-actie replies tijdelijk parentloos maakt. Een volledige queue met threads bevat precies zulke parent/reply-relaties; daardoor kan “Leeg queue” falen zodra er replies bestaan, terwijl de UI dit als gegarandeerde destructive clear aanbiedt. Gebruik hier een aanpak die de self-FK/CHECK niet triggert, bijvoorbeeld eerst tellen en daarna `TRUNCATE agent_message` in een transactie, of een bewezen child-first strategie die ook voor geneste replies werkt. ## Opmerkingen De server-actions behouden de admin-guard en de UI gebruikt een expliciete confirmation flow met dry-run cijfers. De retentie-selectie en cancel-typeguard zijn goed afgedekt met unit-tests; de blokkade zit specifiek in de clear-implementatie rond databaseconstraints.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
janpeter/scrum4me-workers!67
No description provided.