mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-24 13:50:59 +02:00
feat(decision): bounded active snapshot + compaction (redact expunges, supersede archives)
writeSnapshot/readSnapshot/rebuildSnapshot give an O(active) bounded read for the session-start hot path (D1A). compact() rewrites the log to active, archives superseded decisions for history, and EXPUNGES redacted ones (dropped, never archived) so an accidentally-captured secret leaves the store for good. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c95044f849
commit
9d328ad71c
@@ -16,6 +16,7 @@
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
import { writeFileSync, renameSync, existsSync, readFileSync } from "fs";
|
||||||
import { appendJsonl, readJsonl, hasInjection } from "./jsonl-store";
|
import { appendJsonl, readJsonl, hasInjection } from "./jsonl-store";
|
||||||
import { scan } from "./redact-engine";
|
import { scan } from "./redact-engine";
|
||||||
|
|
||||||
@@ -179,3 +180,69 @@ export function appendEvent(paths: DecisionPaths, event: DecisionEvent): void {
|
|||||||
export function readEvents(paths: DecisionPaths): DecisionEvent[] {
|
export function readEvents(paths: DecisionPaths): DecisionEvent[] {
|
||||||
return readJsonl<DecisionEvent>(paths.log);
|
return readJsonl<DecisionEvent>(paths.log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the bounded active snapshot (`decisions.active.json`) atomically. Context
|
||||||
|
* Recovery and search read THIS, not the full history — session start stays
|
||||||
|
* O(active), not O(history).
|
||||||
|
*/
|
||||||
|
export function writeSnapshot(paths: DecisionPaths, active: ActiveDecision[]): void {
|
||||||
|
const tmp = `${paths.snapshot}.tmp.${process.pid}`;
|
||||||
|
writeFileSync(tmp, JSON.stringify(active), "utf-8");
|
||||||
|
renameSync(tmp, paths.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the bounded active snapshot. Returns [] if missing/corrupt (caller may rebuild). */
|
||||||
|
export function readSnapshot(paths: DecisionPaths): ActiveDecision[] {
|
||||||
|
if (!existsSync(paths.snapshot)) return [];
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(readFileSync(paths.snapshot, "utf-8"));
|
||||||
|
return Array.isArray(v) ? (v as ActiveDecision[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute active from the event log and refresh the snapshot. Returns active. */
|
||||||
|
export function rebuildSnapshot(paths: DecisionPaths): ActiveDecision[] {
|
||||||
|
const active = computeActive(readEvents(paths));
|
||||||
|
writeSnapshot(paths, active);
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompactResult {
|
||||||
|
activeCount: number;
|
||||||
|
/** superseded decisions moved to the archive (history kept). */
|
||||||
|
archivedCount: number;
|
||||||
|
/** redacted decisions DROPPED entirely (expunged, NOT archived). */
|
||||||
|
expungedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact the event log to the active set.
|
||||||
|
* - active decisions → kept in `decisions.jsonl`,
|
||||||
|
* - superseded decisions → appended to `decisions.archive.jsonl` (history),
|
||||||
|
* - REDACTED decisions → expunged (dropped, NOT archived) — that's redact's job:
|
||||||
|
* a `redact` is how an accidentally-captured secret leaves the store for good.
|
||||||
|
* Atomic rewrite (tmp + rename). Refreshes the snapshot.
|
||||||
|
*/
|
||||||
|
export function compact(paths: DecisionPaths): CompactResult {
|
||||||
|
const events = readEvents(paths);
|
||||||
|
const active = computeActive(events);
|
||||||
|
const activeIds = new Set(active.map((d) => d.id));
|
||||||
|
const redactedIds = new Set(
|
||||||
|
events.filter((e) => e.kind === "redact" && e.supersedes).map((e) => e.supersedes as string),
|
||||||
|
);
|
||||||
|
// Superseded = a decide that's neither active nor redacted. Archive these for history.
|
||||||
|
const superseded = events.filter(
|
||||||
|
(e): e is DecisionEvent => e.kind === "decide" && !activeIds.has(e.id) && !redactedIds.has(e.id),
|
||||||
|
);
|
||||||
|
for (const e of superseded) appendJsonl(paths.archive, e);
|
||||||
|
|
||||||
|
const tmp = `${paths.log}.tmp.${process.pid}`;
|
||||||
|
writeFileSync(tmp, active.map((d) => JSON.stringify(d)).join("\n") + (active.length ? "\n" : ""), "utf-8");
|
||||||
|
renameSync(tmp, paths.log);
|
||||||
|
writeSnapshot(paths, active);
|
||||||
|
|
||||||
|
return { activeCount: active.length, archivedCount: superseded.length, expungedCount: redactedIds.size };
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,14 +3,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
import {
|
import {
|
||||||
validateDecide,
|
validateDecide,
|
||||||
makeRefEvent,
|
makeRefEvent,
|
||||||
computeActive,
|
computeActive,
|
||||||
filterByScope,
|
filterByScope,
|
||||||
decisionPaths,
|
decisionPaths,
|
||||||
|
appendEvent,
|
||||||
|
readEvents,
|
||||||
|
writeSnapshot,
|
||||||
|
readSnapshot,
|
||||||
|
rebuildSnapshot,
|
||||||
|
compact,
|
||||||
type DecisionEvent,
|
type DecisionEvent,
|
||||||
type ActiveDecision,
|
type ActiveDecision,
|
||||||
|
type DecisionPaths,
|
||||||
} from "../lib/gstack-decision";
|
} from "../lib/gstack-decision";
|
||||||
|
|
||||||
const PEM_SECRET = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----";
|
const PEM_SECRET = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----";
|
||||||
@@ -106,3 +116,66 @@ describe("decisionPaths", () => {
|
|||||||
expect(p.archive).toBe("/tmp/gs/projects/garrytan-gstack/decisions.archive.jsonl");
|
expect(p.archive).toBe("/tmp/gs/projects/garrytan-gstack/decisions.archive.jsonl");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("snapshot + compaction (real files)", () => {
|
||||||
|
function freshPaths(): { paths: DecisionPaths; cleanup: () => void } {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "decision-store-"));
|
||||||
|
const paths: DecisionPaths = {
|
||||||
|
log: join(dir, "decisions.jsonl"),
|
||||||
|
snapshot: join(dir, "decisions.active.json"),
|
||||||
|
archive: join(dir, "decisions.archive.jsonl"),
|
||||||
|
};
|
||||||
|
return { paths, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("writeSnapshot/readSnapshot roundtrip; bounded read returns active", () => {
|
||||||
|
const { paths, cleanup } = freshPaths();
|
||||||
|
const a = decide("1") as ActiveDecision;
|
||||||
|
writeSnapshot(paths, [a]);
|
||||||
|
expect(readSnapshot(paths).map((d) => d.id)).toEqual(["1"]);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuildSnapshot computes active from the event log", () => {
|
||||||
|
const { paths, cleanup } = freshPaths();
|
||||||
|
appendEvent(paths, decide("1"));
|
||||||
|
appendEvent(paths, decide("2"));
|
||||||
|
appendEvent(paths, makeRefEvent("supersede", "1"));
|
||||||
|
expect(rebuildSnapshot(paths).map((d) => d.id)).toEqual(["2"]);
|
||||||
|
expect(readSnapshot(paths).map((d) => d.id)).toEqual(["2"]);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compact keeps active, archives superseded, EXPUNGES redacted (not archived)", () => {
|
||||||
|
const { paths, cleanup } = freshPaths();
|
||||||
|
appendEvent(paths, decide("active1"));
|
||||||
|
appendEvent(paths, decide("super1"));
|
||||||
|
appendEvent(paths, makeRefEvent("supersede", "super1"));
|
||||||
|
appendEvent(paths, decide("secret1", { decision: "had a secret", rationale: "redact me" }));
|
||||||
|
appendEvent(paths, makeRefEvent("redact", "secret1"));
|
||||||
|
|
||||||
|
const r = compact(paths);
|
||||||
|
expect(r.activeCount).toBe(1);
|
||||||
|
expect(r.archivedCount).toBe(1); // super1
|
||||||
|
expect(r.expungedCount).toBe(1); // secret1
|
||||||
|
|
||||||
|
// log = active only
|
||||||
|
expect(readEvents(paths).map((e) => e.id)).toEqual(["active1"]);
|
||||||
|
// archive has the superseded decision...
|
||||||
|
const archive = readFileSync(paths.archive, "utf-8");
|
||||||
|
expect(archive).toContain("super1");
|
||||||
|
// ...but NOT the redacted one (expunged everywhere)
|
||||||
|
expect(archive).not.toContain("secret1");
|
||||||
|
expect(readFileSync(paths.log, "utf-8")).not.toContain("secret1");
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appendEvent + readEvents survive a concurrent-style double append", () => {
|
||||||
|
const { paths, cleanup } = freshPaths();
|
||||||
|
appendEvent(paths, decide("1"));
|
||||||
|
appendEvent(paths, decide("2"));
|
||||||
|
expect(readEvents(paths).length).toBe(2);
|
||||||
|
expect(existsSync(paths.log)).toBe(true);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user