/** * Trace-file index tests: mtime-gated reconciliation (an unchanged tree costs zero * directory scans), registration-time classification, gate blind spots recovered by * the consumers' force-retry, write-time registration (import) and delete coherence. */ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { sessionMeta, userText } from "@prismshadow/penguin-core"; import type { SessionMetaPayload } from "@prismshadow/penguin-core"; import { makeTempRoot, makeTraceHarness, writeTraceFile } from "./helpers.js"; const P = "project-i"; const A = "agent-i"; const S1 = "session-2026-07-05-10-00-00-aabb0001"; const S2 = "session-2026-07-06-09-00-00-aabb0002"; const OLD = "session-2026-07-01-08-00-00-aabb0003"; /** * Ages the given paths' mtimes past the reconciler's FRESH_MS window: freshly written * dirs are deliberately never cached as clean (same-tick change hazard), so steady-state * assertions first make the tree look quiet, exactly like a tree written minutes ago. */ async function backdate(...paths: string[]): Promise { // A FIXED past instant, so repeated backdates are idempotent: the blind-spot test // resets a changed dir to exactly the mtime the gate cached, which is precisely the // "old dir changed without moving any gated mtime" scenario. const old = new Date("2026-01-01T00:00:00.000Z"); for (const p of paths) { await fs.utimes(p, old, old); } } function tracesRoot(root: string): string { return path.join(root, P, "agents", A, "traces"); } function meta(sessionId: string, over: Partial = {}): SessionMetaPayload { return { session_id: sessionId, model_id: "m1", provider: "custom", model_context_window: 1000, system_prompt: "sp", tools: [], agent_state: "/tmp/a", workspace: "/ws/one", ...over, }; } describe("trace-index", () => { let root: string; let h: ReturnType; beforeEach(async () => { root = await makeTempRoot(); h = makeTraceHarness(root); }); afterEach(async () => { h.close(); await fs.rm(root, { recursive: true, force: true }); }); it("registers external files on reconcile and classifies each Session once; an unchanged tree costs zero directory scans", async () => { await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [ sessionMeta(meta(S1, { source: "subagent" })), userText("你好,请检查代码"), ]); await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-05")); await h.traceIndex.reconcileAgent(P, A); const rows = h.traceIndex.repo.listFilesByAgent(P, A); expect( rows.map((r) => ({ sessionId: r.sessionId, fileIndex: r.fileIndex, date: r.date })), ).toEqual([{ sessionId: S1, fileIndex: 1, date: "2026-07-05" }]); expect(rows[0]!.sizeBytes).toBeGreaterThan(0); // Registration-time facts: origin / workspace / first-prompt title, one head-read. const facts = h.traceIndex.repo.getSession(S1); expect(facts?.source).toBe("subagent"); expect(facts?.workspace).toBe("/ws/one"); expect(facts?.title).toBe("你好,请检查代码"); expect(facts?.metaRead).toBe(true); expect(h.sources.get(S1)).toBe("subagent"); expect(h.traceIndex.counters.headReads).toBe(1); // Steady state: reconciling an unchanged tree is pure gate stats — no readdir, no reads. const scans = h.traceIndex.counters.dirScans; await h.traceIndex.reconcileAgent(P, A); await h.traceIndex.reconcileAgent(P, A); expect(h.traceIndex.counters.dirScans).toBe(scans); expect(h.traceIndex.counters.headReads).toBe(1); // A new shard in the same (newest) date dir moves that dir's mtime: the gate notices. await writeTraceFile(root, P, A, "2026-07-05", S2, 1, [ sessionMeta(meta(S2)), userText("second session"), ]); await h.traceIndex.reconcileAgent(P, A); expect(h.traceIndex.repo.listFilesByAgent(P, A)).toHaveLength(2); expect(h.traceIndex.counters.dirScans).toBe(scans + 1); expect(h.traceIndex.repo.getSession(S2)?.metaRead).toBe(true); }); it("a write into an OLD date dir slips past the gate; the consumers' force-retry (locateAll) recovers it", async () => { // Two date dirs indexed; the gate then watches the root + the NEWEST dir only. await writeTraceFile(root, P, A, "2026-07-01", OLD, 1, [userText("old day")]); await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [userText("new day")]); await backdate( tracesRoot(root), path.join(tracesRoot(root), "2026-07-01"), path.join(tracesRoot(root), "2026-07-05"), ); await h.traceIndex.reconcileAgent(P, A); expect(h.traceIndex.repo.listFilesByAgent(P, A)).toHaveLength(2); // External writer appends a SECOND shard into the old dir: neither gated mtime // (root / newest dir) moves. Backdating the old dir again keeps its change invisible // even to a freshness-triggered re-diff — the pure gate blind spot. await writeTraceFile(root, P, A, "2026-07-01", OLD, 2, [userText("late arrival")]); await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-01")); await h.traceIndex.reconcileAgent(P, A); expect(h.traceIndex.repo.listFilesBySession(P, A, OLD)).toHaveLength(1); // gate blind spot // A consumer that misses must force-reconcile and retry rather than 404: the // service's listTraceFiles goes through locateAll, which does exactly that when the // requested index is absent... locateAll's whole-session miss is the trigger, so // exercise it via a session the index does not know at all. const LATE = "session-2026-07-01-09-00-00-aabb0009"; await writeTraceFile(root, P, A, "2026-07-01", LATE, 1, [userText("unseen session")]); await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-01")); const files = await h.service.listTraceFiles(P, A, LATE); expect(files.map((f) => f.index)).toEqual([1]); // The forced pass also swept in the other blind-spot shard. expect(h.traceIndex.repo.listFilesBySession(P, A, OLD)).toHaveLength(2); }); it("import registers synchronously (rows + facts, no re-read); Session delete removes files and rows", async () => { const IMP = "session-2026-07-03-12-00-00-aabb0004"; const lines = [ sessionMeta(meta(IMP, { source: "schedule", workspace: "/ws/imp" })), userText("imported prompt"), ] .map((m) => JSON.stringify({ ...m, timestamp: "2026-07-03T12:00:00.000Z" })) .join("\n"); const headReads = h.traceIndex.counters.headReads; const res = await h.service.importTraceFile(P, A, lines); expect(res).toEqual({ sessionId: IMP, index: 1, date: "2026-07-03" }); // Registered without any head-read (the records were already in memory). expect(h.traceIndex.counters.headReads).toBe(headReads); expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toHaveLength(1); const facts = h.traceIndex.repo.getSession(IMP); expect(facts?.source).toBe("schedule"); expect(facts?.workspace).toBe("/ws/imp"); expect(facts?.title).toBe("imported prompt"); await h.service.deleteSessionTraces(P, A, IMP); expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toEqual([]); expect(h.traceIndex.repo.getSession(IMP)).toBeNull(); // The files are gone from disk too, and a fresh reconcile does not resurrect rows. await h.traceIndex.reconcileAgent(P, A, { force: true }); expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toEqual([]); }); it("externally deleted files and date dirs fall out of the index on the next changed-dir pass", async () => { await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [userText("a")]); await writeTraceFile(root, P, A, "2026-07-05", S1, 2, [userText("b")]); await h.traceIndex.reconcileAgent(P, A); expect(h.traceIndex.repo.listFilesBySession(P, A, S1)).toHaveLength(2); const dir = path.join(root, P, "agents", A, "traces", "2026-07-05"); await fs.rm(path.join(dir, `${S1}_002.jsonl`)); await h.traceIndex.reconcileAgent(P, A); // the dir's mtime moved: rescanned expect(h.traceIndex.repo.listFilesBySession(P, A, S1).map((r) => r.fileIndex)).toEqual([1]); await fs.rm(dir, { recursive: true }); await h.traceIndex.reconcileAgent(P, A); // the root's mtime moved: date dir purged expect(h.traceIndex.repo.listFilesByAgent(P, A)).toEqual([]); }); });