mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 15:09:00 +02:00
harden(gbrain-sources): route drift remove through #1734 guards + realpath drift check
Absorbing #2031 un-blocked a destructive remove that bypassed the #1734 data-loss guards: ensureSourceRegistered's drift path issued `gbrain sources remove` directly, without the detectAutopilot + decideSourceRemove checks every other remove routes through via safeSourcesRemove. gbrain >= 0.42's own prompt was accidentally blocking that path; with --confirm-destructive passed it is live again. - Drift remove now refuses LOUDLY (throws, actionable message) while an autopilot is active or when decideSourceRemove disallows; a silent changed=false would hide the drifted registration. - decideSourceRemove's extraArgs (--keep-storage when supported) propagate to the remove call, matching safeSourcesRemove. - Drift is realpath-normalized before being declared: a symlink alias of the same directory (macOS /tmp -> /private/tmp) is a match, not drift — the probable cause of #1985's reporter hitting the remove on an unmoved repo. - Drift fires a loud stderr line (old -> new path); perpetual drift in logs is the trigger for promoting #1985's reindex-in-place design. Tests: autopilot-active refusal (no remove in call log), fail-closed refusal on unreadable sources list, --keep-storage propagation, symlink-alias no-drift; existing drift tests pin the guard probes so a live autopilot on the dev machine can't flip them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0ad9695867
commit
c20e4625fa
+101
-1
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync } from "fs";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync, symlinkSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
@@ -168,6 +168,13 @@ describe("ensureSourceRegistered", () => {
|
||||
fake.cleanup();
|
||||
});
|
||||
|
||||
// Drift-path tests pin the #1734 guard inputs (inactive autopilot, allowed
|
||||
// remove) so a REAL autopilot running on the dev machine can't flip them.
|
||||
const guardsPinnedInactive = {
|
||||
autopilotProbe: { lockPaths: [], processRunning: () => false },
|
||||
removeDecision: { keepStorage: false },
|
||||
} as const;
|
||||
|
||||
it("recreates source when path differs (gbrain has no `sources update`), returns changed=true", async () => {
|
||||
const fake = makeFakeGbrain({
|
||||
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
|
||||
@@ -175,6 +182,7 @@ describe("ensureSourceRegistered", () => {
|
||||
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
|
||||
federated: true,
|
||||
env: fake.env,
|
||||
...guardsPinnedInactive,
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.state.status).toBe("match");
|
||||
@@ -201,6 +209,7 @@ describe("ensureSourceRegistered", () => {
|
||||
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
|
||||
federated: true,
|
||||
env: fake.env,
|
||||
...guardsPinnedInactive,
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.state.status).toBe("match");
|
||||
@@ -214,6 +223,97 @@ describe("ensureSourceRegistered", () => {
|
||||
fake.cleanup();
|
||||
});
|
||||
|
||||
// #1734 tripwire: the drift remove deletes pages/chunks/embeddings, so it
|
||||
// must refuse while a gbrain autopilot is active — and refuse LOUDLY (throw),
|
||||
// not silently return changed=false. Before the guard routing, this path
|
||||
// issued the remove unconditionally.
|
||||
it("REFUSES the drift remove while autopilot is active (throws, no remove issued)", async () => {
|
||||
const fake = makeFakeGbrain({
|
||||
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
|
||||
});
|
||||
await expect(
|
||||
ensureSourceRegistered("gstack-code-foo", "/new/path", {
|
||||
env: fake.env,
|
||||
autopilotProbe: { lockPaths: [], processRunning: () => true },
|
||||
removeDecision: { keepStorage: false },
|
||||
}),
|
||||
).rejects.toThrow(/autopilot active/);
|
||||
|
||||
const log = readFileSync(fake.logPath, "utf-8");
|
||||
expect(log).not.toContain("sources remove");
|
||||
expect(log).not.toContain("sources add");
|
||||
fake.cleanup();
|
||||
});
|
||||
|
||||
it("REFUSES the drift remove when decideSourceRemove disallows (fail closed, throws)", async () => {
|
||||
const fake = makeFakeGbrain({
|
||||
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
|
||||
});
|
||||
await expect(
|
||||
ensureSourceRegistered("gstack-code-foo", "/new/path", {
|
||||
env: fake.env,
|
||||
autopilotProbe: { lockPaths: [], processRunning: () => false },
|
||||
// A sources-list read failure makes decideSourceRemove fail closed.
|
||||
removeDecision: {
|
||||
keepStorage: false,
|
||||
fetchRows: () => {
|
||||
throw new Error("sources list unavailable");
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/fail closed/);
|
||||
|
||||
const log = readFileSync(fake.logPath, "utf-8");
|
||||
expect(log).not.toContain("sources remove");
|
||||
fake.cleanup();
|
||||
});
|
||||
|
||||
it("propagates decideSourceRemove extraArgs (--keep-storage) to the drift remove", async () => {
|
||||
const fake = makeFakeGbrain({
|
||||
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
|
||||
});
|
||||
const result = await ensureSourceRegistered("gstack-code-foo", "/new/path", {
|
||||
env: fake.env,
|
||||
autopilotProbe: { lockPaths: [], processRunning: () => false },
|
||||
removeDecision: { keepStorage: true },
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
|
||||
const log = readFileSync(fake.logPath, "utf-8");
|
||||
expect(log).toContain(
|
||||
"sources remove gstack-code-foo --yes --confirm-destructive --keep-storage",
|
||||
);
|
||||
fake.cleanup();
|
||||
});
|
||||
|
||||
// Realpath normalization: a registered path that is a symlink alias of the
|
||||
// requested path is a MATCH, not drift. Declaring it drift triggers a
|
||||
// destructive remove + full re-index for a no-op (#1985 reporter hit the
|
||||
// remove on an unmoved repo; macOS /tmp -> /private/tmp is the usual cause).
|
||||
it("does NOT declare drift when registered path is a symlink alias of the requested path", async () => {
|
||||
const base = mkdtempSync(join(tmpdir(), "gbrain-sources-realpath-"));
|
||||
const realDir = join(base, "real-repo");
|
||||
const linkDir = join(base, "link-repo");
|
||||
mkdirSync(realDir, { recursive: true });
|
||||
symlinkSync(realDir, linkDir);
|
||||
|
||||
const fake = makeFakeGbrain({
|
||||
sources: [{ id: "gstack-code-foo", local_path: realDir }],
|
||||
});
|
||||
const result = await ensureSourceRegistered("gstack-code-foo", linkDir, {
|
||||
env: fake.env,
|
||||
...guardsPinnedInactive,
|
||||
});
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.state.status).toBe("match");
|
||||
|
||||
const log = readFileSync(fake.logPath, "utf-8");
|
||||
expect(log).not.toContain("sources remove");
|
||||
expect(log).not.toContain("sources add");
|
||||
fake.cleanup();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("when reregister_on_drift=false and source is at different path, returns changed=false", async () => {
|
||||
const fake = makeFakeGbrain({
|
||||
sources: [{ id: "gstack-code-foo", local_path: "/old/path" }],
|
||||
|
||||
Reference in New Issue
Block a user