Archived
feat: archive session descendants
This commit is contained in:
@@ -206,6 +206,46 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("archives a session subtree within the root workspace", async () => {
|
||||
const archivedInputs: string[] = [];
|
||||
const root = sessionRecord("root");
|
||||
const directChild = { ...sessionRecord("direct-child"), path: "/sessions/direct-child.jsonl", parentSessionPath: root.path };
|
||||
const archivedChild = { ...sessionRecord("archived-child"), path: "/sessions/archived-child.jsonl", parentSessionPath: root.path };
|
||||
const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path };
|
||||
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
|
||||
const fake = fakeRuntime("root", { sessionFile: root.path });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => {
|
||||
archivedInputs.push(input.sessionId);
|
||||
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" });
|
||||
},
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]),
|
||||
listAll: () => Promise.resolve([root, directChild, archivedChild, grandchild, otherWorkspaceChild]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.archiveTree("root")).resolves.toEqual({
|
||||
archived: true,
|
||||
sessionIds: ["root", "direct-child", "grandchild"],
|
||||
archivedCount: 3,
|
||||
skippedAlreadyArchivedCount: 1,
|
||||
});
|
||||
expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]);
|
||||
|
||||
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(), {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
|
||||
|
||||
function candidate(id: string, options: Partial<SessionArchiveTreeCandidate> = {}): SessionArchiveTreeCandidate {
|
||||
return {
|
||||
id,
|
||||
path: `/sessions/${id}.jsonl`,
|
||||
archived: false,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
describe("session archive tree planning", () => {
|
||||
it("finds candidates by full id or prefix", () => {
|
||||
const candidates = [candidate("abcdef"), candidate("xyz")];
|
||||
|
||||
expect(findArchiveCandidateByIdOrPrefix(candidates, "abcdef")?.id).toBe("abcdef");
|
||||
expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abcdef");
|
||||
expect(findArchiveCandidateByIdOrPrefix(candidates, "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("plans recursive descendants and separates already archived targets", () => {
|
||||
const root = candidate("root");
|
||||
const child = candidate("child", { parentSessionPath: root.path });
|
||||
const archivedChild = candidate("archived-child", { parentSessionPath: root.path, archived: true });
|
||||
const grandchild = candidate("grandchild", { parentSessionPath: archivedChild.path });
|
||||
const unrelated = candidate("unrelated");
|
||||
|
||||
const plan = planSessionArchiveTree(root, [root, child, archivedChild, grandchild, unrelated]);
|
||||
|
||||
expect(plan.targets.map((target) => target.id)).toEqual(["root", "child", "archived-child", "grandchild"]);
|
||||
expect(plan.unarchivedTargets.map((target) => target.id)).toEqual(["root", "child", "grandchild"]);
|
||||
expect(plan.skippedAlreadyArchivedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("stops traversal across cycles", () => {
|
||||
const root = candidate("root");
|
||||
const child = candidate("child", { parentSessionPath: root.path });
|
||||
const cycle = candidate("cycle", { path: root.path, parentSessionPath: child.path });
|
||||
|
||||
const plan = planSessionArchiveTree(root, [root, child, cycle]);
|
||||
|
||||
expect(plan.targets.map((target) => target.id)).toEqual(["root", "child"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface SessionArchiveTreeCandidate {
|
||||
id: string;
|
||||
path: string;
|
||||
archived: boolean;
|
||||
parentSessionPath?: string;
|
||||
}
|
||||
|
||||
export interface SessionArchiveTreePlan<T extends SessionArchiveTreeCandidate> {
|
||||
targets: T[];
|
||||
unarchivedTargets: T[];
|
||||
skippedAlreadyArchivedCount: number;
|
||||
}
|
||||
|
||||
export function findArchiveCandidateByIdOrPrefix<T extends SessionArchiveTreeCandidate>(candidates: readonly T[], sessionId: string): T | undefined {
|
||||
return candidates.find((candidate) => candidate.id === sessionId) ?? candidates.find((candidate) => candidate.id.startsWith(sessionId));
|
||||
}
|
||||
|
||||
export function planSessionArchiveTree<T extends SessionArchiveTreeCandidate>(root: T, candidates: readonly T[]): SessionArchiveTreePlan<T> {
|
||||
const targets = sessionArchiveSubtree(root, candidates);
|
||||
const unarchivedTargets = targets.filter((target) => !target.archived);
|
||||
return {
|
||||
targets,
|
||||
unarchivedTargets,
|
||||
skippedAlreadyArchivedCount: targets.length - unarchivedTargets.length,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionArchiveSubtree<T extends SessionArchiveTreeCandidate>(root: T, candidates: readonly T[]): T[] {
|
||||
const childrenByParentPath = new Map<string, T[]>();
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.parentSessionPath === undefined) continue;
|
||||
const children = childrenByParentPath.get(candidate.parentSessionPath) ?? [];
|
||||
children.push(candidate);
|
||||
childrenByParentPath.set(candidate.parentSessionPath, children);
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
const visit = (candidate: T, seenPaths: Set<string>) => {
|
||||
if (seenPaths.has(candidate.path)) return;
|
||||
result.push(candidate);
|
||||
const nextSeenPaths = new Set(seenPaths);
|
||||
nextSeenPaths.add(candidate.path);
|
||||
for (const child of childrenByParentPath.get(candidate.path) ?? []) visit(child, nextSeenPaths);
|
||||
};
|
||||
visit(root, new Set());
|
||||
return result;
|
||||
}
|
||||
@@ -142,6 +142,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/archive-tree`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.archiveTree(request.params.sessionId);
|
||||
} 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);
|
||||
|
||||
Reference in New Issue
Block a user