From 9d328ad71c91397645bdf78f8345597f969300e9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 7 Jun 2026 08:49:04 -0700 Subject: [PATCH] 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) --- lib/gstack-decision.ts | 67 +++++++++++++++++++++++++++++++++ test/gstack-decision.test.ts | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/lib/gstack-decision.ts b/lib/gstack-decision.ts index 175762bec..2247aa310 100644 --- a/lib/gstack-decision.ts +++ b/lib/gstack-decision.ts @@ -16,6 +16,7 @@ import { join } from "path"; import { homedir } from "os"; import { randomUUID } from "crypto"; +import { writeFileSync, renameSync, existsSync, readFileSync } from "fs"; import { appendJsonl, readJsonl, hasInjection } from "./jsonl-store"; import { scan } from "./redact-engine"; @@ -179,3 +180,69 @@ export function appendEvent(paths: DecisionPaths, event: DecisionEvent): void { export function readEvents(paths: DecisionPaths): DecisionEvent[] { return readJsonl(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 }; +} diff --git a/test/gstack-decision.test.ts b/test/gstack-decision.test.ts index 0e38d5ecc..94c1d3330 100644 --- a/test/gstack-decision.test.ts +++ b/test/gstack-decision.test.ts @@ -3,14 +3,24 @@ */ import { describe, it, expect } from "bun:test"; +import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { validateDecide, makeRefEvent, computeActive, filterByScope, decisionPaths, + appendEvent, + readEvents, + writeSnapshot, + readSnapshot, + rebuildSnapshot, + compact, type DecisionEvent, type ActiveDecision, + type DecisionPaths, } from "../lib/gstack-decision"; 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"); }); }); + +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(); + }); +});