import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH } from "../../shared/apiTypes.js"; import { FileSessionUnreadPersistence, SessionUnreadStore, defaultSessionUnreadFilePath, type SessionUnreadPersistedState, type SessionUnreadPersistence, } from "./sessionUnreadStore.js"; const roots: string[] = []; afterEach(async () => { vi.restoreAllMocks(); await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); describe("SessionUnreadStore", () => { it("marks only known active-to-idle transitions unread", () => { const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a"); expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]); expect(store.observeActivityState("session-1", "/repo", true)).toEqual([]); expect(store.observeActivityState("session-1", "/repo", true)).toEqual([]); const completed = store.observeActivityState("session-1", "/repo", false); expect(completed).toMatchObject([{ event: { type: "sessions.unread", catalogId: "catalog-a", catalogRevision: 1, sessionId: "session-1", cwd: "/repo", unread: { completionOrder: 1, completedAt: "2026-07-20T00:00:00.000Z" }, }, }]); expect(store.catalogSnapshot()).toMatchObject({ catalogId: "catalog-a", catalogRevision: 1, sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 1 }], }); expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]); }); it("uses monotonic completion orders so stale acknowledgements cannot clear newer work", () => { const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a"); complete(store, "session-1", "/repo"); const firstOrder = currentOrder(store, "session-1", "/repo"); complete(store, "session-1", "/repo"); const secondOrder = currentOrder(store, "session-1", "/repo"); expect(secondOrder).toBeGreaterThan(firstOrder); expect(store.acknowledge("session-1", { cwd: "/repo", catalogId: "catalog-a", throughCompletionOrder: firstOrder, }).mutations).toEqual([]); expect(currentOrder(store, "session-1", "/repo")).toBe(secondOrder); const acknowledged = store.acknowledge("session-1", { cwd: "/repo", catalogId: "catalog-a", throughCompletionOrder: secondOrder, }); expect(acknowledged.mutations).toMatchObject([{ event: { type: "sessions.unread", catalogId: "catalog-a", catalogRevision: 3, sessionId: "session-1", unread: null, }, }]); expect(store.catalogSnapshot().sessions).toEqual([]); expect(store.acknowledge("session-1", { cwd: "/repo", catalogId: "catalog-a", throughCompletionOrder: secondOrder, }).mutations).toEqual([]); }); it("rejects stale acknowledgements from a reset catalog epoch", () => { const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-new"); complete(store, "session-1", "/repo"); const current = currentOrder(store, "session-1", "/repo"); const stale = store.acknowledge("session-1", { cwd: "/repo", catalogId: "catalog-old", throughCompletionOrder: Number.MAX_SAFE_INTEGER, }); expect(stale.mutations).toEqual([]); expect(currentOrder(store, "session-1", "/repo")).toBe(current); }); it("scopes lifecycle and acknowledgements to the canonical id and cwd pair", () => { const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a"); complete(store, "session-1", "/repo-a"); complete(store, "session-1", "/repo-b"); const repoBOrder = currentOrder(store, "session-1", "/repo-b"); store.acknowledge("session-1", { cwd: "/repo-b", catalogId: "catalog-a", throughCompletionOrder: repoBOrder, }); expect(store.catalogSnapshot().sessions).toMatchObject([ { sessionId: "session-1", cwd: "/repo-a", completionOrder: 1 }, ]); }); it("forgets a closing runtime's active latch without manufacturing a completion", () => { const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a"); store.observeActivityState("session-1", "/repo", true); store.forgetActivity("session-1", "/repo"); expect(store.observeActivityState("session-1", "/repo", false)).toEqual([]); expect(store.catalogSnapshot().sessions).toEqual([]); }); it("excludes verified tracked sub-sessions and clears accidental lifecycle state", () => { const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a"); store.observeActivityState("tracked", "/repo", true); expect(store.excludeSession("tracked", "/repo")).toEqual([]); expect(store.observeActivityState("tracked", "/repo", false)).toEqual([]); expect(store.observeActivityState("tracked", "/repo", true)).toEqual([]); expect(store.observeActivityState("tracked", "/repo", false)).toEqual([]); store.forgetSession("tracked", "/repo"); complete(store, "tracked", "/repo"); const removed = store.excludeSession("tracked", "/repo"); expect(removed).toMatchObject([{ event: { catalogId: "catalog-a", sessionId: "tracked", cwd: "/repo", unread: null }, }]); expect(store.catalogSnapshot().sessions).toEqual([]); }); it("removes durable and transient state when a cwd is reconciled", () => { const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a"); complete(store, "keep", "/repo"); complete(store, "remove", "/repo"); store.observeActivityState("active-orphan", "/repo", true); store.excludeSession("excluded-orphan", "/repo"); const mutations = store.reconcileCwd("/repo", ["keep"]); expect(mutations).toMatchObject([{ event: { sessionId: "remove", unread: null } }]); expect(store.catalogSnapshot().sessions.map((summary) => summary.sessionId)).toEqual(["keep"]); expect(store.observeActivityState("active-orphan", "/repo", false)).toEqual([]); complete(store, "excluded-orphan", "/repo"); expect(currentOrder(store, "excluded-orphan", "/repo")).toBeGreaterThan(0); }); it("bounds the catalog and emits an authoritative removal when pruning", () => { const store = storeAt("2026-07-20T00:00:00.000Z", "catalog-a"); let finalMutations = store.observeActivityState("baseline", "/repo", false); for (let index = 0; index <= SESSION_UNREAD_LIMIT; index += 1) { const sessionId = `session-${index.toString()}`; store.observeActivityState(sessionId, "/repo", true); finalMutations = store.observeActivityState(sessionId, "/repo", false); } const snapshot = store.catalogSnapshot(); expect(snapshot.sessions).toHaveLength(SESSION_UNREAD_LIMIT); expect(snapshot.sessions.some((summary) => summary.sessionId === "session-0")).toBe(false); expect(snapshot.sessions[0]).toMatchObject({ sessionId: `session-${SESSION_UNREAD_LIMIT.toString()}`, completionOrder: SESSION_UNREAD_LIMIT + 1 }); expect(finalMutations).toMatchObject([ { event: { unread: { sessionId: `session-${SESSION_UNREAD_LIMIT.toString()}` } } }, { event: { sessionId: "session-0", unread: null } }, ]); }); it("persists the catalog epoch, revisions, and completion order across store instances", async () => { const persistence = new MemoryPersistence(undefined); const first = persistedStore(persistence, "catalog-a", "2026-07-20T00:00:00.000Z"); await Promise.all([first.load(), first.load()]); expect(persistence.loadCalls).toBe(1); complete(first, "session-1", "/repo"); await first.flush(); const second = persistedStore(persistence, "unused-catalog-b", "2026-07-20T01:00:00.000Z"); await second.load(); expect(second.catalogSnapshot()).toEqual(first.catalogSnapshot()); const order = currentOrder(second, "session-1", "/repo"); second.acknowledge("session-1", { cwd: "/repo", catalogId: "catalog-a", throughCompletionOrder: order, }); await second.flush(); const third = persistedStore(persistence, "unused-catalog-c", "2026-07-20T02:00:00.000Z"); await third.load(); expect(third.catalogSnapshot()).toMatchObject({ catalogId: "catalog-a", catalogRevision: 2, sessions: [] }); complete(third, "session-1", "/repo"); expect(currentOrder(third, "session-1", "/repo")).toBe(2); }); it("repairs malformed persistence with a fresh epoch and keeps stale old-epoch acks harmless", async () => { const errors: { operation: "load" | "save"; error: unknown }[] = []; const persistence = new MemoryPersistence({ version: 999, catalogId: "catalog-old" }); const store = new SessionUnreadStore({ persistence, createCatalogId: () => "catalog-reset", onPersistenceError: (operation, error) => { errors.push({ operation, error }); }, now: () => new Date("2026-07-20T00:00:00.000Z"), }); expect(() => store.catalogSnapshot()).toThrow("must be loaded"); await store.load(); expect(errors).toHaveLength(1); expect(errors[0]?.operation).toBe("load"); expect(errors[0]?.error).toBeInstanceOf(Error); expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] }); expect(persistence.valueSnapshot()).toMatchObject({ version: 1, catalogId: "catalog-reset", catalogRevision: 0, nextCompletionOrder: 0, sessions: [], }); complete(store, "session-1", "/repo"); expect(store.acknowledge("session-1", { cwd: "/repo", catalogId: "catalog-old", throughCompletionOrder: Number.MAX_SAFE_INTEGER, }).mutations).toEqual([]); expect(store.catalogSnapshot().sessions).toHaveLength(1); }); it("resets persisted protocol fields that exceed shared bounds and rejects oversized runtime identities", async () => { const persistence = new MemoryPersistence({ version: 1, catalogId: "catalog-old", catalogRevision: 1, nextCompletionOrder: 1, sessions: [{ sessionId: "x".repeat(SESSION_UNREAD_SESSION_ID_MAX_LENGTH + 1), cwd: "/repo", completionOrder: 1, completedAt: "2026-07-20T00:00:00.000Z", }], }); const errors: unknown[] = []; const store = new SessionUnreadStore({ persistence, createCatalogId: () => "catalog-reset", onPersistenceError: (_operation, error) => { errors.push(error); }, }); await store.load(); expect(errors).toHaveLength(1); expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] }); expect(() => store.observeActivityState( "x".repeat(SESSION_UNREAD_SESSION_ID_MAX_LENGTH + 1), "/repo", true, )).toThrow("sessionId exceeds its length limit"); }); it("rejects an oversized persisted catalog instead of loading unbounded state", async () => { const sessions = Array.from({ length: SESSION_UNREAD_LIMIT + 1 }, (_, index) => ({ sessionId: `session-${index.toString()}`, cwd: "/repo", completionOrder: index + 1, completedAt: "2026-07-20T00:00:00.000Z", })); const persistence = new MemoryPersistence({ version: 1, catalogId: "catalog-old", catalogRevision: sessions.length, nextCompletionOrder: sessions.length, sessions, }); const errors: unknown[] = []; const store = new SessionUnreadStore({ persistence, createCatalogId: () => "catalog-reset", onPersistenceError: (_operation, error) => { errors.push(error); }, }); await store.load(); expect(errors).toHaveLength(1); expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] }); }); it("serializes writes and captures each mutation's state", async () => { const persistence = new BlockingPersistence(emptyPersistedState("catalog-a")); const store = persistedStore(persistence, "unused-catalog", "2026-07-20T00:00:00.000Z"); await store.load(); complete(store, "session-1", "/repo"); const order = currentOrder(store, "session-1", "/repo"); store.acknowledge("session-1", { cwd: "/repo", catalogId: "catalog-a", throughCompletionOrder: order, }); await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(1); }); expect(persistence.maximumConcurrentSaves).toBe(1); expect(persistence.savedStates[0]?.sessions).toHaveLength(1); persistence.releaseNextSave(); await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(2); }); expect(persistence.maximumConcurrentSaves).toBe(1); expect(persistence.savedStates[1]?.sessions).toEqual([]); const flushed = store.flush(); persistence.releaseNextSave(); await flushed; }); it("coalesces a mutation burst behind one in-flight persistence write", async () => { const persistence = new BlockingPersistence(emptyPersistedState("catalog-a")); const store = persistedStore(persistence, "unused-catalog", "2026-07-20T00:00:00.000Z"); await store.load(); complete(store, "session-0", "/repo"); for (let index = 1; index <= 200; index += 1) complete(store, `session-${index.toString()}`, "/repo"); await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(1); }); expect(persistence.savedStates[0]).toMatchObject({ catalogRevision: 1, nextCompletionOrder: 1 }); persistence.releaseNextSave(); await vi.waitFor(() => { expect(persistence.savedStates).toHaveLength(2); }); expect(persistence.savedStates[1]).toMatchObject({ catalogRevision: 201, nextCompletionOrder: 201 }); persistence.releaseNextSave(); await store.flush(); expect(persistence.savedStates).toHaveLength(2); }); it("holds one latest snapshot without retrying every mutation during a storage outage", async () => { const saveError = new Error("storage unavailable"); const save = vi.fn(() => Promise.reject(saveError)); const store = persistedStore({ load: () => Promise.resolve(emptyPersistedState("catalog-a")), save, }, "unused-catalog", "2026-07-20T00:00:00.000Z"); await store.load(); complete(store, "session-0", "/repo"); await vi.waitFor(() => { expect(save).toHaveBeenCalledOnce(); }); for (let index = 1; index <= 100; index += 1) complete(store, `session-${index.toString()}`, "/repo"); await Promise.resolve(); expect(save).toHaveBeenCalledOnce(); await expect(store.flush()).rejects.toBe(saveError); expect(save).toHaveBeenCalledTimes(2); }); it("retries the latest snapshot before exposing state after a transient save failure", async () => { const persistence = new FailOncePersistence(emptyPersistedState("catalog-a")); const errors: unknown[] = []; const store = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog", now: () => new Date("2026-07-20T00:00:00.000Z"), onPersistenceError: (_operation, error) => { errors.push(error); }, }); await store.load(); complete(store, "session-1", "/repo"); const snapshot = await store.durableCatalogSnapshot(); expect(errors).toHaveLength(1); expect(persistence.saveCalls).toBe(2); expect(persistence.valueSnapshot()).toMatchObject({ catalogId: "catalog-a", catalogRevision: 1, nextCompletionOrder: 1, sessions: [{ sessionId: "session-1", completionOrder: 1 }], }); expect(snapshot.sessions).toHaveLength(1); }); it("rejects operational load failures without overwriting the unread file", async () => { const loadError = Object.assign(new Error("read failed"), { code: "EIO" }); const save = vi.fn(() => Promise.resolve()); const errors: { operation: "load" | "save"; error: unknown }[] = []; const store = new SessionUnreadStore({ persistence: { load: () => Promise.reject(loadError), save }, createCatalogId: () => "catalog-reset", onPersistenceError: (operation, error) => { errors.push({ operation, error }); }, }); await expect(store.load()).rejects.toBe(loadError); expect(save).not.toHaveBeenCalled(); expect(errors).toEqual([{ operation: "load", error: loadError }]); expect(() => store.catalogSnapshot()).toThrow("must be loaded"); }); it("rejects startup when a missing or corrupt catalog cannot persist its fresh epoch", async () => { const saveError = new Error("disk full"); const errors: { operation: "load" | "save"; error: unknown }[] = []; const store = new SessionUnreadStore({ persistence: { load: () => Promise.resolve(undefined), save: () => Promise.reject(saveError), }, createCatalogId: () => "catalog-reset", onPersistenceError: (operation, error) => { errors.push({ operation, error }); }, }); await expect(store.load()).rejects.toBe(saveError); expect(errors).toEqual([{ operation: "save", error: saveError }]); expect(() => store.catalogSnapshot()).toThrow("must be loaded"); }); }); describe("FileSessionUnreadPersistence", () => { it("repairs malformed JSON with a fresh persisted catalog epoch", async () => { const root = await temporaryRoot(); const filePath = join(root, "session-unread.json"); await writeFile(filePath, "{not-json", "utf8"); const errors: unknown[] = []; const store = new SessionUnreadStore({ persistence: new FileSessionUnreadPersistence(filePath), createCatalogId: () => "catalog-reset", onPersistenceError: (_operation, error) => { errors.push(error); }, }); await store.load(); expect(errors).toHaveLength(1); expect(store.catalogSnapshot()).toEqual({ catalogId: "catalog-reset", catalogRevision: 0, sessions: [] }); expect(JSON.parse(await readFile(filePath, "utf8"))).toMatchObject({ catalogId: "catalog-reset", sessions: [] }); }); it("uses PI_WEB_DATA_DIR and atomically reloads a private state file", async () => { const root = await temporaryRoot(); expect(defaultSessionUnreadFilePath({ PI_WEB_DATA_DIR: "state" }, root)).toBe(join(root, "state", "session-unread.json")); const filePath = join(root, "state", "custom-unread.json"); const persistence = new FileSessionUnreadPersistence(filePath); const store = new SessionUnreadStore({ persistence, createCatalogId: () => "catalog-a", now: () => new Date("2026-07-20T00:00:00.000Z"), }); await store.load(); complete(store, "session-1", "/repo"); await store.flush(); const persisted: unknown = JSON.parse(await readFile(filePath, "utf8")); expect(persisted).toMatchObject({ version: 1, catalogId: "catalog-a", catalogRevision: 1, nextCompletionOrder: 1, sessions: [{ sessionId: "session-1", cwd: "/repo", completionOrder: 1 }], }); // Windows reports synthesized POSIX mode bits and ignores the requested file mode. if (process.platform !== "win32") expect((await stat(filePath)).mode & 0o777).toBe(0o600); expect((await readdir(join(root, "state"))).filter((name) => name.endsWith(".tmp"))).toEqual([]); const reloaded = new SessionUnreadStore({ persistence, createCatalogId: () => "unused-catalog" }); await reloaded.load(); expect(reloaded.catalogSnapshot()).toMatchObject({ catalogId: "catalog-a", sessions: [{ sessionId: "session-1", completionOrder: 1 }], }); }); }); function storeAt(iso: string, catalogId: string): SessionUnreadStore { return new SessionUnreadStore({ now: () => new Date(iso), createCatalogId: () => catalogId }); } function persistedStore( persistence: SessionUnreadPersistence, catalogId: string, iso: string, ): SessionUnreadStore { return new SessionUnreadStore({ persistence, createCatalogId: () => catalogId, now: () => new Date(iso), }); } function complete(store: SessionUnreadStore, sessionId: string, cwd: string): void { store.observeActivityState(sessionId, cwd, true); store.observeActivityState(sessionId, cwd, false); } function currentOrder(store: SessionUnreadStore, sessionId: string, cwd: string): number { return store.catalogSnapshot().sessions.find((summary) => summary.sessionId === sessionId && summary.cwd === cwd)?.completionOrder ?? 0; } function emptyPersistedState(catalogId: string): SessionUnreadPersistedState { return { version: 1, catalogId, catalogRevision: 0, nextCompletionOrder: 0, sessions: [], }; } class MemoryPersistence implements SessionUnreadPersistence { loadCalls = 0; constructor(private value: unknown) {} load(): Promise { this.loadCalls += 1; return Promise.resolve(structuredClone(this.value)); } save(state: SessionUnreadPersistedState): Promise { this.value = structuredClone(state); return Promise.resolve(); } valueSnapshot(): unknown { return structuredClone(this.value); } } class FailOncePersistence implements SessionUnreadPersistence { saveCalls = 0; private value: SessionUnreadPersistedState; constructor(initial: SessionUnreadPersistedState) { this.value = structuredClone(initial); } load(): Promise { return Promise.resolve(structuredClone(this.value)); } save(state: SessionUnreadPersistedState): Promise { this.saveCalls += 1; if (this.saveCalls === 1) return Promise.reject(new Error("transient save failure")); this.value = structuredClone(state); return Promise.resolve(); } valueSnapshot(): unknown { return structuredClone(this.value); } } class BlockingPersistence implements SessionUnreadPersistence { readonly savedStates: SessionUnreadPersistedState[] = []; maximumConcurrentSaves = 0; private concurrentSaves = 0; private readonly releases: (() => void)[] = []; constructor(private readonly initial: SessionUnreadPersistedState) {} load(): Promise { return Promise.resolve(structuredClone(this.initial)); } save(state: SessionUnreadPersistedState): Promise { this.concurrentSaves += 1; this.maximumConcurrentSaves = Math.max(this.maximumConcurrentSaves, this.concurrentSaves); this.savedStates.push(structuredClone(state)); return new Promise((resolve) => { this.releases.push(() => { this.concurrentSaves -= 1; resolve(); }); }); } releaseNextSave(): void { const release = this.releases.shift(); if (release === undefined) throw new Error("No blocked persistence save to release"); release(); } } async function temporaryRoot(): Promise { const root = await mkdtemp(join(tmpdir(), "pi-web-unread-")); roots.push(root); return root; }