feat: archive session descendants

This commit is contained in:
Federico Jaramillo Martinez
2026-05-23 00:05:03 +02:00
parent a1e903f8f9
commit 428f7bb8c2
17 changed files with 416 additions and 25 deletions
+135 -12
View File
@@ -13,12 +13,13 @@ import {
type CreateAgentSessionRuntimeFactory,
type EditToolDetails,
} from "@earendil-works/pi-coding-agent";
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js";
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
import type { ActiveSession } from "./sessionRuntimeStore.js";
import type { AuthChange } from "./authService.js";
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
@@ -46,6 +47,13 @@ interface PiSessionListEntry {
name?: string;
parentSessionPath?: string;
}
interface WorkspaceArchiveCandidate extends SessionArchiveTreeCandidate {
cwd: string;
listEntry?: PiSessionListEntry;
activeSession?: PiAgentSession;
}
type AgentModel = Model<Api>;
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
@@ -408,12 +416,32 @@ export class PiSessionService {
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");
if (sessionHasActiveWork(session)) throw new Error("Stop current session activity before archiving");
const archiveInput = await this.archiveInputForSession(session);
await this.closeActive(session.sessionId);
await this.archiveStore.archive(archiveInput);
}
async archiveTree(sessionId: string): Promise<ClientArchiveSessionsResponse> {
const session = await this.getOrOpen(sessionId);
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
const plan = planSessionArchiveTree(root, catalog);
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && sessionHasActiveWork(target));
if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
for (const input of archiveInputs) await this.closeActive(input.sessionId);
for (const input of archiveInputs) await this.archiveStore.archive(input);
return {
archived: true,
sessionIds: archiveInputs.map((input) => input.sessionId),
archivedCount: archiveInputs.length,
skippedAlreadyArchivedCount: plan.skippedAlreadyArchivedCount,
};
}
async restore(sessionId: string): Promise<void> {
await this.closeActive(sessionId);
await this.archiveStore.restore(sessionId);
@@ -465,16 +493,41 @@ export class PiSessionService {
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
const listed = (await this.sessionManager.list(cwd)).find((candidate) => candidate.id === session.sessionId);
if (listed !== undefined) return archiveInputFromListEntry(listed);
return {
sessionId: session.sessionId,
cwd,
path: sessionFile,
created: new Date().toISOString(),
modified: new Date().toISOString(),
messageCount: session.messages.length,
firstMessage: "",
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
};
return archiveInputFromActiveSession(session);
}
private async workspaceArchiveCandidates(cwd: string): Promise<WorkspaceArchiveCandidate[]> {
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
const candidates = new Map<string, WorkspaceArchiveCandidate>();
const archivedById = new Map<string, ArchivedSessionRecord>();
for (const record of archivedRecords) {
if (record.cwd === cwd) archivedById.set(record.sessionId, record);
}
for (const session of sessions) {
const archived = archivedById.get(session.id);
if (archived === undefined) candidates.set(session.id, archiveCandidateFromListEntry(session));
else {
const candidate = archiveCandidateFromArchivedRecord(archived, session);
if (candidate !== undefined) candidates.set(candidate.id, candidate);
}
}
for (const record of archivedById.values()) {
if (candidates.has(record.sessionId)) continue;
const candidate = archiveCandidateFromArchivedRecord(record, undefined);
if (candidate !== undefined) candidates.set(candidate.id, candidate);
}
for (const active of new Set(this.active.values())) {
const session = active.runtime.session;
if (session.sessionManager.getCwd() !== cwd || archivedById.has(session.sessionId)) continue;
const existing = candidates.get(session.sessionId);
candidates.set(session.sessionId, { ...(existing ?? archiveCandidateFromActiveSession(session, false)), activeSession: session });
}
return [...candidates.values()];
}
private async listSessionNames(cwd: string): Promise<string[]> {
@@ -742,6 +795,76 @@ function archiveInputFromListEntry(session: PiSessionListEntry): ArchiveSessionI
};
}
function archiveInputFromActiveSession(session: PiAgentSession): ArchiveSessionInput {
const sessionFile = session.sessionFile;
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
return {
sessionId: session.sessionId,
cwd: session.sessionManager.getCwd(),
path: sessionFile,
created: new Date().toISOString(),
modified: new Date().toISOString(),
messageCount: session.messages.length,
firstMessage: "",
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
};
}
function archiveCandidateFromListEntry(session: PiSessionListEntry): WorkspaceArchiveCandidate {
return {
id: session.id,
path: session.path,
cwd: session.cwd,
archived: false,
listEntry: session,
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
};
}
function archiveCandidateFromArchivedRecord(record: ArchivedSessionRecord, fallback: PiSessionListEntry | undefined): WorkspaceArchiveCandidate | undefined {
const path = record.originalPath ?? fallback?.path;
if (path === undefined) return undefined;
const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
return {
id: record.sessionId,
path,
cwd: record.cwd,
archived: true,
...(fallback === undefined ? {} : { listEntry: fallback }),
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
};
}
function archiveCandidateFromActiveSession(session: PiAgentSession, archived: boolean): WorkspaceArchiveCandidate {
const sessionFile = session.sessionFile;
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
return {
id: session.sessionId,
path: sessionFile,
cwd: session.sessionManager.getCwd(),
archived,
activeSession: session,
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
};
}
function archiveInputFromCandidate(candidate: WorkspaceArchiveCandidate): ArchiveSessionInput {
if (candidate.listEntry !== undefined) return archiveInputFromListEntry(candidate.listEntry);
if (candidate.activeSession !== undefined) return archiveInputFromActiveSession(candidate.activeSession);
throw new Error(`Session is not available for archiving: ${candidate.id}`);
}
function sessionHasActiveWork(session: PiAgentSession): boolean {
return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount > 0;
}
function sessionDisplayName(session: PiAgentSession): string {
return session.sessionName ?? session.sessionId;
}
function clientSessionFromArchivedRecord(record: ArchivedSessionRecord, fallback: PiSessionListEntry | undefined): ClientSession | undefined {
const path = record.originalPath ?? fallback?.path;
const created = record.created ?? fallback?.created.toISOString();