diff --git a/src/brainy.ts b/src/brainy.ts index 5a600402..d5f2fd84 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -17549,10 +17549,50 @@ export class Brainy implements BrainyInterface { } else { await p.rebuild() } + } else if ( + report.invariants.some((i) => !i.holds && i.heal === 'repair') && + typeof (provider as { repair?: () => Promise }).repair === 'function' + ) { + // INCREMENTAL HEAL ROUTING (ADR-008 D4): a failing verdict whose heal + // is 'repair' routes to the provider's own repair() — O(missing), + // re-posting exactly what its ledger names, never a store-sized + // rebuild. The return shape is the provider's own; the RE-READ of the + // report is what decides success (the acceptance meta-pin's law: run + // the named heal once, re-read, nothing may still fail the same way). + const failingRepairs = report.invariants + .filter((i) => !i.holds && i.heal === 'repair') + .map((i) => i.name) + prodLog.warn( + `[Brainy] repairIndex(): provider '${report.provider}' asks for an incremental ` + + `repair (${failingRepairs.join(', ')}) — running its own repair().` + ) + await (provider as { repair: () => Promise }).repair() + let cleared = false + let after: ProviderInvariantReport | null = null + try { + after = await p.validateInvariants() + cleared = !after.invariants.some( + (i) => !i.holds && i.heal === 'repair' && failingRepairs.includes(i.name) + ) + } catch { + // The post-heal re-read failing is itself reportable, never a crash. + } + record(`provider:${report.provider}`, { + checked: true, + healed: cleared ? failingRepairs.length : 0, + detail: cleared + ? `incremental repair cleared: ${failingRepairs.join(', ')}` + : `repair() ran but the re-read still fails (${ + after + ? after.invariants.filter((i) => !i.holds).map((i) => `${i.name}→${i.heal}`).join(', ') + : 're-read threw' + }) — escalate to repairIndex({ rebuild: ['${familyName}'] })`, + reason: cleared ? undefined : 'repair did not converge' + }) } else { record(`provider:${report.provider}`, { checked: true, healed: 0, - detail: `unhealthy without a rebuild verdict (failing: ${report.invariants.filter((i) => !i.holds).map((i) => `${i.name}→${i.heal}`).join(', ')})` + detail: `unhealthy without a routable verdict (failing: ${report.invariants.filter((i) => !i.holds).map((i) => `${i.name}→${i.heal}`).join(', ')})` }) } } diff --git a/tests/integration/repair-report.test.ts b/tests/integration/repair-report.test.ts index 273eee2e..28e0ad99 100644 --- a/tests/integration/repair-report.test.ts +++ b/tests/integration/repair-report.test.ts @@ -68,4 +68,46 @@ describe('repairIndex per-family receipt', () => { expect(orphans!.healed, 'the ghost was pruned and receipted').toBeGreaterThan(0) expect(report.healedTotal).toBeGreaterThan(0) }, 120000) + + + it("a heal:'repair' verdict routes to the provider's own repair(), and the re-read decides", async () => { + // A fake provider report: one failing invariant asking for the INCREMENTAL + // heal. repairIndex must call repair() (never rebuild()) and count the heal + // only when the post-repair re-read clears the same verdict. + const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-route-')) + const brain: any = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true }) + await brain.init() + brains.push(brain) + let repairCalls = 0 + let rebuildCalls = 0 + let healed = false + const failing = { + provider: 'vector', healthy: false, serving: true, + invariants: [{ name: 'node-coverage', holds: false, detail: 'short 3', heal: 'repair' as const }], + checkedAt: 1, durationMs: 1 + } + const clean = { + provider: 'vector', healthy: true, serving: true, + invariants: [{ name: 'node-coverage', holds: true, detail: 'ok', heal: 'none' as const }], + checkedAt: 2, durationMs: 1 + } + ;(brain.index as any).validateInvariants = async () => (healed ? clean : failing) + ;(brain.index as any).repair = async () => { repairCalls++; healed = true; return { repaired: 3 } } + const origRebuild = (brain.index as any).rebuild + ;(brain.index as any).rebuild = async () => { rebuildCalls++ } + try { + const report = await brain.repairIndex() + const row = report.families.find((f: any) => f.family === 'provider:vector') + expect(row, 'the provider family is in the receipt').toBeDefined() + expect(repairCalls, 'repair() ran exactly once').toBe(1) + expect(rebuildCalls, "a heal:'repair' verdict never runs rebuild()").toBe(0) + expect(row!.healed, 'the cleared verdict counts as healed').toBe(1) + expect(String(row!.detail)).toMatch(/incremental repair cleared: node-coverage/) + } finally { + delete (brain.index as any).validateInvariants + delete (brain.index as any).repair + ;(brain.index as any).rebuild = origRebuild + } + }) + })