Archived
feat: add safe session bulk cleanup actions
This commit is contained in:
@@ -318,6 +318,33 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("permanently deletes archived sessions through the archive store", async () => {
|
||||
const deletedSessionIds: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
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("reconciles workspace activity when listing only archived sessions", async () => {
|
||||
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
|
||||
@@ -52,7 +52,7 @@ function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefi
|
||||
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & { deleteArchived?: (sessionId: string) => Promise<void> };
|
||||
interface PiSessionListEntry {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -489,6 +489,16 @@ export class PiSessionService {
|
||||
await this.archiveStore.restore(sessionId);
|
||||
}
|
||||
|
||||
async deleteArchived(sessionId: string): Promise<void> {
|
||||
const record = await this.archiveStore.get(sessionId);
|
||||
if (record === undefined) throw new Error("Archived session not found");
|
||||
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
|
||||
|
||||
await this.closeActive(record.sessionId);
|
||||
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
|
||||
await this.archiveStore.deleteArchived(record.sessionId);
|
||||
}
|
||||
|
||||
async detachParent(sessionId: string): Promise<void> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
const sessionFile = session.sessionFile;
|
||||
@@ -530,6 +540,12 @@ export class PiSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureArchivedRecordMoved(record: ArchivedSessionRecord): Promise<ArchivedSessionRecord> {
|
||||
const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
|
||||
if (session === undefined) return record;
|
||||
return this.archiveStore.archive(archiveInputFromListEntry(session));
|
||||
}
|
||||
|
||||
private async archiveInputForSession(session: PiAgentSession): Promise<ArchiveSessionInput> {
|
||||
const cwd = session.sessionManager.getCwd();
|
||||
const sessionFile = session.sessionFile;
|
||||
|
||||
@@ -44,6 +44,33 @@ describe("SessionArchiveStore", () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
|
||||
@@ -88,6 +88,18 @@ export class SessionArchiveStore {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteArchived(sessionId: string): Promise<void> {
|
||||
await this.exclusive(async () => {
|
||||
const data = await this.read();
|
||||
const record = data.sessions.find((session) => session.sessionId === sessionId);
|
||||
if (record === undefined) return;
|
||||
|
||||
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
|
||||
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
|
||||
await this.write({ sessions });
|
||||
});
|
||||
}
|
||||
|
||||
async isArchived(sessionId: string): Promise<boolean> {
|
||||
return (await this.get(sessionId)) !== undefined;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.deleteArchived(request.params.sessionId);
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.detachParent(request.params.sessionId);
|
||||
|
||||
Reference in New Issue
Block a user