diff --git a/docs/prompt-history.md b/docs/prompt-history.md index ee37b4b06..ea3e614f5 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -2,7 +2,8 @@ Prompt history stores captured prompts per pi instance, can import older history and project session transcripts, and lets you delete prompts from the -history selector. Compaction arrives in a later slice of the chain. +history selector. Opted-in sessions consolidate a project's files at shutdown +(see "Compaction" below). ## Capture is opt-in @@ -45,6 +46,8 @@ Everything sits under `~/.pi/agent/history/`: - `projects//.jsonl` — one append-only capture file per pi process. - `projects//seed.jsonl` — one-time transcript import for this project. +- `projects//compact--.jsonl` — older capture files merged by + compaction. - `history-global.jsonl` — imported legacy editor-history prompts. - `hidden.json` — deletion records (tombstones); see "Delete" below. @@ -147,3 +150,45 @@ corrupt, or not an array), history is blocked with a recovery warning instead of resurfacing hidden prompts, transcript bootstrap waits, and deletes refuse to rewrite it. Recovery is explicit — restore the file or delete it yourself (hidden prompts may then reappear). + +## Compaction + +Compaction keeps the number of files per project small. It is housekeeping, +**not a retention limit**: it consolidates files and never drops a visible +prompt because of its age or of any count. + +It runs when an opted-in pi session shuts down, for the current project only. +With capture off, shutdown does nothing to the store. The project is compacted +when its directory holds **more than 50 files** or **more than 5000 entries** +in total. Then: + +- The **10 newest files** stay as they are. "Newest" is the most recent entry + timestamp in a file (the file's modification time when it has none), so a + file rewritten by a delete does not jump ahead. +- Every older file is merged, oldest first, into one new + `compact--.jsonl`, which is written completely (temp file + rename) + before any merged file is removed. Earlier compact files are merged again + like any other file. +- Never merged: `seed.jsonl` (while it exists, the transcript import does not + run again) and the capture file of the session that is shutting down. + `history-global.jsonl` sits outside the project directories and is never + touched. +- Prompts with a tombstone in `hidden.json` are not copied into the compact + file, so they cannot reappear from it later, even after their tombstone + leaves the 1000-entry cache. If `hidden.json` cannot be trusted, compaction + is skipped. + +Other pi instances may still be appending to the files being merged. Each +file is renamed to a claim name before it is read (`.gc--.jsonl`), +so a later append by path starts a fresh file under the original name, and +bytes written to the claimed file after it was read are moved into the +compact file once the claim is removed. + +Failures never lose prompts: a file that cannot be read is left untouched, +and if the compact file cannot be written, the claimed files stay on disk and +are still read like any other store file. A claimed file that cannot be +removed stays too; its prompts appear once, because the selector drops +duplicates. + +Prompts leave the store only through the delete flow above, or when you remove +files manually. diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 21d41e75e..0f950e4a7 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -5,9 +5,9 @@ // writer and slice-2 drains, with the search input (filterPrompts + // forwardToSearch fallthrough), the lazy loaded window, the project<->global // scope toggle, the preview panel + wheel handling, the slice-4 import -// (legacy migration + seed bootstrap inside getWriter), and the slice-5 -// modal delete (store sweep + exact tombstone). GC/compaction arrives in a -// later slice. +// (legacy migration + seed bootstrap inside getWriter), the slice-5 +// modal delete (store sweep + exact tombstone), and the slice-6 +// session_shutdown GC/compaction. // // Capture is OPT-IN: nothing is recorded unless // GENTLE_PI_HISTORY_CAPTURE=1|true|on. The selector honors the same gate: @@ -31,6 +31,7 @@ import { getKeybindings, Input, matchesKey, + stripTerminalSequences, Text, type TUI, type TuiMouseEvent, @@ -46,9 +47,11 @@ import { drainGlobal, drainProject, ensureRegistryEntry, + gcProjectDir, migrateLegacyStores, openSessionWriter, type SessionWriterState, + sessionFilePath, type SweepResult, } from "./store.ts"; import { @@ -148,7 +151,8 @@ export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { function sanitizeForDisplay(text: string): string { let out = ""; for (let i = 0; i < text.length; i++) { - const cp = text.codePointAt(i)!; + const cp = text.codePointAt(i); + if (cp === undefined) break; if (cp === 0x0a) { out += "\n"; } else if (cp === 0x09) { @@ -229,7 +233,7 @@ class FixedRowText { // Truncate first so an overlong help row can never exceed width, // then center the truncated copy (design §C hardening). const truncated = truncateToWidth(this.text, width, "…"); - const visible = truncated.replace(/\x1b\[[0-9;]*m/g, ""); + const visible = stripTerminalSequences(truncated); const pad = Math.max(0, Math.floor((width - visible.length) / 2)); return " ".repeat(pad) + truncated; })() @@ -1211,6 +1215,20 @@ export default function promptHistoryExtension( } }); + // Consolidate this project's store files on graceful shutdown (slice 6), + // only for opted-in sessions: with capture off the store is never + // rewritten. This instance's own capture file is never a merge candidate. + pi.on("session_shutdown", () => { + if (!captureEnabled(env)) return; + try { + gcProjectDir(root, cwd, { + keepFiles: [sessionFilePath(root, cwd, instanceId)], + }); + } catch { + // GC is best-effort and never blocks shutdown + } + }); + // When a tool asks for user input while the history overlay is open, // dismiss the overlay so the tool can take over the UI. pi.on("tool_call", () => { diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 0dd5b6331..24ce85c94 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -5,8 +5,8 @@ // and identity, the advisory registry, entry primitives, the per-instance // session writer, the scope drain/reader/query section (ordering, dedup, // tombstone filter, project/global drains), legacy migration, and the -// project seed bootstrap, and scope deletes (slice 5). GC/compaction -// arrives in a later slice. Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 +// project seed bootstrap, scope deletes (slice 5), and GC/compaction +// (slice 6). Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 // primitives). import { createHash } from "node:crypto"; @@ -196,8 +196,7 @@ export function parseStoreLine(raw: string): StoreEntry | null { } // =========================================================================== -// Instance writer (formerly multi-store.ts; GC/compaction arrives in a -// later slice) +// Instance writer (formerly multi-store.ts) // =========================================================================== /** Mutable state of ONE pi instance's exclusive capture file. */ @@ -799,3 +798,221 @@ export function bootstrapProjectSeed( fs.renameSync(tmp, seed); return { seeded: collected.length, ran: true }; } + +// --------------------------------------------------------------------------- +// GC / compaction (design v2, slice 6) +// --------------------------------------------------------------------------- + +/** Compact when a project dir holds MORE than this many store files... */ +const GC_FILE_THRESHOLD = 50; +/** ...or MORE than this many valid entries across them. */ +const GC_LINE_THRESHOLD = 5000; +/** The newest files (by fileSortKey) are never merged. */ +const GC_KEEP_NEWEST = 10; + +export interface GcResult { + compacted: boolean; + /** Store files merged into the compact file. */ + merged: number; +} + +export interface GcOptions { + fileThreshold?: number; + lineThreshold?: number; + keepNewest?: number; + /** + * Files that are never merge candidates — the calling instance's own + * capture file, which it may still append to. + */ + keepFiles?: readonly string[]; + /** Tombstone state dir (hidden.json); defaults to the store root. */ + stateDir?: string; +} + +/** + * Threshold check + compaction entry point (wired at session_shutdown). + * Compaction consolidates files: it merges the oldest store files of ONE + * project into a single `compact--.jsonl` and removes the merged + * originals. It is not a retention limit — every visible prompt is copied; + * only tombstoned prompts (already deleted by the user) are dropped. + * + * Never merged: `seed.jsonl` (its presence is the bootstrap gate, so + * removing it would re-seed deleted prompts from transcripts), the files + * in `keepFiles`, and the newest `keepNewest` files. The global seed lives + * outside the project dir and is never touched. An untrusted hidden.json + * skips compaction (fail closed), and any failure leaves every original + * readable: GC never throws. + */ +export function gcProjectDir( + root: string, + cwd: string, + opts: GcOptions = {}, +): GcResult { + const none: GcResult = { compacted: false, merged: 0 }; + try { + const dir = path.join(root, "projects", projectHash(cwd)); + const files = listProjectFiles(dir).map((file) => ({ + file, + entries: readFileEntries(file), + })); + if (files.length === 0) return none; + const totalEntries = files.reduce((sum, f) => sum + f.entries.length, 0); + if ( + files.length <= (opts.fileThreshold ?? GC_FILE_THRESHOLD) && + totalEntries <= (opts.lineThreshold ?? GC_LINE_THRESHOLD) + ) { + return none; + } + const hidden = readHiddenPrompts(opts.stateDir ?? root); + if (hidden.status === "untrusted") return none; + + const excluded = new Set( + [seedFilePath(root, cwd), ...(opts.keepFiles ?? [])].map((file) => + path.resolve(file), + ), + ); + // Newest first by the stable ts-based key (mtime is bumped by delete + // rewrites); ties break by name so the pick is deterministic. + const candidates = files + .filter((f) => !excluded.has(path.resolve(f.file))) + .map((f) => ({ file: f.file, key: fileSortKey(f.file, f.entries) })) + .sort((a, b) => b.key - a.key || a.file.localeCompare(b.file)); + const tail = candidates + .slice(opts.keepNewest ?? GC_KEEP_NEWEST) + .reverse() // oldest first: the merged output is chronological + .map((c) => c.file); + if (tail.length === 0) return none; + return compactFiles(dir, tail, hidden.keys); + } catch { + return none; + } +} + +/** One merge candidate after its claim: the open descriptor + read cursor. */ +interface ClaimedFile { + claim: string; + fd: number; + /** Bytes consumed so far (complete lines only). */ + consumed: number; +} + +/** + * Keep every non-empty line except tombstoned entries; malformed lines are + * kept verbatim, as the delete sweep does. Each kept line ends in "\n". + */ +function keepVisibleLines(text: string, hidden: ReadonlySet): string { + let out = ""; + for (const lineText of text.split("\n")) { + if (lineText.length === 0) continue; + const parsed = parseStoreLine(lineText); + if (parsed && isPromptHidden(hidden, parsed.text)) continue; + out += `${lineText}\n`; + } + return out; +} + +/** + * Claim a merge candidate: open it FIRST (an unreadable file is skipped + * untouched), then rename it to a claim name that still ends in `.jsonl`, + * so drains keep reading it until the compact file lands. After the + * rename, a live writer appending by path creates a fresh file under the + * original name; a write through a descriptor opened before the rename + * lands in the claimed inode, which the kept descriptor still reads. + */ +function claimFile(file: string): ClaimedFile | null { + let fd: number; + try { + fd = fs.openSync(file, "r"); + } catch { + return null; + } + const claim = `${file}.gc-${process.pid}-${Date.now()}.jsonl`; + try { + fs.renameSync(file, claim); + } catch { + fs.closeSync(fd); + return null; + } + return { claim, fd, consumed: 0 }; +} + +/** + * Merge the claimed tail (oldest first) into one compact file, written + * atomically (tmp + rename) BEFORE any claim is removed. A failure before + * the compact file lands leaves every claim in place (still a readable + * store file); a claim that cannot be removed survives as a harmless + * duplicate (drains dedupe by identity). After each removal the claim's + * descriptor is drained once more and any late bytes are appended to the + * compact file (or written back under the claim name if that fails). + */ +function compactFiles( + dir: string, + tail: readonly string[], + hidden: ReadonlySet, +): GcResult { + const claimed = tail + .map(claimFile) + .filter((c): c is ClaimedFile => c !== null); + if (claimed.length === 0) return { compacted: false, merged: 0 }; + const compact = path.join(dir, `compact-${process.pid}-${Date.now()}.jsonl`); + const tmp = `${compact}.tmp-${process.pid}-${Date.now()}`; + try { + let merged = ""; + for (const c of claimed) { + const snapshot = readFrom(c.fd, 0); + c.consumed = snapshot.lastIndexOf(NEWLINE) + 1; + merged += keepVisibleLines( + snapshot.subarray(0, c.consumed).toString("utf8"), + hidden, + ); + } + fs.writeFileSync(tmp, merged, "utf8"); + fs.renameSync(tmp, compact); + } catch { + try { + fs.unlinkSync(tmp); + } catch { + // the tmp name never matches a *.jsonl store file + } + for (const c of claimed) fs.closeSync(c.fd); + return { compacted: false, merged: 0 }; + } + for (const c of claimed) { + try { + fs.rmSync(c.claim); + } catch { + // the claim keeps every byte; it is merged again by a later GC + fs.closeSync(c.fd); + continue; + } + carryOver(c, compact, hidden); + } + return { compacted: true, merged: claimed.length }; +} + +/** Move bytes that reached a removed claim after it was read. */ +function carryOver( + c: ClaimedFile, + compact: string, + hidden: ReadonlySet, +): void { + let late: Buffer = Buffer.alloc(0); + try { + late = readFrom(c.fd, c.consumed); + if (late.length === 0) return; + // A torn last line is completed so the next append stays parseable. + const text = late.toString("utf8"); + fs.appendFileSync( + compact, + keepVisibleLines(text.endsWith("\n") ? text : `${text}\n`, hidden), + ); + } catch { + try { + if (late.length > 0) fs.writeFileSync(c.claim, late, { flag: "wx" }); + } catch { + // nothing else can hold these bytes; the claim name is taken + } + } finally { + fs.closeSync(c.fd); + } +} diff --git a/odd/tasks/history-contributor-chain.md b/odd/tasks/history-contributor-chain.md index 361fb4458..a5f0bafcb 100644 --- a/odd/tasks/history-contributor-chain.md +++ b/odd/tasks/history-contributor-chain.md @@ -12,7 +12,7 @@ Delegated direct writer for multi-file implementation and preparatory reading. O ## Tasks - [ ] H1: Reconcile #1391 against current main, fix migration/seed privacy and retry issues with deterministic tests; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. - [ ] H2: Reconcile #1393 against post-H1 main, preserve `GENTLE_PI_HISTORY_CAPTURE`, ensure exact tombstones, race-safe deletion, and failure reporting; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. Merge resolution + fixes staged in `fix/history-pr1393-adaptation` (see Progress); commit pending. -- [ ] H3: Reconcile #1394 against post-H2 main, preserve capture compatibility and seed gate, protect active writers during GC, and update truthful docs; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. +- [ ] H3: Reconcile #1394 against post-H2 main, preserve capture compatibility and seed gate, protect active writers during GC, and update truthful docs; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. Merge resolution + fixes staged in `fix/history-pr1394-adaptation` (see Progress); commit pending. ## Acceptance and checks Run focused `tests/history-*.test.ts`, project verification/typecheck, `git diff --check`, and required exact-head CI as applicable. Each merged slice preserves current main's opt-in behavior and shows no lost prompts in concurrency/failure tests. No automatic rollback is a substitute for data safety. Record failures/skips/pending honestly. @@ -22,7 +22,9 @@ Run focused `tests/history-*.test.ts`, project verification/typecheck, `git diff 2026-09-26 (H2, worktree `fix/history-pr1393-adaptation`, contributor head `e30233751` merging origin/main `1d128c981`): route delegated (writer; 2+ non-trivial source/test files). Conflicts in `docs/prompt-history.md` and `extensions/history/index.ts` resolved on main's #1391 structure: per-load `createOpenFlow(env, root, cwd)` (the selector never initializes the writer), injected `agentDir`/`sessionsRoot`, store root as tombstone state dir, `GENTLE_PI_HISTORY_CAPTURE` gate; the contributor's module-level `getWriter`/`drainForScope` (selector-triggered migration/seed writes, hard-wired real root, `GENTLE_PI_HISTORY_ENABLE`) was not kept. Deletion is wired through an injected `SelectorStore`; the gated `setImmediate` warm-up from #1393 stays. Fixes with strict TDD: (a) unbound/duplicated module constants resolved (single `AGENT_DIR`/`PI_HISTORY_ROOT`/`SESSIONS_ROOT`; no `CURRENT_CWD`/`INSTANCE_ID`/`PI_HISTORY_NAV_STATE_DIR`); (b) `sweepFile` filters only complete lines and carries over bytes appended to the replaced inode (via the still-open fd) after the rename; (c) per-file read/rewrite failures are counted in `SweepResult.failed`, tmp files are unlinked, and `storeDeleteFollowUp` surfaces `STORE_DELETE_PARTIAL_TEXT` while still writing the tombstone; (d) tombstones are `sha256:` hashes of the full normalized prompt (`tombstoneKey`/`isPromptHidden`), legacy plaintext 120-char entries stay honored. RED observed: sweep tests 11 fail (torn line lost, rename failure thrown, shape), prefix sibling hidden + plaintext in hidden.json (scratch script), index-importing tests failed on conflict markers/missing exports. GREEN: `node --experimental-strip-types --test tests/history-*.test.ts` 231/231 pass; `node scripts/check-types.mjs` 188 baseline, no regressions; `git diff --check` clean. Merge resolved and staged; no commit, push, or merge. +2026-09-26 (H3, worktree `fix/history-pr1394-adaptation`, contributor head `59811531f` merging origin/main `d71eec3d1`): route delegated (writer; 2+ non-trivial source/test files). All conflicts resolved on main's structure (main's `docs/prompt-history.md`, `index.ts`, `store.ts`, and every history test file except the new `tests/history-gc.test.ts`); #1394's auto-merged test edits were reverted to main because they re-introduced `lookupCwd`, `GENTLE_PI_HISTORY_ENABLE`, `src/index.ts` paths, the always-open empty selector, and dropped main's coverage; #1394's `selector-helpers.ts` carried committed conflict markers and `load-shared-history.ts` dropped the SPDX header, so both stay at main. The #1394 head no longer contains the responsive header or sidebar-aware overlay margin (the contributor removed them in `e2cca1f9b` for a follow-up PR), so there was nothing to layer; astral-safe sanitization and SGR-stripped padding were already on main, and only the contributor's `codePointAt` guard and `stripTerminalSequences` centering were layered. Layered GC: `gcProjectDir` (thresholds >50 files or >5000 entries, keep newest 10) plus a `session_shutdown` hook gated by `captureEnabled(env)` over injected root/cwd, excluding the instance's own session file. Fixes with strict TDD: (a) `seed.jsonl` and `keepFiles` are never candidates, global seed untouched; (b) each tail file is opened, then claimed by atomic rename to `.gc--.jsonl` before reading, the compact file lands before claims are removed, and bytes written to a claim after its read (including torn lines) are carried into the compact file; (c) selection and merge order use `fileSortKey` (oldest first); (d) tombstoned prompts (`isPromptHidden`, sha256 + legacy plaintext) are not copied, untrusted hidden.json skips GC; (e) shutdown GC is a no-op with capture off; (f) GC never throws, unreadable files stay untouched (the contributor version deleted them), compact-write failures leave the claims readable and remove the tmp. RED observed: 9 gc tests failed against the contributor implementation layered on main (seed merged, late appends lost by path and by fd, mtime order, hidden prompts copied, untrusted hidden.json compacted, ENOSPC thrown, sealed file deleted, own file merged), plus 2 shutdown-wiring tests against the contributor hook (compacted with capture off, merged the own file). GREEN: `node --experimental-strip-types --test tests/history-*.test.ts` 251/251 pass; `node scripts/check-types.mjs` 188 baseline, no regressions; `git diff --check` clean. Docs describe the GC thresholds and state that compaction consolidates files and is not a retention limit. Residual risk: a writer whose single append opened the file before the claim and writes only after the post-removal read would lose that write. Not layered (needs a decision): #1394's always-open selector on an empty store. Merge resolved and staged; no commit, push, or merge. + ## Next step -H2: parent reviews the staged merge resolution, commits it, and runs review/CI per policy. H1 note below is historical. +H3: parent reviews the staged merge resolution, commits it, and runs review/CI per policy. H2/H1 notes below are historical. H1 is blocked: native review lineage `review-88445da92b015b5c` escalated with `targeted_validator_rejected` for R3-001/R3-002 after the single bounded correction. Do not publish or merge this candidate as approved. Diagnose the native refusal through supported maintainer inspection or make a separately authorized fresh candidate; keep H2/H3 pending. Last integrated commit `15249c57b`, correction commit `cc6ecca68`; independent recheck: 172 focused history tests pass, typecheck retains 188 baseline diagnostics without regressions, diff check passed. `pnpm test` aborted before tests because pnpm attempted a noninteractive `node_modules` purge; the equivalent direct unit stage ran 3,754 tests (3,707 pass, 4 fail, 43 skip), while direct provider-contract and runtime-harness stages passed. Three failures are presence-poll timeouts in `agents-view-thread-identity.test.ts`; one `gentle-shell.test.ts` border mismatch includes unexpected `INSERT`. Baseline attribution unverified. Native review is still escalated. No PR push or merge occurred. diff --git a/tests/history-gc.test.ts b/tests/history-gc.test.ts new file mode 100644 index 000000000..7a03863dc --- /dev/null +++ b/tests/history-gc.test.ts @@ -0,0 +1,678 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { hidePrompt } from "../extensions/history/hide-prompts.ts"; +import { + drainProject, + gcProjectDir, + globalSeedPath, + projectHash, + seedFilePath, +} from "../extensions/history/store.ts"; + +// GC/compaction (slice 6): threshold no-op below the limits, keep-newest +// semantics, and the failure paths — the compact file lands atomically +// before any original is removed, cleanup failures are tolerated, unreadable +// files are skipped, and an append landing mid-compaction is never lost. +// All fixtures live under os.tmpdir(): the user's real ~/.pi store root is +// never touched. (Ported from the dev repo's test/history/gc.test.ts; +// gcProjectDir with explicit thresholds is the wired and tested entry +// point.) + +const CWD = "/pi-history-fixtures/project-gc"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-gc-")); +} + +function projectRoot(root: string): string { + return path.join(root, "projects", projectHash(CWD)); +} + +function writeFile( + dir: string, + name: string, + count: number, + mtimeMs: number, +): string { + const file = path.join(dir, name); + fs.writeFileSync( + file, + `${Array.from({ length: count }, (_, i) => + JSON.stringify({ v: 1, text: `${name}-${i}` }), + ).join("\n")}\n`, + "utf8", + ); + fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs)); + return file; +} + +function totalLines(dir: string): number { + let total = 0; + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith(".jsonl")) continue; + total += fs + .readFileSync(path.join(dir, f), "utf8") + .split("\n") + .filter((l) => l.trim().length > 0).length; + } + return total; +} + +/** Line texts of the single compact-*.jsonl file in dir (must exist). */ +function compactTexts(dir: string): string[] { + const compact = fs.readdirSync(dir).find((f) => f.startsWith("compact-")); + assert.ok(compact, "a compact-*.jsonl file must exist"); + return fs + .readFileSync(path.join(dir, compact), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +/** Write explicit entries (text + optional ts) with a fixed mtime. */ +function writeEntries( + dir: string, + name: string, + entries: Array<{ text: string; ts?: number }>, + mtimeMs: number, +): string { + const file = path.join(dir, name); + fs.writeFileSync( + file, + `${entries.map((e) => JSON.stringify({ v: 1, ...e })).join("\n")}\n`, + "utf8", + ); + fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs)); + return file; +} + +/** Every entry text across the dir's .jsonl files (order unspecified). */ +function dirTexts(dir: string): string[] { + const out: string[] = []; + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith(".jsonl")) continue; + for (const line of fs.readFileSync(path.join(dir, f), "utf8").split("\n")) { + if (line.trim().length === 0) continue; + out.push((JSON.parse(line) as { text: string }).text); + } + } + return out; +} + +/** + * Replace fs.rmSync (the shared CJS exports object store.ts resolves at + * call time) for the duration of fn; the original is always restored. + * `rmSync` inside the replacement is the captured original, so replacements + * can observe-or-fail and then call through. + */ +function withRmSyncPatched( + replacement: (file: string, rmSync: (file: string) => void) => void, + fn: () => void, +): void { + type RmSync = (file: string) => void; + const realRmSync = fs.rmSync.bind(fs) as RmSync; + const target = fs as unknown as { rmSync: RmSync }; + target.rmSync = (file: string) => { + replacement(file, realRmSync); + }; + try { + fn(); + } finally { + target.rmSync = realRmSync; + } +} + +test("under both thresholds: GC is a no-op", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + writeFile(dir, "a.jsonl", 10, 1000); + writeFile(dir, "b.jsonl", 10, 2000); + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: false, merged: 0 }); + assert.equal(fs.readdirSync(dir).length, 2); +}); + +test("file-count threshold merges the oldest files into one compact file", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 12 files (threshold 10) x 10 lines each. + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 11 }); + // 12 files -> newest 1 kept + 1 compact file = 2 files; all lines kept. + assert.equal(fs.readdirSync(dir).length, 2); + assert.equal(totalLines(dir), 120); + // The compact file is the renamed final artifact, not a staging leftover. + assert.match( + fs.readdirSync(dir).find((f) => f.startsWith("compact-")) ?? "", + /^compact-\d+-\d+\.jsonl$/, + ); + assert.deepEqual( + fs.readdirSync(dir).filter((f) => f.includes(".tmp-")), + [], + ); + // The newest original file survives untouched by name. + assert.equal(fs.readdirSync(dir).includes("f12.jsonl"), true); +}); + +test("line-count threshold triggers compaction too", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 3 files x 4000 lines = 12000 > 10000 threshold. + for (let i = 1; i <= 3; i++) { + writeFile(dir, `g${i}.jsonl`, 4000, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.equal(result.compacted, true); + assert.equal(totalLines(dir), 12000); + assert.equal(fs.readdirSync(dir).includes("g3.jsonl"), true); +}); + +test("GC on a missing project dir is a no-op", () => { + const root = makeRoot(); + const result = gcProjectDir(root, "/does/not/exist"); + assert.deepEqual(result, { compacted: false, merged: 0 }); +}); + +test("compaction keeps the newest 10 files, merges the rest", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 15; i++) { + writeFile(dir, `h${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 10, + }); + assert.deepEqual(result, { compacted: true, merged: 5 }); + const names = fs.readdirSync(dir).sort(); + // 10 newest originals + 1 compact file. + assert.equal(names.length, 11); + assert.equal(names[0].startsWith("compact-"), true); + assert.equal(names.includes("h15.jsonl"), true); + assert.equal(names.includes("h05.jsonl"), false); + assert.equal(names.includes("h06.jsonl"), true); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options object. +test( + "an unreadable file (chmod 000) is left in place; GC still compacts the readable tail", + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 3 files, keepNewest 1 -> the two oldest are merge candidates; the + // sealed one cannot be read, so it must be neither merged nor removed: + // removing bytes that were never copied would lose them. + writeFile(dir, "readable-old.jsonl", 5, 1000); + const sealed = writeFile(dir, "sealed-old.jsonl", 5, 2000); + writeFile(dir, "newest.jsonl", 5, 3000); + fs.chmodSync(sealed, 0o000); + try { + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 1 }); + assert.deepEqual(compactTexts(dir), [ + "readable-old.jsonl-0", + "readable-old.jsonl-1", + "readable-old.jsonl-2", + "readable-old.jsonl-3", + "readable-old.jsonl-4", + ]); + assert.equal(fs.existsSync(sealed), true); + assert.equal(fs.readdirSync(dir).includes("newest.jsonl"), true); + } finally { + fs.chmodSync(sealed, 0o644); + } + assert.equal( + fs.readFileSync(sealed, "utf8").trim().split("\n").length, + 5, + ); + }, +); + +test("the compact file lands complete before any original is removed", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + // Observe, do not replace: at the FIRST cleanup unlink the compact file + // must already exist on disk with the full merged content (110 lines). + // That is the crash-safe ordering contract: readers never see the tail + // gone with no compact file in its place. + let compactCompleteAtFirstRm: boolean | null = null; + withRmSyncPatched( + (file, rmSync) => { + if (compactCompleteAtFirstRm === null) { + const parent = path.dirname(file); + const compact = fs + .readdirSync(parent) + .find((f) => f.startsWith("compact-")); + compactCompleteAtFirstRm = + compact !== undefined && + fs + .readFileSync(path.join(parent, compact), "utf8") + .trim() + .split("\n") + .filter((l) => l.trim().length > 0).length === 110; + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 11 }); + }, + ); + assert.equal(compactCompleteAtFirstRm, true); +}); + +test("rm failure is tolerated: originals survive, GC still reports success", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 12; i++) { + writeFile(dir, `f${String(i).padStart(2, "0")}.jsonl`, 10, i * 1000); + } + // Simulate every cleanup unlink failing (e.g. originals held by another + // process): the compact file already landed, so a surviving original is + // harmless — readers dedupe by identity. + withRmSyncPatched( + () => { + throw new Error("simulated EBUSY: original still held"); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 10000, + keepNewest: 1, + }); + // The success shape is unchanged even though cleanup failed. + assert.deepEqual(result, { compacted: true, merged: 11 }); + }, + ); + // The compact file is complete on disk... + assert.equal(compactTexts(dir).length, 110); + // ...and every original survived the failed cleanup (12 + 1 compact). + assert.equal(fs.readdirSync(dir).length, 13); +}); + +test("an append landing during compaction is never lost (active writer)", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // 12 old files (merge-tail candidates) + one active writer file with the + // newest mtime. The freshness rule keeps the active file out of the merge + // tail — that is what makes concurrent appends safe during GC. + for (let i = 1; i <= 12; i++) { + writeFile(dir, `t${String(i).padStart(2, "0")}.jsonl`, 5, i * 1000); + } + const active = writeFile(dir, "active.jsonl", 5, 99_000); + // Mid-compaction (first cleanup unlink), the active writer appends a line. + let appended = false; + withRmSyncPatched( + (file, rmSync) => { + if (!appended) { + appended = true; + fs.appendFileSync( + active, + `${JSON.stringify({ v: 1, text: "during-gc" })}\n`, + "utf8", + ); + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 10, + lineThreshold: 100000, + keepNewest: 10, + }); + // 13 files > threshold 10; tail = 3 oldest; active writer untouched. + assert.deepEqual(result, { compacted: true, merged: 3 }); + }, + ); + // The active file survived by name with every line: the pre-GC lines and + // the line appended mid-compaction. + const activeTexts = fs + .readFileSync(active, "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(activeTexts, [ + "active.jsonl-0", + "active.jsonl-1", + "active.jsonl-2", + "active.jsonl-3", + "active.jsonl-4", + "during-gc", + ]); + // The tail's 15 lines all compacted; nothing from kept files was merged. + const mergedTexts = compactTexts(dir); + assert.equal(mergedTexts.length, 15); + assert.ok(mergedTexts.includes("t01.jsonl-0")); + assert.ok(mergedTexts.includes("t03.jsonl-4")); + assert.ok(!mergedTexts.some((t) => t.startsWith("active."))); + assert.ok(!mergedTexts.some((t) => t.startsWith("t04."))); + // Whole-dir accounting: 13 x 5 original lines + 1 mid-GC append. + assert.equal(totalLines(dir), 66); +}); + +// --------------------------------------------------------------------------- +// Adaptation hardening (PR #1394 on main): seed gate, live writers, +// chronology, tombstones, and failure tolerance. +// --------------------------------------------------------------------------- + +test("compaction never selects seed.jsonl nor touches the global seed", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // The seed is the OLDEST file, so a plain oldest-tail pick would merge it + // and drop the bootstrap gate (an existing seed is never regenerated). + const seed = seedFilePath(root, CWD); + writeEntries(dir, "seed.jsonl", [{ text: "seeded", ts: 10 }], 500); + const seedBytes = fs.readFileSync(seed, "utf8"); + const globalSeed = globalSeedPath(root); + fs.writeFileSync(globalSeed, `${JSON.stringify({ v: 1, text: "g" })}\n`); + const globalBytes = fs.readFileSync(globalSeed, "utf8"); + for (let i = 1; i <= 4; i++) { + writeFile(dir, `s${i}.jsonl`, 2, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 3 }); + assert.equal(fs.readFileSync(seed, "utf8"), seedBytes); + assert.equal(fs.readFileSync(globalSeed, "utf8"), globalBytes); + assert.ok(!compactTexts(dir).includes("seeded")); +}); + +test("the current instance's own session file is never merged", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // A long-lived instance whose file is the oldest in the dir. + const own = writeFile(dir, "own-instance.jsonl", 3, 500); + for (let i = 1; i <= 4; i++) { + writeFile(dir, `o${i}.jsonl`, 2, i * 1000); + } + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + keepFiles: [own], + }); + assert.deepEqual(result, { compacted: true, merged: 3 }); + assert.equal(fs.existsSync(own), true); + assert.ok(!compactTexts(dir).some((t) => t.startsWith("own-instance"))); +}); + +test("an append by path to a merged file between read and removal is kept", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // An idle-but-live instance: old mtime, so its file is in the merge tail. + const idle = writeFile(dir, "idle.jsonl", 2, 1000); + for (let i = 2; i <= 4; i++) { + writeFile(dir, `p${i}.jsonl`, 2, i * 1000); + } + let appended = false; + withRmSyncPatched( + (file, rmSync) => { + if (!appended) { + appended = true; + // The live writer appends by path, exactly as appendSessionCapture. + fs.appendFileSync( + idle, + `${JSON.stringify({ v: 1, text: "late-by-path" })}\n`, + ); + } + rmSync(file); + }, + () => { + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + assert.equal(result.compacted, true); + }, + ); + assert.equal(appended, true); + const texts = dirTexts(dir); + assert.ok(texts.includes("late-by-path"), "the late append survived"); + assert.ok(texts.includes("idle.jsonl-0")); + assert.ok(texts.includes("idle.jsonl-1")); +}); + +test("a write through a descriptor opened before the claim is carried over", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + const idle = writeFile(dir, "idle.jsonl", 2, 1000); + for (let i = 2; i <= 4; i++) { + writeFile(dir, `q${i}.jsonl`, 2, i * 1000); + } + // A writer that opened the file before GC started writes after GC read it. + const fd = fs.openSync(idle, "a"); + let written = false; + try { + withRmSyncPatched( + (file, rmSync) => { + if (!written) { + written = true; + fs.writeSync( + fd, + `${JSON.stringify({ v: 1, text: "late-by-fd" })}\n`, + ); + } + rmSync(file); + }, + () => { + gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + }, + ); + } finally { + fs.closeSync(fd); + } + assert.equal(written, true); + const texts = dirTexts(dir); + assert.ok(texts.includes("late-by-fd"), "the in-flight write survived"); + assert.equal(texts.filter((t) => t.startsWith("idle.jsonl-")).length, 2); +}); + +test("merged output is chronological by entry ts, not by mutable mtime", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // "old" holds the oldest prompts but was rewritten recently (a delete + // sweep bumps mtime); "mid" is genuinely newer by ts. + writeEntries( + dir, + "old.jsonl", + [ + { text: "old-1", ts: 1000 }, + { text: "old-2", ts: 1001 }, + ], + 90_000, + ); + writeEntries( + dir, + "mid.jsonl", + [ + { text: "mid-1", ts: 2000 }, + { text: "mid-2", ts: 2001 }, + ], + 2000, + ); + writeEntries(dir, "new.jsonl", [{ text: "new-1", ts: 3000 }], 3000); + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 2 }); + assert.deepEqual(compactTexts(dir), ["old-1", "old-2", "mid-1", "mid-2"]); + assert.equal(fs.readdirSync(dir).includes("new.jsonl"), true); + // The drain still reads newest-first after compaction. + const drained = drainProject(root, CWD, 1000, root); + assert.equal(drained.status, "ok"); + if (drained.status === "ok") { + assert.deepEqual(drained.prompts, [ + "new-1", + "mid-2", + "mid-1", + "old-2", + "old-1", + ]); + } +}); + +test("compaction drops tombstoned prompts instead of copying them", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + writeEntries( + dir, + "a.jsonl", + [{ text: "keep me" }, { text: "secret token" }], + 1000, + ); + writeEntries(dir, "b.jsonl", [{ text: "legacy hidden" }], 2000); + writeEntries(dir, "c.jsonl", [{ text: "newest" }], 3000); + assert.equal(hidePrompt(root, "secret token").status, "written"); + // A legacy plaintext (prefix-format) tombstone stays honored too. + const hidden = JSON.parse( + fs.readFileSync(path.join(root, "hidden.json"), "utf8"), + ) as string[]; + fs.writeFileSync( + path.join(root, "hidden.json"), + JSON.stringify([...hidden, "legacy hidden"]), + ); + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + assert.equal(result.compacted, true); + const texts = dirTexts(dir); + assert.ok(texts.includes("keep me")); + assert.ok(!texts.includes("secret token")); + assert.ok(!texts.includes("legacy hidden")); +}); + +test("an untrusted hidden.json blocks compaction (fail closed)", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 4; i++) { + writeFile(dir, `u${i}.jsonl`, 2, i * 1000); + } + fs.writeFileSync(path.join(root, "hidden.json"), "{corrupt", "utf8"); + const before = fs.readdirSync(dir).sort(); + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: false, merged: 0 }); + assert.deepEqual(fs.readdirSync(dir).sort(), before); +}); + +test("a failed compact write is tolerated and loses nothing", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= 4; i++) { + writeFile(dir, `w${i}.jsonl`, 3, i * 1000); + } + type RenameSync = (from: string, to: string) => void; + const target = fs as unknown as { renameSync: RenameSync }; + const realRename = fs.renameSync.bind(fs) as RenameSync; + target.renameSync = (from: string, to: string) => { + if (path.basename(to).startsWith("compact-")) { + throw new Error("simulated ENOSPC"); + } + realRename(from, to); + }; + let result: ReturnType; + try { + result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + } finally { + target.renameSync = realRename; + } + assert.deepEqual(result, { compacted: false, merged: 0 }); + assert.equal(dirTexts(dir).length, 12); + assert.deepEqual( + fs.readdirSync(dir).filter((f) => f.includes(".tmp-")), + [], + ); +}); + +test("a torn last line in a merged file is carried over, not dropped", () => { + const root = makeRoot(); + const dir = projectRoot(root); + fs.mkdirSync(dir, { recursive: true }); + // A writer died mid-append: the final line has no newline yet. + const torn = path.join(dir, "torn.jsonl"); + fs.writeFileSync( + torn, + `${JSON.stringify({ v: 1, text: "whole" })}\n${JSON.stringify({ v: 1, text: "torn" })}`, + ); + fs.utimesSync(torn, new Date(1000), new Date(1000)); + writeFile(dir, "r2.jsonl", 2, 2000); + writeFile(dir, "r3.jsonl", 2, 3000); + const result = gcProjectDir(root, CWD, { + fileThreshold: 2, + lineThreshold: 100000, + keepNewest: 1, + }); + assert.deepEqual(result, { compacted: true, merged: 2 }); + assert.equal(fs.existsSync(torn), false); + // Every compact line parses, and the torn entry survived. + const texts = compactTexts(dir); + assert.ok(texts.includes("whole")); + assert.ok(texts.includes("torn")); + assert.ok(texts.includes("r2.jsonl-1")); +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 3dd494ac6..5f3288dfb 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -30,8 +30,11 @@ function openWriterForTest(root: string, instanceId: string) { return openSessionWriter(root, CWD, instanceId); } -/** Load the extension against a temp root and return the capture handler. */ -function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { +/** Load the extension against a temp root and return its event handlers. */ +function handlersWith( + env: NodeJS.ProcessEnv, + root: string, +): Map void> { const registered: Array<[string, unknown]> = []; const pi = { on: (event: string, handler: unknown) => { @@ -52,7 +55,31 @@ function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { agentDir: path.join(root, "agent"), sessionsRoot: path.join(root, "sessions"), }); - return registered[0][1] as (event: unknown) => void; + return new Map( + registered.map(([event, handler]) => [ + event, + handler as (event: unknown) => void, + ]), + ); +} + +/** Load the extension against a temp root and return the capture handler. */ +function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { + const handler = handlersWith(env, root).get("before_agent_start"); + assert.ok(handler, "the capture handler is registered"); + return handler; +} + +/** Fill the project dir past the default GC file threshold (50 files). */ +function fillProjectDir(root: string, files: number): string { + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 1; i <= files; i++) { + const file = path.join(dir, `peer-${String(i).padStart(3, "0")}.jsonl`); + fs.writeFileSync(file, `${JSON.stringify({ v: 1, text: `peer ${i}` })}\n`); + fs.utimesSync(file, new Date(i * 1000), new Date(i * 1000)); + } + return dir; } test("no file is created until the first capture", () => { @@ -114,12 +141,11 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -test("the extension entry registers exactly the slice-3 wiring surface", () => { +test("the extension entry registers exactly the slice-6 wiring surface", () => { // Module load must stay side-effect free (importing index.ts parses the // whole graph without touching the real ~/.pi store root). Wiring as of - // slice 3: before_agent_start capture + tool_call overlay dismiss, the - // ctrl+shift+r shortcut, and the history command. session_shutdown is - // slice 6 and must not appear yet. + // slice 6: before_agent_start capture, session_shutdown GC, tool_call + // overlay dismiss, the ctrl+shift+r shortcut, and the history command. const registered: Array<[string, unknown]> = []; const shortcuts: Array<[string, unknown]> = []; const commands: Array<[string, unknown]> = []; @@ -137,7 +163,7 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { promptHistoryExtension(pi as never); assert.deepEqual( registered.map(([event]) => event), - ["before_agent_start", "tool_call"], + ["before_agent_start", "session_shutdown", "tool_call"], ); assert.deepEqual(shortcuts.map(([key]) => key), ["ctrl+shift+r"]); assert.deepEqual(commands.map(([name]) => name), ["history"]); @@ -214,3 +240,35 @@ test("disabling capture stops new lines and leaves existing files alone", () => handler({ prompt: "never written" }); assert.deepEqual(fileTexts(file), ["kept"]); }); + +test("session_shutdown GC is a no-op while capture is off", () => { + const root = makeRoot(); + const dir = fillProjectDir(root, 60); + const before = fs.readdirSync(dir).sort(); + const shutdown = handlersWith({}, root).get("session_shutdown"); + assert.ok(shutdown, "the shutdown handler is registered"); + shutdown({}); + assert.deepEqual(fs.readdirSync(dir).sort(), before); + assert.deepEqual(fs.readdirSync(root).sort(), ["projects"]); +}); + +test("session_shutdown GC compacts the injected root and keeps its own file", () => { + const root = makeRoot(); + const dir = fillProjectDir(root, 60); + // This instance's own capture file is the oldest one in the dir. + const own = sessionFilePath(root, CWD, "inst-entry"); + fs.writeFileSync(own, `${JSON.stringify({ v: 1, text: "own" })}\n`); + fs.utimesSync(own, new Date(1), new Date(1)); + const shutdown = handlersWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root).get( + "session_shutdown", + ); + assert.ok(shutdown, "the shutdown handler is registered"); + shutdown({}); + const names = fs.readdirSync(dir); + // Default policy: the newest 10 peers stay, the other 50 merge into one + // compact file, and the own file is never a merge candidate. + assert.equal(names.filter((n) => n.startsWith("compact-")).length, 1); + assert.equal(names.filter((n) => n.startsWith("peer-")).length, 10); + assert.equal(fs.existsSync(own), true); + assert.deepEqual(fileTexts(own), ["own"]); +});