Archived
Add session archiving
This commit is contained in:
@@ -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`));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ClientSession {
|
||||
modified: string;
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
archived?: boolean;
|
||||
archivedAt?: string;
|
||||
}
|
||||
|
||||
export interface ClientMessagePage {
|
||||
|
||||
Reference in New Issue
Block a user