From 0246aa6d20d98e1245f75bf67a8a6c54c90e092a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 15:10:34 +0000 Subject: [PATCH 1/2] fix(graph): mark uninspected status instead of placeholder zeros When immutable inspection is skipped (sidecar/WAL, containment, or schema/invariant failures), GraphStatus now carries inspected: false. printStatus labels parse health, last index, and sources as not inspected so a stranded graph.db-wal no longer reads as an empty graph. Fixes #204 Co-authored-by: David --- CHANGELOG.md | 1 + src/drift/index.ts | 1 + src/graph/__tests__/cli-graph.test.ts | 87 ++++++++++++++++++++++++++- src/graph/__tests__/status.test.ts | 18 +++++- src/graph/cli-graph.ts | 53 +++++++++++++--- src/graph/status.ts | 38 +++++++----- src/team/contracts/graph.ts | 7 +++ 7 files changed, 178 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 989d8b3b..ddda230c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file. - Project Hub Overview now opens with a compact Context card above the atlas instead of a header button. It links to Context when the Wiki index is fresh and to Health otherwise, so a stale or unavailable index no longer leads to a page that cannot load. ### Fixed +- `mex graph status` no longer prints placeholder zeros when immutable inspection is skipped (for example a stranded `graph.db-wal`). Text marks parse health, last index, and sources as not inspected, and `--json` adds an additive `inspected: false` flag while keeping the existing zero `parseHealth`/`changes` objects (#204). - Agent population failures retain the real copyable manual prompt for retry or manual continuation. Integration pointer notes are visible as non-blocking guidance. - Setup and Overview share the computer's contact preference so completing or skipping the invitation does not immediately trigger another request. diff --git a/src/drift/index.ts b/src/drift/index.ts index dbaa8cda..f4db6704 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -396,6 +396,7 @@ function unavailableGraphStatus(message: string): GraphStatus { severity: "warning", message, }], + inspected: false, }; } diff --git a/src/graph/__tests__/cli-graph.test.ts b/src/graph/__tests__/cli-graph.test.ts index e899eed6..361d2218 100644 --- a/src/graph/__tests__/cli-graph.test.ts +++ b/src/graph/__tests__/cli-graph.test.ts @@ -2,9 +2,17 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { GraphSourceChanges } from "../../team/contracts/graph.js"; +import type { GraphParseHealth, GraphSourceChanges, GraphStatus } from "../../team/contracts/graph.js"; import { runGraphScope } from "../cli-agent.js"; -import { formatGraphSourceChanges, runGraph, runGraphRefresh, runGraphRebuild } from "../cli-graph.js"; +import { + formatGraphLastSuccessfulIndex, + formatGraphParseHealth, + formatGraphSourceChanges, + formatGraphStatusSources, + runGraph, + runGraphRefresh, + runGraphRebuild, +} from "../cli-graph.js"; function changes(overrides: Partial = {}): GraphSourceChanges { return { @@ -21,7 +29,82 @@ function changes(overrides: Partial = {}): GraphSourceChange }; } +function parseHealth(overrides: Partial = {}): GraphParseHealth { + return { + total: 0, + ok: 0, + partial: 0, + failed: 0, + failedPaths: [], + failedPathsTruncated: false, + ...overrides, + }; +} + +function status(overrides: Partial = {}): GraphStatus { + return { + status: "degraded", + observedAt: "2026-09-13T04:33:14.973Z", + currentRepo: { + branch: "main", + head: "df89810df4f5aaaaaaaaaaaaaaaaaaaaaaaaaaaa", + dirty: false, + observedAt: "2026-09-13T04:33:14.973Z", + }, + lastSuccessfulIndexAt: null, + indexedAt: null, + indexedBranch: null, + indexedHead: null, + schemaVersion: null, + extractorVersion: null, + grammarVersion: null, + parseHealth: parseHealth(), + changes: changes(), + diagnostics: [], + ...overrides, + }; +} + describe("graph CLI status formatting", () => { + it("does not print placeholder zeros when immutable inspection was skipped", () => { + const skipped = status({ + inspected: false, + diagnostics: [{ + code: "GRAPH_INDEX_SIDECAR_ACTIVE", + severity: "warning", + message: "Graph maintenance or recovery is active (graph.db-wal); immutable inspection was skipped.", + }], + }); + + expect(formatGraphParseHealth(skipped)).toBe("Parse health: not inspected (graph.db-wal present)"); + expect(formatGraphParseHealth(skipped)).not.toMatch(/\b0 ok\b/); + expect(formatGraphLastSuccessfulIndex(skipped)).toBe("Last successful index: not inspected"); + expect(formatGraphLastSuccessfulIndex(skipped)).not.toContain("never"); + expect(formatGraphStatusSources(skipped)).toBe("Sources: not inspected"); + expect(formatGraphStatusSources(skipped)).not.toMatch(/0 changed/); + }); + + it("prints measured parse health for an inspected graph", () => { + const inspected = status({ + status: "stale", + inspected: true, + lastSuccessfulIndexAt: "2026-09-13T04:33:14.973Z", + parseHealth: parseHealth({ total: 111, ok: 111 }), + changes: changes({ total: 1, deleted: ["src/gone.ts"] }), + }); + + expect(formatGraphParseHealth(inspected)).toBe("Parse health: 111 ok, 0 partial, 0 failed"); + expect(formatGraphLastSuccessfulIndex(inspected)).toBe("Last successful index: 2026-09-13T04:33:14.973Z"); + expect(formatGraphStatusSources(inspected)).toBe("Sources: 1 changed (0 added, 0 modified, 1 deleted)"); + }); + + it("treats omitted inspected as measured so existing consumers stay valid", () => { + const measuredEmpty = status(); + expect(formatGraphParseHealth(measuredEmpty)).toBe("Parse health: 0 ok, 0 partial, 0 failed"); + expect(formatGraphLastSuccessfulIndex(measuredEmpty)).toBe("Last successful index: never"); + expect(formatGraphStatusSources(measuredEmpty)).toBe("Sources: 0 changed (0 added, 0 modified, 0 deleted)"); + }); + it("labels bounded path arrays as shown instead of an exact breakdown", () => { const rendered = formatGraphSourceChanges(changes({ total: 125, diff --git a/src/graph/__tests__/status.test.ts b/src/graph/__tests__/status.test.ts index b6dc79b4..d2906689 100644 --- a/src/graph/__tests__/status.test.ts +++ b/src/graph/__tests__/status.test.ts @@ -156,6 +156,7 @@ describe("inspectGraphStatus", () => { expect(status).toMatchObject({ status: "missing", + inspected: true, observedAt: NOW.toISOString(), currentRepo: { branch: null, head: null, dirty: false, observedAt: NOW.toISOString() }, schemaVersion: null, @@ -277,6 +278,7 @@ describe("inspectGraphStatus", () => { const status = await inspect(root); expect(status.status).toBe("fresh"); + expect(status.inspected).toBe(true); expect(status.schemaVersion).toBe(DB_SCHEMA_VERSION); expect(status.parseHealth).toMatchObject({ total: 1, ok: 1, partial: 0, failed: 0 }); expect(status.changes).toMatchObject({ @@ -524,6 +526,15 @@ describe("inspectGraphStatus", () => { expect(statSync(`${dbPath}-wal`).size).toBeGreaterThan(0); const transient = await inspect(transientRoot); expect(transient.status).toBe("degraded"); + expect(transient.inspected).toBe(false); + expect(transient.parseHealth).toEqual({ + total: 0, + ok: 0, + partial: 0, + failed: 0, + failedPaths: [], + failedPathsTruncated: false, + }); expect(transient.changes.total).toBe(0); expect(transient.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_SIDECAR_ACTIVE" })); expect(transient.diagnostics).not.toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_CORRUPT" })); @@ -546,7 +557,7 @@ describe("inspectGraphStatus", () => { paths: ["graph.db-journal"], }); const active = await inspect(root); - expect(active).toMatchObject({ status: "degraded", schemaVersion: null }); + expect(active).toMatchObject({ status: "degraded", schemaVersion: null, inspected: false }); expect(active.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_SIDECAR_ACTIVE", })); @@ -567,7 +578,7 @@ describe("inspectGraphStatus", () => { paths: ["graph.db-wal"], }); const unavailable = await inspect(root); - expect(unavailable).toMatchObject({ status: "degraded", schemaVersion: null }); + expect(unavailable).toMatchObject({ status: "degraded", schemaVersion: null, inspected: false }); expect(unavailable.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_SIDECAR_UNAVAILABLE", })); @@ -588,7 +599,7 @@ describe("inspectGraphStatus", () => { expect(statSync(`${dbPath}-journal`).size).toBeGreaterThan(0); const status = await inspect(root); - expect(status).toMatchObject({ status: "degraded", schemaVersion: null }); + expect(status).toMatchObject({ status: "degraded", schemaVersion: null, inspected: false }); expect(status.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_SIDECAR_ACTIVE", })); @@ -1202,6 +1213,7 @@ describe("inspectGraphStatus", () => { now: NOW, }); expect(lexical.status).toBe("degraded"); + expect(lexical.inspected).toBe(false); expect(lexical.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_PATH_OUTSIDE_PROJECT", })); diff --git a/src/graph/cli-graph.ts b/src/graph/cli-graph.ts index 43258590..5dd1538d 100644 --- a/src/graph/cli-graph.ts +++ b/src/graph/cli-graph.ts @@ -181,15 +181,11 @@ const MAX_DIAGNOSTICS_SHOWN = 20; function printStatus(status: GraphStatus): void { const branch = status.currentRepo.branch ?? "detached/no branch"; const head = status.currentRepo.head?.slice(0, 12) ?? "no HEAD"; - const changes = status.changes; console.log(`Graph status: ${status.status}`); console.log(`Repository: ${branch} @ ${head}${status.currentRepo.dirty ? " (dirty)" : ""}`); - console.log(`Last successful index: ${status.lastSuccessfulIndexAt ?? "never"}`); - console.log(formatGraphSourceChanges(changes)); - console.log( - `Parse health: ${status.parseHealth.ok} ok, ${status.parseHealth.partial} partial, ` - + `${status.parseHealth.failed} failed`, - ); + console.log(formatGraphLastSuccessfulIndex(status)); + console.log(formatGraphStatusSources(status)); + console.log(formatGraphParseHealth(status)); for (const diagnostic of status.diagnostics) { console.log(`${diagnostic.severity.toUpperCase()} ${diagnostic.code}: ${diagnostic.message}`); } @@ -199,6 +195,49 @@ function printStatus(status: GraphStatus): void { if (command) console.log(`Next: ${command}`); } +function graphWasInspected(status: GraphStatus): boolean { + return status.inspected !== false; +} + +/** Internal human-output formatter; JSON output retains the complete contract. */ +export function formatGraphLastSuccessfulIndex(status: GraphStatus): string { + if (!graphWasInspected(status)) return "Last successful index: not inspected"; + return `Last successful index: ${status.lastSuccessfulIndexAt ?? "never"}`; +} + +/** Internal human-output formatter; JSON output retains the complete contract. */ +export function formatGraphStatusSources(status: GraphStatus): string { + if (!graphWasInspected(status)) return "Sources: not inspected"; + return formatGraphSourceChanges(status.changes); +} + +/** Internal human-output formatter; JSON output retains the complete contract. */ +export function formatGraphParseHealth(status: GraphStatus): string { + if (!graphWasInspected(status)) { + const detail = uninspectedParseHealthDetail(status); + return detail ? `Parse health: not inspected (${detail})` : "Parse health: not inspected"; + } + return `Parse health: ${status.parseHealth.ok} ok, ${status.parseHealth.partial} partial, ` + + `${status.parseHealth.failed} failed`; +} + +function uninspectedParseHealthDetail(status: GraphStatus): string | undefined { + for (const diagnostic of status.diagnostics) { + if ( + diagnostic.code !== "GRAPH_INDEX_SIDECAR_ACTIVE" + && diagnostic.code !== "GRAPH_INDEX_SIDECAR_UNAVAILABLE" + ) { + continue; + } + const listed = /\(([^)]+)\)/.exec(diagnostic.message)?.[1]; + if (!listed) continue; + return diagnostic.code === "GRAPH_INDEX_SIDECAR_ACTIVE" + ? `${listed} present` + : `${listed} unavailable`; + } + return undefined; +} + /** Internal human-output formatter; JSON output retains the complete contract. */ export function formatGraphSourceChanges(changes: GraphSourceChanges): string { if (changes.truncated) { diff --git a/src/graph/status.ts b/src/graph/status.ts index c12d0fef..845381c7 100644 --- a/src/graph/status.ts +++ b/src/graph/status.ts @@ -487,7 +487,7 @@ async function inspectGraphStatusAttempt( const currentRepo = emptyRepoState(observedAt); return { retry: false, - status: graphStatus({ + status: uninspectedGraphStatus({ status: "degraded", observedAt, currentRepo, @@ -544,7 +544,7 @@ async function inspectGraphStatusAttempt( diagnostics.push(classified.diagnostic); return { retry: false, - status: graphStatus({ + status: uninspectedGraphStatus({ status: classified.status, observedAt, currentRepo, @@ -562,7 +562,7 @@ async function inspectGraphStatusAttempt( }); return { retry: false, - status: graphStatus({ + status: uninspectedGraphStatus({ status: "corrupt", observedAt, currentRepo, @@ -582,7 +582,7 @@ async function inspectGraphStatusAttempt( }); return { retry: false, - status: graphStatus({ + status: uninspectedGraphStatus({ status: "degraded", observedAt, currentRepo, @@ -598,7 +598,7 @@ async function inspectGraphStatusAttempt( diagnostics.push(sidecarDiagnostic(initialSidecars)); return { retry: false, - status: graphStatus({ + status: uninspectedGraphStatus({ status: "degraded", observedAt, currentRepo, @@ -663,7 +663,7 @@ async function inspectGraphStatusAttempt( message: "The versionless graph database already contains incompatible schema objects; automatic in-place repair is unsafe.", } : rebuildDiagnostic("The empty graph database has no schema version.")); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: partialSchema ? "corrupt" : "rebuild_required", observedAt, currentRepo, @@ -680,7 +680,7 @@ async function inspectGraphStatusAttempt( severity: "error", message: "The graph schema version table is empty or contains an invalid version; automatic in-place repair is unsafe.", }); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: "corrupt", observedAt, currentRepo, @@ -711,7 +711,7 @@ async function inspectGraphStatusAttempt( message: `The older graph schema is partial or unsafe to migrate: ${errorMessage(error)}`, }); } - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: diagnostics.at(-1)?.code === "GRAPH_INDEX_SCHEMA_INVALID" ? "corrupt" : "rebuild_required", observedAt, currentRepo, @@ -732,7 +732,7 @@ async function inspectGraphStatusAttempt( severity: "error", message: "The current graph schema has incompatible column, key, or generated-table structure.", }); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: "corrupt", observedAt, currentRepo, @@ -750,7 +750,7 @@ async function inspectGraphStatusAttempt( severity: "error", message: `The current graph schema is missing required structure: ${schemaFailures.join("; ")}.`, }); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: "corrupt", observedAt, currentRepo, @@ -776,7 +776,7 @@ async function inspectGraphStatusAttempt( severity: "error", message: `SQLite quick-check failed: ${integrity.join("; ")}`, }); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: "corrupt", observedAt, currentRepo, @@ -796,7 +796,7 @@ async function inspectGraphStatusAttempt( severity: "error", message: `The graph violates persisted invariants: ${coreInvariantFailures.join("; ")}.`, }); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: "corrupt", observedAt, currentRepo, @@ -816,7 +816,7 @@ async function inspectGraphStatusAttempt( severity: "error", message: snapshotResult.error, }); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: "corrupt", observedAt, currentRepo, @@ -840,7 +840,7 @@ async function inspectGraphStatusAttempt( severity: "error", message: `Graph snapshot metadata records schema ${snapshot.schemaVersion}, but SQLite records ${schemaVersion}.`, }); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: "corrupt", observedAt, currentRepo, @@ -1117,7 +1117,7 @@ async function inspectGraphStatusAttempt( } catch (error) { const classified = classifyDatabaseError(error, "read"); diagnostics.push(classified.diagnostic); - return finishDatabaseResult(graphStatus({ + return finishDatabaseResult(uninspectedGraphStatus({ status: classified.status, observedAt, currentRepo, @@ -1151,6 +1151,7 @@ function graphStatus(input: { parseHealth: GraphParseHealth; changes: GraphSourceChanges; diagnostics: readonly Diagnostic[]; + inspected?: boolean; }): GraphStatus { return { status: input.status, @@ -1166,9 +1167,16 @@ function graphStatus(input: { parseHealth: input.parseHealth, changes: input.changes, diagnostics: input.diagnostics, + inspected: input.inspected ?? true, }; } +function uninspectedGraphStatus( + input: Omit[0], "inspected">, +): GraphStatus { + return graphStatus({ ...input, inspected: false }); +} + function suppressExecutableGraphRemediations(status: GraphStatus): GraphStatus { return { ...status, diff --git a/src/team/contracts/graph.ts b/src/team/contracts/graph.ts index 4db83cc1..57a7f008 100644 --- a/src/team/contracts/graph.ts +++ b/src/team/contracts/graph.ts @@ -62,6 +62,13 @@ export interface GraphStatus { parseHealth: GraphParseHealth; changes: GraphSourceChanges; diagnostics: readonly Diagnostic[]; + /** + * False when immutable inspection was skipped, so `parseHealth`, `changes`, + * and index timestamps are placeholders rather than measurements. Absent or + * true means those fields were produced from an inspected store or from a + * completed missing-index observation. + */ + inspected?: boolean; } export interface GraphMaintenanceProgress { From 254127f5692d15702e0340860b539bb13d9c5c52 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 15:21:57 +0000 Subject: [PATCH 2/2] test(graph): cover stranded WAL status as not inspected Add a fixture-only graph.db-wal case so sidecar-active status asserts inspected: false and human formatters do not print 0 ok, without depending on a full index build. Co-authored-by: David --- src/graph/__tests__/status.test.ts | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/graph/__tests__/status.test.ts b/src/graph/__tests__/status.test.ts index d2906689..fc59b2bb 100644 --- a/src/graph/__tests__/status.test.ts +++ b/src/graph/__tests__/status.test.ts @@ -35,6 +35,11 @@ import { serializeGraphSnapshot, type GraphSnapshot, } from "../snapshot.js"; +import { + formatGraphLastSuccessfulIndex, + formatGraphParseHealth, + formatGraphStatusSources, +} from "../cli-graph.js"; import { inspectGraphSidecars, inspectGraphStatus, @@ -281,6 +286,9 @@ describe("inspectGraphStatus", () => { expect(status.inspected).toBe(true); expect(status.schemaVersion).toBe(DB_SCHEMA_VERSION); expect(status.parseHealth).toMatchObject({ total: 1, ok: 1, partial: 0, failed: 0 }); + expect(formatGraphParseHealth(status)).toBe("Parse health: 1 ok, 0 partial, 0 failed"); + expect(formatGraphLastSuccessfulIndex(status)).toBe(`Last successful index: ${status.lastSuccessfulIndexAt}`); + expect(formatGraphStatusSources(status)).toBe("Sources: 0 changed (0 added, 0 modified, 0 deleted)"); expect(status.changes).toMatchObject({ total: 0, added: [], @@ -544,6 +552,40 @@ describe("inspectGraphStatus", () => { } }); + it("does not treat a stranded WAL sidecar as a measured empty graph", async () => { + const root = temporaryRoot("mex-graph-stranded-wal-"); + const dbPath = join(root, ".mex", "graph.db"); + mkdirSync(dirname(dbPath), { recursive: true }); + writeFileSync(dbPath, "untouched store bytes"); + writeFileSync(`${dbPath}-wal`, "stranded wal"); + + const status = await inspect(root); + + expect(status).toMatchObject({ + status: "degraded", + inspected: false, + lastSuccessfulIndexAt: null, + schemaVersion: null, + }); + expect(status.parseHealth).toEqual({ + total: 0, + ok: 0, + partial: 0, + failed: 0, + failedPaths: [], + failedPathsTruncated: false, + }); + expect(status.changes.total).toBe(0); + expect(status.diagnostics).toContainEqual(expect.objectContaining({ + code: "GRAPH_INDEX_SIDECAR_ACTIVE", + message: expect.stringContaining("graph.db-wal"), + })); + expect(formatGraphParseHealth(status)).toBe("Parse health: not inspected (graph.db-wal present)"); + expect(formatGraphParseHealth(status)).not.toMatch(/\b0 ok\b/); + expect(formatGraphLastSuccessfulIndex(status)).toBe("Last successful index: not inspected"); + expect(formatGraphStatusSources(status)).toBe("Sources: not inspected"); + }); + it("reports sidecars deterministically and refuses immutable interpretation while one is active or unavailable", async () => { const root = temporaryRoot("mex-graph-sidecar-probe-"); const dbPath = join(root, ".mex", "graph.db");