import test from 'node:test'; import assert from 'node:assert/strict'; import { createRequestLifecycle } from '../src/request-lifecycle.mjs'; function recoveryFixture({ retained, target = 0, signalAt = null }) { const requests = Array.from({ length: retained }, (_, i) => ({ business: 'demo', request: `r${i}`, revision: 1, source_message: 'source', phase: i === target ? 'implementation' : 'cancelled', pm: 'pm', project: 'factory', wait: null, question: null, plan_digest: 'plan', selection: { bindings: [{ digest: 'plan', plan: { staff: { coder: 'coder', reviewer: 'reviewer', chief: 'chief' } } }] }, spec_commit: null, candidate: null, snapshot: 'snapshot', tasks: ['vikunja:2/1'], evidence: [], })); const receipts = [], messages = [], inspected = new Set(); let lifecycle, evidence = false, signalled = false; const journal = { listRequests: () => requests.map(({ business, request }) => ({ business, request })), readRequest(business, request) { inspected.add(request); if (!signalled && request === `r${signalAt}`) { signalled = true; evidence = true; lifecycle.recover({ limit: 64 }); } return structuredClone(requests.find(row => row.business === business && row.request === request)); }, effects: (business, request) => receipts.filter(row => row.business === business && row.request === request), transaction: fn => fn(), prepareEffect(row) { const old = receipts.find(r => r.business === row.business && r.request === row.request && r.op === row.op && r.stage === row.stage); if (old) return old; receipts.push(row); return row; }, finishEffect(row) { receipts.push(row); return row; }, }; const services = { origin() {}, authorize() {}, raiseDecision() {}, readDecision() {}, project() {}, continuationContext: (_, role) => ({ run: `${role}-live`, prerequisite: 'stable' }), continuationProgress: state => state.request === `r${target}` && evidence ? { phase: 'review', tasks: state.tasks, evidence: ['validated-push', 'validated-review'] } : null, continuationMessage(state, role, body) { const id = `m${messages.length}`; messages.push({ id, request: state.request, role, body }); return id; }, }; lifecycle = createRequestLifecycle({ journal, services }); return { lifecycle, inspected, wakes: () => messages.filter(m => m.request === `r${target}` && m.role === 'pm').length }; } for (const [retained, target, signalAt, prime] of [[128, 0, 1, false], [257, 64, 65, true]]) { test(`overlapping evidence revisits its request across bounded recovery batches (${retained} retained rows)`, async t => { const f = recoveryFixture({ retained, target, signalAt }); t.after(() => f.lifecycle.close()); if (prime) await f.lifecycle.recover({ limit: 64 }); await f.lifecycle.recover({ limit: 64 }); assert.equal(f.wakes(), 2, 'the joined evidence-triggered recovery wakes the live PM without a later timer sweep'); for (let i = 0; i < 6; i++) await f.lifecycle.recover({ limit: 64 }); assert.equal(f.wakes(), 2, 'unchanged evidence remains durably deduplicated'); }); } test('quiescent recovery keeps its per-call batch bound and advances the cursor', async t => { const f = recoveryFixture({ retained: 128 }); t.after(() => f.lifecycle.close()); await f.lifecycle.recover({ limit: 64 }); assert.equal(f.inspected.size, 64); await f.lifecycle.recover({ limit: 64 }); assert.equal(f.inspected.size, 128); assert.equal(f.wakes(), 1); });