mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 16:17:56 +02:00
feat(gbrain-sync): add cycleCompleted() cycle-state probe
Reads `gbrain doctor` cycle_freshness to classify whether a source has completed a full cycle (completed/never/unknown). A fail naming this source -> never; a fail naming only other sources -> completed; an absent or unparseable check -> unknown, so an unrelated doctor failure never masks a real state. Gates the automatic call-graph build on --full. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { execFileSync, spawnSync } from "child_process";
|
import { execFileSync, spawnSync } from "child_process";
|
||||||
import { withErrorContext } from "./gstack-memory-helpers";
|
import { withErrorContext } from "./gstack-memory-helpers";
|
||||||
|
import { execGbrainJson } from "./gbrain-exec";
|
||||||
|
|
||||||
export interface SourceState {
|
export interface SourceState {
|
||||||
/** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */
|
/** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */
|
||||||
@@ -182,3 +183,60 @@ export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | n
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a source's call graph has been built.
|
||||||
|
*
|
||||||
|
* "completed" — `gbrain dream` has run a full maintenance cycle, so the
|
||||||
|
* brain-global `resolve_symbol_edges` phase populated this
|
||||||
|
* source's call graph (`gbrain code-callers`/`code-callees`
|
||||||
|
* return edges).
|
||||||
|
* "never" — a cycle has provably NOT completed for this source.
|
||||||
|
* "unknown" — doctor is unavailable, unparseable, or reports a failure
|
||||||
|
* that doesn't name this source. Callers MUST treat unknown
|
||||||
|
* conservatively (the orchestrator skips auto-dream and WARNs
|
||||||
|
* rather than launch a ~35-min cycle on a flaky-doctor signal —
|
||||||
|
* see the `gbrain-doctor-overstrict` learning).
|
||||||
|
*/
|
||||||
|
export type CycleStatus = "completed" | "never" | "unknown";
|
||||||
|
|
||||||
|
interface DoctorCheck {
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
interface DoctorReport {
|
||||||
|
checks?: DoctorCheck[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `gbrain doctor --json --fast` and decide whether <sourceId>'s call
|
||||||
|
* graph is built, by inspecting the `cycle_freshness` check.
|
||||||
|
*
|
||||||
|
* Decision table (cycle_freshness.status / message):
|
||||||
|
* - ok → "completed"
|
||||||
|
* - fail|warn AND message names <sourceId> → "never"
|
||||||
|
* - fail|warn AND message omits <sourceId> → "unknown" (a real failure
|
||||||
|
* about OTHER sources must not be silently read as completed for us)
|
||||||
|
* - check absent / doctor null / other status → "unknown"
|
||||||
|
*
|
||||||
|
* `sourceId` is matched as a LITERAL substring (not a regex) so an id with
|
||||||
|
* regex metacharacters can never misfire. Routes through `execGbrainJson` so
|
||||||
|
* DATABASE_URL is seeded from gbrain's config (consistent with every other
|
||||||
|
* gstack-side gbrain call). `env` is the caller's base env (tests inject a
|
||||||
|
* shim on PATH).
|
||||||
|
*/
|
||||||
|
export function cycleCompleted(sourceId: string, env?: NodeJS.ProcessEnv): CycleStatus {
|
||||||
|
const report = execGbrainJson<DoctorReport>(["doctor", "--json", "--fast"], { baseEnv: env });
|
||||||
|
if (!report || !Array.isArray(report.checks)) return "unknown";
|
||||||
|
|
||||||
|
const check = report.checks.find((c) => c.name === "cycle_freshness");
|
||||||
|
if (!check) return "unknown";
|
||||||
|
|
||||||
|
if (check.status === "ok") return "completed";
|
||||||
|
if (check.status === "fail" || check.status === "warn") {
|
||||||
|
const msg = check.message || "";
|
||||||
|
return msg.includes(sourceId) ? "never" : "unknown";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for cycleCompleted() in lib/gbrain-sources.ts.
|
||||||
|
*
|
||||||
|
* cycleCompleted reads `gbrain doctor --json --fast` and decides whether a
|
||||||
|
* source's call graph (the brain-global resolve_symbol_edges phase) has been
|
||||||
|
* built. We put a fake `gbrain` on PATH that emits canned doctor JSON so the
|
||||||
|
* decision table can be exercised without a live brain. Same PATH-injection
|
||||||
|
* trick as test/gbrain-sources.test.ts (Bun's spawn caches PATH at process
|
||||||
|
* start; explicit env is the only reliable redirect).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, chmodSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
import { cycleCompleted } from "../lib/gbrain-sources";
|
||||||
|
|
||||||
|
interface FakeSetup {
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
cleanup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake `gbrain`:
|
||||||
|
* doctor --json --fast → echo $DOCTOR_JSON (or exit $DOCTOR_EXIT if set)
|
||||||
|
* anything else → exit 1
|
||||||
|
* The doctor payload is baked into the script so each test gets its own shim.
|
||||||
|
*/
|
||||||
|
function makeFakeGbrain(opts: { doctorJson?: string; doctorExit?: number }): FakeSetup {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "gbrain-cycle-test-"));
|
||||||
|
const bindir = join(tmp, "bin");
|
||||||
|
mkdirSync(bindir, { recursive: true });
|
||||||
|
|
||||||
|
const exit = opts.doctorExit ?? 0;
|
||||||
|
// Single-quote the JSON for the heredoc-free echo; escape embedded single quotes.
|
||||||
|
const payload = (opts.doctorJson ?? "").replace(/'/g, "'\\''");
|
||||||
|
const fake = `#!/bin/sh
|
||||||
|
case "$1 $2 $3" in
|
||||||
|
"doctor --json --fast")
|
||||||
|
if [ ${exit} -ne 0 ]; then exit ${exit}; fi
|
||||||
|
printf '%s' '${payload}'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "fake gbrain: unknown command: $@" >&2
|
||||||
|
exit 1
|
||||||
|
`;
|
||||||
|
const fakePath = join(bindir, "gbrain");
|
||||||
|
writeFileSync(fakePath, fake);
|
||||||
|
chmodSync(fakePath, 0o755);
|
||||||
|
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` };
|
||||||
|
return { env, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SRC = "gstack-code-gstack-c5994d95";
|
||||||
|
|
||||||
|
function doctor(check: { name: string; status: string; message?: string } | null): string {
|
||||||
|
return JSON.stringify({ checks: check ? [check] : [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cycleCompleted", () => {
|
||||||
|
it("returns 'completed' when cycle_freshness is ok", () => {
|
||||||
|
const fake = makeFakeGbrain({
|
||||||
|
doctorJson: doctor({ name: "cycle_freshness", status: "ok", message: "all sources fresh" }),
|
||||||
|
});
|
||||||
|
expect(cycleCompleted(SRC, fake.env)).toBe("completed");
|
||||||
|
fake.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'never' when cycle_freshness fails AND names this source", () => {
|
||||||
|
const fake = makeFakeGbrain({
|
||||||
|
doctorJson: doctor({
|
||||||
|
name: "cycle_freshness",
|
||||||
|
status: "fail",
|
||||||
|
message: `Source '${SRC}' has never completed a full cycle. Run gbrain dream.`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(cycleCompleted(SRC, fake.env)).toBe("never");
|
||||||
|
fake.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'unknown' when cycle_freshness fails but names only OTHER sources", () => {
|
||||||
|
const fake = makeFakeGbrain({
|
||||||
|
doctorJson: doctor({
|
||||||
|
name: "cycle_freshness",
|
||||||
|
status: "fail",
|
||||||
|
message: "Source 'some-other-source' has never completed a full cycle.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// A real failure that doesn't mention us must NOT be read as completed.
|
||||||
|
expect(cycleCompleted(SRC, fake.env)).toBe("unknown");
|
||||||
|
fake.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'unknown' when the cycle_freshness check is absent", () => {
|
||||||
|
const fake = makeFakeGbrain({
|
||||||
|
doctorJson: doctor({ name: "engine_health", status: "ok" }),
|
||||||
|
});
|
||||||
|
expect(cycleCompleted(SRC, fake.env)).toBe("unknown");
|
||||||
|
fake.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'unknown' when doctor exits non-zero", () => {
|
||||||
|
const fake = makeFakeGbrain({ doctorExit: 1 });
|
||||||
|
expect(cycleCompleted(SRC, fake.env)).toBe("unknown");
|
||||||
|
fake.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'unknown' when doctor emits non-JSON", () => {
|
||||||
|
const fake = makeFakeGbrain({ doctorJson: "not json at all" });
|
||||||
|
expect(cycleCompleted(SRC, fake.env)).toBe("unknown");
|
||||||
|
fake.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the source id as a LITERAL substring (regex metachars are inert)", () => {
|
||||||
|
// An id containing regex metachars must match literally, not as a pattern.
|
||||||
|
const metaId = "gstack-code-a.b+c";
|
||||||
|
const fake = makeFakeGbrain({
|
||||||
|
doctorJson: doctor({
|
||||||
|
name: "cycle_freshness",
|
||||||
|
status: "warn",
|
||||||
|
message: `Source '${metaId}' has never completed a full cycle.`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(cycleCompleted(metaId, fake.env)).toBe("never");
|
||||||
|
// A different id that a regex 'a.b+c' would also match must NOT match literally.
|
||||||
|
expect(cycleCompleted("gstack-code-aXbc", fake.env)).toBe("unknown");
|
||||||
|
fake.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user