feat(FN-5284): surface db corruption via health checks, self-healing, and d

- fix(FN-5284): add db corruption audit mutation type
- feat(FN-5284): complete Step 7 — document corruption surfacing
- feat(FN-5284): complete Step 6 — add db corruption banner
- feat(FN-5284): complete Step 5 — extend health payload
- feat(FN-5284): complete Step 3 — surface db corruption in self-healing
- feat(FN-5284): complete Step 2 — add db corruption notification event
- feat(FN-5284): complete Step 1 — expose integrity check errors

Fusion-Task-Id: FN-5284
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 04:13:43 -07:00
committed by gsxdsm
parent 12a6ee9af1
commit 1b49cbc94d
21 changed files with 712 additions and 11 deletions

View File

@@ -465,6 +465,8 @@ describe("Database", () => {
expect(dbB.integrityCheckLastRunAt).toBeTruthy();
expect(dbA.corruptionDetected).toBe(false);
expect(dbB.corruptionDetected).toBe(false);
expect(dbA.integrityCheckErrors).toEqual([]);
expect(dbB.integrityCheckErrors).toEqual([]);
} finally {
dbA.close();
dbB.close();
@@ -478,7 +480,7 @@ describe("Database", () => {
vi.useFakeTimers();
const integritySpy = vi.spyOn(Database.prototype, "integrityCheck").mockReturnValue({
ok: false,
errors: ["malformed database"],
errors: ["malformed database", "broken index"],
});
const freshDir = makeTmpDir();
const freshFusionDir = join(freshDir, ".fusion");
@@ -498,6 +500,8 @@ describe("Database", () => {
expect(dbB.integrityCheckLastRunAt).toBeTruthy();
expect(dbA.corruptionDetected).toBe(true);
expect(dbB.corruptionDetected).toBe(true);
expect(dbA.integrityCheckErrors).toEqual(["malformed database", "broken index"]);
expect(dbB.integrityCheckErrors).toEqual(["malformed database", "broken index"]);
} finally {
dbA.close();
dbB.close();
@@ -1036,6 +1040,7 @@ describe("Database", () => {
it("returns ok for healthy databases and leaves corruption flag false", () => {
expect(db.corruptionDetected).toBe(false);
expect(db.integrityCheck()).toEqual({ ok: true });
expect(db.integrityCheckErrors).toEqual([]);
});
it("keeps corruptionDetected false after init for healthy database", () => {

View File

@@ -16,6 +16,8 @@ describe("TaskStore.getDatabaseHealth", () => {
const health = store.getDatabaseHealth();
expect(health.healthy).toBe(true);
expect(health.corruptionDetected).toBe(false);
expect(health.corruptionErrors).toEqual([]);
expect(health.isRunning).toBe(false);
expect(health.lastCheckedAt).toBeNull();
});
@@ -27,19 +29,34 @@ describe("TaskStore.getDatabaseHealth", () => {
const health = store.getDatabaseHealth();
expect(health.healthy).toBe(true);
expect(health.corruptionDetected).toBe(false);
expect(health.corruptionErrors).toEqual([]);
expect(health.isRunning).toBe(true);
});
it("reports unhealthy when corruption has been detected", () => {
const db = store.getDatabase();
db.corruptionDetected = true;
db.integrityCheckErrors = ["bad row", "bad index"];
db.integrityCheckPending = false;
db.integrityCheckLastRunAt = "2026-05-11T12:34:56.000Z";
const health = store.getDatabaseHealth();
expect(health.healthy).toBe(false);
expect(health.corruptionDetected).toBe(true);
expect(health.corruptionErrors).toEqual(["bad row", "bad index"]);
expect(health.isRunning).toBe(false);
expect(health.lastCheckedAt?.toISOString()).toBe("2026-05-11T12:34:56.000Z");
});
it("caps corruption errors to the first five entries", () => {
const db = store.getDatabase();
db.corruptionDetected = true;
db.integrityCheckErrors = ["one", "two", "three", "four", "five", "six"];
const health = store.getDatabaseHealth();
expect(health.corruptionErrors).toEqual(["one", "two", "three", "four", "five"]);
});
});

View File

@@ -1251,6 +1251,7 @@ export class Database {
/** Returns the database file path (or ":memory:" for in-memory databases). */
get path(): string { return this.dbPath; }
corruptionDetected = false;
integrityCheckErrors: string[] = [];
integrityCheckPending = false;
integrityCheckLastRunAt: string | null = null;
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
@@ -3684,6 +3685,7 @@ export class Database {
participant.integrityCheckPending = false;
participant.integrityCheckLastRunAt = startedAt;
participant.corruptionDetected = !integrity.ok;
participant.integrityCheckErrors = integrity.ok ? [] : [...integrity.errors];
}
if (!integrity.ok) {

View File

@@ -9329,11 +9329,16 @@ ${stepsSection}`;
getDatabaseHealth(): {
healthy: boolean;
corruptionDetected: boolean;
corruptionErrors: string[];
lastCheckedAt: Date | null;
isRunning: boolean;
} {
const corruptionDetected = this.db.corruptionDetected;
return {
healthy: !this.db.corruptionDetected,
healthy: !corruptionDetected,
corruptionDetected,
corruptionErrors: this.db.integrityCheckErrors.slice(0, 5),
lastCheckedAt: this.db.integrityCheckLastRunAt ? new Date(this.db.integrityCheckLastRunAt) : null,
isRunning: this.db.integrityCheckPending,
};

View File

@@ -404,6 +404,7 @@ export type NtfyNotificationEvent =
| "planning-awaiting-input"
| "gridlock"
| "board-stall-unrecovered"
| "db-corruption-detected"
| "fallback-used"
| "memory-dreams-processed"
| "token-budget"
@@ -422,6 +423,7 @@ export const NOTIFICATION_EVENTS = [
"planning-awaiting-input",
"gridlock",
"board-stall-unrecovered",
"db-corruption-detected",
"fallback-used",
"memory-dreams-processed",
"token-budget",