Add session archiving

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 21:06:11 +02:00
parent b5b1814383
commit 402d5b17aa
11 changed files with 272 additions and 25 deletions
@@ -27,6 +27,8 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
app.post<{ Params: { sessionId: string }; Body: { requestId: string; value: string } }>("/api/sessions/:sessionId/commands/respond", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/abort", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/stop", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/archive", (request, reply) => proxy(request, reply));
app.post<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/restore", (request, reply) => proxy(request, reply));
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
+37 -11
View File
@@ -13,6 +13,7 @@ import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSessi
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js";
import { SessionArchiveStore } from "./sessionArchiveStore.js";
import type { ActiveSession } from "./sessionRuntimeStore.js";
function noop(): void {
@@ -24,6 +25,7 @@ export class PiSessionService {
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService;
private readonly archiveStore = new SessionArchiveStore();
private readonly agentDir = getAgentDir();
private readonly authStorage = AuthStorage.create();
private readonly modelRegistry = ModelRegistry.create(this.authStorage);
@@ -46,17 +48,22 @@ export class PiSessionService {
}
async list(cwd: string): Promise<ClientSession[]> {
const sessions = await SessionManager.list(cwd);
return sessions.map((s) => ({
id: s.id,
path: s.path,
cwd: s.cwd,
...(s.name === undefined ? {} : { name: s.name }),
created: s.created.toISOString(),
modified: s.modified.toISOString(),
messageCount: s.messageCount,
firstMessage: s.firstMessage,
}));
const [sessions, archivedRecords] = await Promise.all([SessionManager.list(cwd), this.archiveStore.list()]);
const archivedById = new Map(archivedRecords.filter((record) => record.cwd === cwd).map((record) => [record.sessionId, record]));
return sessions.map((s) => {
const archived = archivedById.get(s.id);
return {
id: s.id,
path: s.path,
cwd: s.cwd,
...(s.name === undefined ? {} : { name: s.name }),
created: s.created.toISOString(),
modified: s.modified.toISOString(),
messageCount: s.messageCount,
firstMessage: s.firstMessage,
...(archived === undefined ? {} : { archived: true, archivedAt: archived.archivedAt }),
};
});
}
async start(cwd: string): Promise<ClientSession> {
@@ -104,6 +111,7 @@ export class PiSessionService {
}
async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
await this.assertWritable(sessionId);
const session = await this.getOrOpen(sessionId);
const behavior = session.isStreaming || session.isCompacting ? streamingBehavior ?? "followUp" : undefined;
this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
@@ -115,6 +123,7 @@ export class PiSessionService {
}
async shell(sessionId: string, text: string): Promise<void> {
await this.assertWritable(sessionId);
const active = await this.getActive(sessionId);
const { session } = active.runtime;
const isExcluded = text.startsWith("!!");
@@ -149,13 +158,26 @@ export class PiSessionService {
}
async runCommand(sessionId: string, text: string): Promise<ClientCommandResult> {
await this.assertWritable(sessionId);
return this.commandService.run(sessionId, text);
}
async respondToCommand(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
await this.assertWritable(sessionId);
return this.commandService.respond(sessionId, requestId, value);
}
async archive(sessionId: string): Promise<void> {
const session = await this.getOrOpen(sessionId);
if (session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount > 0) throw new Error("Stop current session activity before archiving");
await this.archiveStore.archive(sessionId, session.sessionManager.getCwd());
this.stop(sessionId);
}
async restore(sessionId: string): Promise<void> {
await this.archiveStore.restore(sessionId);
}
async abort(sessionId: string): Promise<void> {
const active = this.active.get(sessionId);
if (active) await active.runtime.session.abort();
@@ -170,6 +192,10 @@ export class PiSessionService {
this.activities.delete(sessionId);
}
private async assertWritable(sessionId: string): Promise<void> {
if (await this.archiveStore.isArchived(sessionId)) throw new Error("Archived sessions are read-only. Restore the session to continue.");
}
private async getOrOpen(sessionId: string): Promise<AgentSession> {
return (await this.getActive(sessionId)).runtime.session;
}
@@ -0,0 +1,79 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
export interface ArchivedSessionRecord {
sessionId: string;
cwd: string;
archivedAt: string;
}
interface ArchiveFile {
sessions: ArchivedSessionRecord[];
}
export class SessionArchiveStore {
constructor(private readonly filePath = join(homedir(), ".pi-web", "archived-sessions.json")) {}
async list(): Promise<ArchivedSessionRecord[]> {
return (await this.read()).sessions;
}
async archive(sessionId: string, cwd: string): Promise<ArchivedSessionRecord> {
const data = await this.read();
const existing = data.sessions.find((session) => session.sessionId === sessionId);
if (existing !== undefined) return existing;
const record = { sessionId, cwd, archivedAt: new Date().toISOString() };
data.sessions.push(record);
await this.write(data);
return record;
}
async restore(sessionId: string): Promise<void> {
const data = await this.read();
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
if (sessions.length === data.sessions.length) return;
await this.write({ sessions });
}
async isArchived(sessionId: string): Promise<boolean> {
return (await this.list()).some((session) => session.sessionId === sessionId);
}
private async read(): Promise<ArchiveFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
return parseArchiveFile(value);
} catch (error: unknown) {
if (isNodeErrorWithCode(error, "ENOENT")) return { sessions: [] };
throw error;
}
}
private async write(data: ArchiveFile): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
}
function parseArchiveFile(value: unknown): ArchiveFile {
if (!isRecord(value) || !Array.isArray(value["sessions"])) throw new Error("Invalid archive file");
return { sessions: value["sessions"].map(parseArchivedSessionRecord) };
}
function parseArchivedSessionRecord(value: unknown): ArchivedSessionRecord {
if (!isRecord(value)) throw new Error("Invalid archived session record");
const sessionId = value["sessionId"];
const cwd = value["cwd"];
const archivedAt = value["archivedAt"];
if (typeof sessionId !== "string" || typeof cwd !== "string" || typeof archivedAt !== "string") throw new Error("Invalid archived session record");
return { sessionId, cwd, archivedAt };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+18
View File
@@ -85,6 +85,24 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
return { stopped: true };
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => {
try {
await sessions.archive(request.params.sessionId);
return { archived: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
try {
await sessions.restore(request.params.sessionId);
return { restored: true };
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
});
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
eventHub.add(request.params.sessionId, socket);
});
+2
View File
@@ -24,6 +24,8 @@ export interface ClientSession {
modified: string;
messageCount: number;
firstMessage: string;
archived?: boolean;
archivedAt?: string;
}
export interface ClientMessagePage {