Archived
217 lines
8.3 KiB
TypeScript
217 lines
8.3 KiB
TypeScript
import { constants } from "node:fs";
|
|
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { defaultSessionArchiveFilePath, SessionArchiveStore } from "./sessionArchiveStore.js";
|
|
|
|
const tempRoots: string[] = [];
|
|
|
|
describe("defaultSessionArchiveFilePath", () => {
|
|
it("uses PI_WEB_DATA_DIR when configured", () => {
|
|
expect(defaultSessionArchiveFilePath({ PI_WEB_DATA_DIR: "managed-state" }, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "managed-state", "archived-sessions.json"));
|
|
});
|
|
|
|
it("preserves the ~/.pi-web default when PI_WEB_DATA_DIR is unset", () => {
|
|
expect(defaultSessionArchiveFilePath({}, "/tmp/pi-web")).toBe(join(homedir(), ".pi-web", "archived-sessions.json"));
|
|
});
|
|
});
|
|
|
|
describe("SessionArchiveStore", () => {
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
await Promise.all(tempRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
});
|
|
|
|
it("stores its default index and archived files under PI_WEB_DATA_DIR", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-data-dir-"));
|
|
tempRoots.push(root);
|
|
const dataDir = join(root, "managed-state");
|
|
const activeDir = join(root, "active");
|
|
await mkdir(activeDir, { recursive: true });
|
|
const sourcePath = join(activeDir, "2026-01-01_managed.jsonl");
|
|
await writeFile(sourcePath, "session contents\n", "utf8");
|
|
vi.stubEnv("PI_WEB_DATA_DIR", dataDir);
|
|
|
|
const store = new SessionArchiveStore();
|
|
const record = await store.archive({
|
|
sessionId: "managed",
|
|
cwd: "/workspace",
|
|
path: sourcePath,
|
|
created: "2026-01-01T00:00:00.000Z",
|
|
modified: "2026-01-01T00:01:00.000Z",
|
|
messageCount: 1,
|
|
firstMessage: "hello",
|
|
});
|
|
|
|
const archivePath = join(dataDir, "archived-sessions", "2026-01-01_managed.jsonl");
|
|
expect(record.archivePath).toBe(archivePath);
|
|
await expect(readFile(archivePath, "utf8")).resolves.toBe("session contents\n");
|
|
await expect(readFile(join(dataDir, "archived-sessions.json"), "utf8")).resolves.toContain('"sessionId": "managed"');
|
|
});
|
|
|
|
it("moves archived session files out of the active session directory and restores them", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-"));
|
|
tempRoots.push(root);
|
|
const activeDir = join(root, "active");
|
|
await mkdir(activeDir, { recursive: true });
|
|
const sourcePath = join(activeDir, "2026-01-01_s1.jsonl");
|
|
await writeFile(sourcePath, "session contents\n", "utf8");
|
|
|
|
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
|
|
const record = await store.archive({
|
|
sessionId: "s1",
|
|
cwd: "/workspace",
|
|
path: sourcePath,
|
|
created: "2026-01-01T00:00:00.000Z",
|
|
modified: "2026-01-01T00:01:00.000Z",
|
|
messageCount: 2,
|
|
firstMessage: "hello",
|
|
});
|
|
|
|
expect(await exists(sourcePath)).toBe(false);
|
|
expect(record.originalPath).toBe(sourcePath);
|
|
expect(record.archivePath).toBeDefined();
|
|
if (record.archivePath === undefined) throw new Error("Expected archive path");
|
|
expect(await readFile(record.archivePath, "utf8")).toBe("session contents\n");
|
|
await expect(store.list()).resolves.toMatchObject([{ sessionId: "s1", originalPath: sourcePath, archivePath: record.archivePath, messageCount: 2 }]);
|
|
|
|
await store.restore("s1");
|
|
|
|
expect(await readFile(sourcePath, "utf8")).toBe("session contents\n");
|
|
expect(await exists(record.archivePath)).toBe(false);
|
|
await expect(store.list()).resolves.toEqual([]);
|
|
});
|
|
|
|
it("permanently deletes archived session files and records", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-delete-"));
|
|
tempRoots.push(root);
|
|
const activeDir = join(root, "active");
|
|
await mkdir(activeDir, { recursive: true });
|
|
const sourcePath = join(activeDir, "2026-01-01_s1.jsonl");
|
|
await writeFile(sourcePath, "session contents\n", "utf8");
|
|
|
|
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
|
|
const record = await store.archive({
|
|
sessionId: "s1",
|
|
cwd: "/workspace",
|
|
path: sourcePath,
|
|
created: "2026-01-01T00:00:00.000Z",
|
|
modified: "2026-01-01T00:01:00.000Z",
|
|
messageCount: 2,
|
|
firstMessage: "hello",
|
|
});
|
|
|
|
if (record.archivePath === undefined) throw new Error("Expected archive path");
|
|
await store.deleteArchived("s1");
|
|
|
|
expect(await exists(sourcePath)).toBe(false);
|
|
expect(await exists(record.archivePath)).toBe(false);
|
|
await expect(store.list()).resolves.toEqual([]);
|
|
});
|
|
|
|
it("archives and permanently deletes sessions in batches", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-batch-"));
|
|
tempRoots.push(root);
|
|
const activeDir = join(root, "active");
|
|
await mkdir(activeDir, { recursive: true });
|
|
const sourceA = join(activeDir, "2026-01-01_a.jsonl");
|
|
const sourceB = join(activeDir, "2026-01-01_b.jsonl");
|
|
await writeFile(sourceA, "a\n", "utf8");
|
|
await writeFile(sourceB, "b\n", "utf8");
|
|
|
|
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
|
|
const records = await store.archiveMany([
|
|
{
|
|
sessionId: "a",
|
|
cwd: "/workspace",
|
|
path: sourceA,
|
|
created: "2026-01-01T00:00:00.000Z",
|
|
modified: "2026-01-01T00:01:00.000Z",
|
|
messageCount: 1,
|
|
firstMessage: "a",
|
|
},
|
|
{
|
|
sessionId: "b",
|
|
cwd: "/workspace",
|
|
path: sourceB,
|
|
created: "2026-01-01T00:00:00.000Z",
|
|
modified: "2026-01-01T00:02:00.000Z",
|
|
messageCount: 2,
|
|
firstMessage: "b",
|
|
},
|
|
]);
|
|
|
|
expect(records.map((record) => record.sessionId)).toEqual(["a", "b"]);
|
|
expect(await exists(sourceA)).toBe(false);
|
|
expect(await exists(sourceB)).toBe(false);
|
|
await expect(store.list()).resolves.toMatchObject([{ sessionId: "a" }, { sessionId: "b" }]);
|
|
|
|
const archivePaths = records.map((record) => record.archivePath);
|
|
if (archivePaths.some((path) => path === undefined)) throw new Error("Expected archive paths");
|
|
await expect(store.deleteArchivedMany(["a", "b", "missing"])).resolves.toEqual(["a", "b"]);
|
|
for (const archivePath of archivePaths) {
|
|
if (archivePath === undefined) throw new Error("Expected archive path");
|
|
expect(await exists(archivePath)).toBe(false);
|
|
}
|
|
await expect(store.list()).resolves.toEqual([]);
|
|
});
|
|
|
|
it("prefers exact persisted session IDs over prefix matches and canonicalizes stored cwd", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-prefix-"));
|
|
tempRoots.push(root);
|
|
const archiveFile = join(root, "archived-sessions.json");
|
|
const rawCwd = join(root, "workspace", "..", "workspace");
|
|
await writeFile(archiveFile, JSON.stringify({
|
|
sessions: [
|
|
{
|
|
sessionId: "abc123",
|
|
cwd: rawCwd,
|
|
archivedAt: "2026-01-01T00:00:00.000Z",
|
|
originalPath: "/sessions/abc123.jsonl",
|
|
archivePath: "/archive/abc123.jsonl",
|
|
messageCount: 3,
|
|
firstMessage: "prefix",
|
|
name: "Prefix match",
|
|
parentSessionPath: "/sessions/root.jsonl",
|
|
},
|
|
{
|
|
sessionId: "abc",
|
|
cwd: rawCwd,
|
|
archivedAt: "2026-01-01T00:00:00.000Z",
|
|
originalPath: "/sessions/abc.jsonl",
|
|
archivePath: "/archive/abc.jsonl",
|
|
messageCount: 1,
|
|
firstMessage: "exact",
|
|
},
|
|
],
|
|
}), "utf8");
|
|
|
|
const store = new SessionArchiveStore(archiveFile, join(root, "archived-files"));
|
|
|
|
await expect(store.get("abc")).resolves.toMatchObject({
|
|
sessionId: "abc",
|
|
cwd: resolve(rawCwd),
|
|
firstMessage: "exact",
|
|
});
|
|
await expect(store.get("abc1")).resolves.toMatchObject({
|
|
sessionId: "abc123",
|
|
cwd: resolve(rawCwd),
|
|
firstMessage: "prefix",
|
|
name: "Prefix match",
|
|
parentSessionPath: "/sessions/root.jsonl",
|
|
});
|
|
await expect(store.isArchived("abc1")).resolves.toBe(true);
|
|
await expect(store.isArchived("missing")).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
async function exists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path, constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|