import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.js"; import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; describe("PiSessionService archive and cleanup", () => { it("archives a session subtree within the root workspace", async () => { const archivedInputs: string[] = []; const root = sessionRecord("root"); const directChild = { ...sessionRecord("direct-child"), path: "/sessions/direct-child.jsonl", parentSessionPath: root.path }; const archivedChild = { ...sessionRecord("archived-child"), path: "/sessions/archived-child.jsonl", parentSessionPath: root.path }; const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path }; const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path }; const fake = fakeRuntime("root", { sessionFile: root.path }); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]), get: () => Promise.resolve(undefined), archive: (input) => { archivedInputs.push(input.sessionId); return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }); }, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), }, sessionManager: { create: () => fakeSessionManager(), list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]), open: () => fakeSessionManager(), }, heartbeatIntervalMs: 60_000, }); await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({ archived: true, sessionIds: ["root", "direct-child", "grandchild"], archivedCount: 3, skippedAlreadyArchivedCount: 1, }); expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]); await service.dispose(); }); it("permanently deletes archived sessions through the archive store", async () => { const deletedSessionIds: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) ? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" } : undefined), archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), deleteArchived: (sessionId) => { deletedSessionIds.push(sessionId); return Promise.resolve(); }, }, sessionManager: sessionGateway([sessionRecord("active")]), heartbeatIntervalMs: 60_000, }); await expect(service.deleteArchived("arch")).resolves.toBeUndefined(); await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found"); expect(deletedSessionIds).toEqual(["archived"]); await service.dispose(); }); it("bulk archives inactive sessions by cwd without opening runtimes", async () => { const recordsByCwd = new Map([ ["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]], ["/two", [sessionRecord("c", "/two")]], ]); const listCalls: string[] = []; const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); }); const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), archiveMany, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), }, sessionManager: { create: () => fakeSessionManager(), list: (cwd) => { listCalls.push(cwd); return Promise.resolve(recordsByCwd.get(cwd) ?? []); }, open, }, heartbeatIntervalMs: 60_000, }); const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]); expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] }); expect(listCalls).toEqual(["/one", "/two"]); expect(open).not.toHaveBeenCalled(); expect(archiveMany).toHaveBeenCalledTimes(1); expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]); await service.dispose(); }); it("bulk archive reports per-session failures without aborting other archives", async () => { const busy = fakeRuntime("busy", { isStreaming: true }); let createCalls = 0; const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { createCalls += 1; return Promise.resolve(busy.runtime); }, archiveStore: { list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), archiveMany, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), }, sessionManager: { create: () => fakeSessionManager(), list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]), open: () => fakeSessionManager(), }, heartbeatIntervalMs: 60_000, }); await service.status(sessionRef("busy")); const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]); expect(createCalls).toBe(1); expect(busy.calls.abort).toBe(0); expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]); expect(result.archivedSessionIds).toEqual(["ok"]); expect(result.failures).toEqual([ { sessionId: "busy", error: "Stop current session activity before archiving" }, { sessionId: "missing", error: "Session not found" }, ]); await service.dispose(); }); it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => { const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" }; const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" }; const busy = fakeRuntime("busy-archived", { isStreaming: true }); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(busy.runtime), archiveStore: { list: () => Promise.resolve([busyRecord, idleRecord]), get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined), archive: () => { throw new Error("archive should not be called for records that already have archive files"); }, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), deleteArchived: () => Promise.resolve(), deleteArchivedMany, }, sessionManager: { create: () => fakeSessionManager(), list: () => Promise.resolve([sessionRecord("unarchived")]), open: () => fakeSessionManager(), }, heartbeatIntervalMs: 60_000, }); await service.status(sessionRef("busy-archived")); const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]); expect(busy.calls.abort).toBe(0); expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]); expect(result.deletedSessionIds).toEqual(["idle-archived"]); expect(result.failures).toEqual([ { sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" }, { sessionId: "unarchived", error: "Archived session not found" }, ]); await service.dispose(); }); it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => { const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const listCalls: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([ { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, { sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, { sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" }, ]), get: () => Promise.resolve(undefined), archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }), archiveMany, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), deleteArchived: () => Promise.resolve(), deleteArchivedMany, }, sessionManager: { create: () => fakeSessionManager(), list: (cwd) => { listCalls.push(cwd); return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]); }, open: () => fakeSessionManager(), }, heartbeatIntervalMs: 60_000, }); const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]); expect(listCalls).toEqual(["/workspace"]); expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]); expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]); expect(result.failures).toEqual([]); await service.dispose(); }); it("previews session cleanup without mutating and executes a recomputed plan", async () => { const archivedInputs: string[] = []; const deletedSessionIds: string[] = []; let listAllCalls = 0; const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { list: () => Promise.resolve([archived, otherArchived]), get: () => Promise.resolve(undefined), archive: () => Promise.reject(new Error("cleanup should use archiveMany")), archiveMany: (inputs) => { archivedInputs.push(...inputs.map((input) => input.sessionId)); return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }))); }, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), deleteArchivedMany: (sessionIds) => { deletedSessionIds.push(...sessionIds); return Promise.resolve([...sessionIds]); }, }, sessionManager: { create: () => fakeSessionManager(), list: () => Promise.resolve([]), listAll: () => { listAllCalls += 1; return Promise.resolve([ listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"), listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"), ]); }, open: () => fakeSessionManager(), }, heartbeatIntervalMs: 60_000, }); const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]); expect(archivedInputs).toEqual([]); expect(deletedSessionIds).toEqual([]); const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); expect(result.archivedSessionIds).toEqual(["execute-only"]); expect(result.deletedSessionIds).toEqual(["archived-old"]); expect(archivedInputs).toEqual(["execute-only"]); expect(deletedSessionIds).toEqual(["archived-old"]); await service.dispose(); }); it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => { const listCalls: string[] = []; const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { list: () => Promise.resolve([ { sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, { sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" }, ]), get: () => Promise.resolve(undefined), archive: () => Promise.reject(new Error("cleanup should use archiveMany")), archiveMany, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")), deleteArchivedMany, }, sessionManager: { create: () => fakeSessionManager(), list: (cwd) => { listCalls.push(cwd); return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]); }, listAll: () => Promise.resolve([]), open: () => fakeSessionManager(), }, heartbeatIntervalMs: 60_000, }); const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); expect(listCalls).toEqual(["/old-project"]); expect(archiveMany).toHaveBeenCalledTimes(1); expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]); expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]); expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]); await service.dispose(); }); it("skips busy active sessions during cleanup execution", async () => { const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" }); const archivedInputs: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), archive: (input) => { archivedInputs.push(input.sessionId); return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); }, restore: () => Promise.resolve(), isArchived: () => Promise.resolve(false), }, sessionManager: { create: () => fakeSessionManager("/old-project"), list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), open: () => fakeSessionManager("/old-project"), }, heartbeatIntervalMs: 60_000, }); await service.status("busy-open"); const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } }); expect(result.archivedSessionIds).toEqual([]); expect(result.skippedBusySessionIds).toEqual(["busy-open"]); expect(archivedInputs).toEqual([]); expect(fake.calls.abort).toBe(0); await service.dispose(); }); });