Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 as measurements when immutable inspection is skipped. While a stranded `graph.db-wal`, an unreadable sidecar, a containment failure or a failed invariant audit blocks the read, the text output now says `Last successful index: not inspected`, `Sources: not inspected` and `Parse health: not inspected` instead of `never` and `0 ok`, and `--json` carries an additive `inspected: false` so a consumer can tell an uninspected store from one that genuinely parsed nothing. The same applies when a `GRAPH_SNAPSHOT_CONTENT_MISMATCH` stops the source comparison before the index timestamps are read, and to the `mex check` fallback status when the loader itself throws. `parseHealth` and `changes` keep their shape (#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.

Expand Down
1 change: 1 addition & 0 deletions src/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ function unavailableGraphStatus(message: string): GraphStatus {
const observedAt = new Date().toISOString();
return {
status: "degraded",
inspected: false,
observedAt,
currentRepo: { branch: null, head: null, dirty: false, observedAt },
lastSuccessfulIndexAt: null,
Expand Down
84 changes: 83 additions & 1 deletion src/graph/__tests__/cli-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@ import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { GraphSourceChanges } from "../../team/contracts/graph.js";
import { runGraphScope } from "../cli-agent.js";
import { formatGraphSourceChanges, runGraph, runGraphRefresh, runGraphRebuild } from "../cli-graph.js";
import { openSqlite } from "../db/sqlite.js";
import {
GRAPH_SNAPSHOT_METADATA_KEY,
parseGraphSnapshot,
serializeGraphSnapshot,
} from "../snapshot.js";
import {
formatGraphSourceChanges,
runGraph,
runGraphRefresh,
runGraphRebuild,
runGraphStatus,
} from "../cli-graph.js";

function changes(overrides: Partial<GraphSourceChanges> = {}): GraphSourceChanges {
return {
Expand Down Expand Up @@ -35,6 +47,76 @@ describe("graph CLI status formatting", () => {
);
expect(rendered).not.toContain("1 added, 2 modified, 0 deleted");
});

it("prints not inspected instead of zeros while a stranded WAL blocks inspection", async () => {
const root = mkdtempSync(join(tmpdir(), "mex-status-wal-cli-"));
const output: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((line) => output.push(String(line)));
try {
writeFileSync(join(root, "api.ts"), "export const api = true;");
await runGraph({ root, json: true });
writeFileSync(join(root, ".mex", "graph.db-wal"), "stranded");

output.length = 0;
await runGraphStatus({ root });
expect(output).toContain("Last successful index: not inspected");
expect(output).toContain("Sources: not inspected");
expect(output).toContain("Parse health: not inspected");
expect(output.some((line) => line.startsWith("WARNING GRAPH_INDEX_SIDECAR_ACTIVE"))).toBe(true);
expect(output.some((line) => line.includes("0 ok") || line.includes("never"))).toBe(false);

output.length = 0;
await runGraphStatus({ root, json: true });
expect(JSON.parse(output.join(""))).toMatchObject({ status: "degraded", inspected: false });
} finally {
log.mockRestore();
rmSync(root, { recursive: true, force: true });
}
}, 60_000);

it("prints not inspected when a snapshot digest mismatch stops source comparison", async () => {
const root = mkdtempSync(join(tmpdir(), "mex-status-snapshot-cli-"));
const output: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((line) => output.push(String(line)));
try {
writeFileSync(join(root, "api.ts"), "export const api = true;");
await runGraph({ root, json: true });
const db = openSqlite(join(root, ".mex", "graph.db"));
try {
const row = db.prepare("SELECT value FROM project_metadata WHERE key = ?")
.get(GRAPH_SNAPSHOT_METADATA_KEY) as { value: string };
const snapshot = parseGraphSnapshot(row.value);
if (!snapshot) throw new Error("test fixture has no valid graph snapshot");
db.prepare("UPDATE project_metadata SET value = ? WHERE key = ?").run(
serializeGraphSnapshot({ ...snapshot, sourceCorpusDigest: "0".repeat(64) }),
GRAPH_SNAPSHOT_METADATA_KEY,
);
} finally {
db.close();
}
writeFileSync(join(root, "api.ts"), "export const api = false;");

output.length = 0;
await runGraphStatus({ root });
expect(output).toContain("Graph status: corrupt");
expect(output).toContain("Last successful index: not inspected");
expect(output).toContain("Sources: not inspected");
expect(output).toContain("Parse health: not inspected");
expect(output.some((line) => line.startsWith("ERROR GRAPH_SNAPSHOT_CONTENT_MISMATCH"))).toBe(true);
expect(output.some((line) => line.includes("0 changed") || line.includes("never"))).toBe(false);

output.length = 0;
await runGraphStatus({ root, json: true });
expect(JSON.parse(output.join(""))).toMatchObject({
status: "corrupt",
inspected: false,
lastSuccessfulIndexAt: null,
});
} finally {
log.mockRestore();
rmSync(root, { recursive: true, force: true });
}
}, 60_000);
});


Expand Down
17 changes: 17 additions & 0 deletions src/graph/__tests__/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ describe("inspectGraphStatus", () => {
const corruptBefore = treeState(corruptRoot);
const corrupt = await inspect(corruptRoot);
expect(corrupt.status).toBe("corrupt");
expect(corrupt.inspected).toBe(false);
expect(corrupt.changes.total).toBe(0);
expect(corrupt.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_CORRUPT" }));
expect(executableRemediations(corrupt)).toContain("mex graph rebuild");
Expand All @@ -524,13 +525,23 @@ 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.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" }));
expect(executableRemediations(transient)).toContain("mex graph repair");
} finally {
writer.close();
}
const checkpointed = openSqlite(dbPath);
try {
checkpointed.exec("PRAGMA wal_checkpoint(TRUNCATE)");
} finally {
checkpointed.close();
}
const measured = await inspect(transientRoot);
expect(measured.inspected).toBe(true);
expect(measured.parseHealth.total).toBe(1);
});

it("reports sidecars deterministically and refuses immutable interpretation while one is active or unavailable", async () => {
Expand Down Expand Up @@ -765,12 +776,18 @@ describe("inspectGraphStatus", () => {
...snapshot,
sourceCorpusDigest: "0".repeat(64),
}));
source(digestRoot, "src/a.ts", "export const a = 2;\n");
const digestMismatch = await inspect(digestRoot);
expect(digestMismatch.status).toBe("corrupt");
expect(digestMismatch.diagnostics).toContainEqual(expect.objectContaining({
code: "GRAPH_SNAPSHOT_CONTENT_MISMATCH",
}));
expect(executableRemediations(digestMismatch)).toContain("mex graph rebuild");
// Sources were never compared and the index timestamps never populated:
// the placeholder changes and null timestamp must not read as measured.
expect(digestMismatch.inspected).toBe(false);
expect(digestMismatch.lastSuccessfulIndexAt).toBeNull();
expect(digestMismatch.changes.total).toBe(0);

const inconsistentRoot = temporaryRoot("mex-graph-snapshot-mismatch-");
source(inconsistentRoot, "src/a.ts", "export const a = 1;\n");
Expand Down
18 changes: 12 additions & 6 deletions src/graph/cli-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,18 @@ function printStatus(status: GraphStatus): void {
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`,
);
if (status.inspected === false) {
console.log("Last successful index: not inspected");
console.log("Sources: not inspected");
console.log("Parse health: not inspected");
} else {
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`,
);
}
for (const diagnostic of status.diagnostics) {
console.log(`${diagnostic.severity.toUpperCase()} ${diagnostic.code}: ${diagnostic.message}`);
}
Expand Down
23 changes: 23 additions & 0 deletions src/graph/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ async function inspectGraphStatusAttempt(
status: "degraded",
observedAt,
currentRepo,
inspected: false,
parseHealth: emptyParseHealth(),
changes: emptySourceChanges(),
diagnostics: [contained.diagnostic],
Expand Down Expand Up @@ -534,6 +535,7 @@ async function inspectGraphStatusAttempt(
status: "missing",
observedAt,
currentRepo,
inspected: true,
parseHealth: emptyParseHealth(),
changes,
diagnostics,
Expand All @@ -548,6 +550,7 @@ async function inspectGraphStatusAttempt(
status: classified.status,
observedAt,
currentRepo,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -566,6 +569,7 @@ async function inspectGraphStatusAttempt(
status: "corrupt",
observedAt,
currentRepo,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -586,6 +590,7 @@ async function inspectGraphStatusAttempt(
status: "degraded",
observedAt,
currentRepo,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -602,6 +607,7 @@ async function inspectGraphStatusAttempt(
status: "degraded",
observedAt,
currentRepo,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand Down Expand Up @@ -667,6 +673,7 @@ async function inspectGraphStatusAttempt(
status: partialSchema ? "corrupt" : "rebuild_required",
observedAt,
currentRepo,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -685,6 +692,7 @@ async function inspectGraphStatusAttempt(
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand Down Expand Up @@ -716,6 +724,7 @@ async function inspectGraphStatusAttempt(
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -737,6 +746,7 @@ async function inspectGraphStatusAttempt(
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -755,6 +765,7 @@ async function inspectGraphStatusAttempt(
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -781,6 +792,7 @@ async function inspectGraphStatusAttempt(
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -801,6 +813,7 @@ async function inspectGraphStatusAttempt(
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -821,6 +834,7 @@ async function inspectGraphStatusAttempt(
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand All @@ -845,6 +859,7 @@ async function inspectGraphStatusAttempt(
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand Down Expand Up @@ -881,11 +896,16 @@ async function inspectGraphStatusAttempt(
severity: "error",
message: "Graph snapshot source or parse-health totals disagree with the published SQLite rows.",
});
// Parse health was read, but sources were never compared against the
// snapshot and the index timestamps were never populated, so the
// aggregate is a partial inspection: report it as not inspected rather
// than presenting the placeholder changes and null timestamps as facts.
return finishDatabaseResult(graphStatus({
status: "corrupt",
observedAt,
currentRepo,
schemaVersion,
inspected: false,
parseHealth,
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand Down Expand Up @@ -1121,6 +1141,7 @@ async function inspectGraphStatusAttempt(
status: classified.status,
observedAt,
currentRepo,
inspected: false,
parseHealth: emptyParseHealth(),
changes: changesWithoutIndex(live, currentRepo, maxChangedPaths),
diagnostics,
Expand Down Expand Up @@ -1148,12 +1169,14 @@ function graphStatus(input: {
schemaVersion?: number | null;
extractorVersion?: string | null;
grammarVersion?: string | null;
inspected?: boolean;
parseHealth: GraphParseHealth;
changes: GraphSourceChanges;
diagnostics: readonly Diagnostic[];
}): GraphStatus {
return {
status: input.status,
inspected: input.inspected ?? true,
Comment thread
theDakshJaitly marked this conversation as resolved.
observedAt: input.observedAt,
currentRepo: input.currentRepo,
lastSuccessfulIndexAt: input.lastSuccessfulIndexAt ?? null,
Expand Down
6 changes: 6 additions & 0 deletions src/team/contracts/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export interface GraphSourceChanges {

export interface GraphStatus {
status: GraphStatusKind;
/**
* False when the store could not be opened for immutable inspection, so
* `lastSuccessfulIndexAt`, `parseHealth` and `changes` are placeholders
* rather than measurements. Optional and additive.
*/
inspected?: boolean;
observedAt: string;
currentRepo: RepoState;
lastSuccessfulIndexAt: string | null;
Expand Down
21 changes: 21 additions & 0 deletions test/graph-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,27 @@ describe("code-graph grounding integration", () => {
expect(warning).not.toHaveBeenCalled();
});

it("marks the fallback status as not inspected when the status loader throws", async () => {
const { config } = fixture();
const warning = vi.fn();

const report = await runDriftCheckWithGraphStatus(config, {
scaffoldPatterns: ["ROUTER.md"],
readOnlyGroundingRuntimeLoader: async () => {
throw new Error("simulated status loader failure");
},
graphWarning: warning,
});

expect(report.graphStatus.status).toBe("degraded");
expect(report.graphStatus.inspected).toBe(false);
expect(report.graphStatus.diagnostics).toContainEqual(expect.objectContaining({
code: "GRAPH_STATUS_UNAVAILABLE",
message: "simulated status loader failure",
}));
expect(warning).toHaveBeenCalledWith(expect.stringContaining("Code graph status unavailable"));
});

it("leaves graph-aware output to first-party renderers unless a warning sink is supplied", async () => {
const { config } = fixture();
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
Expand Down
Loading