diff --git a/.changeset/archive-session-descendants.md b/.changeset/archive-session-descendants.md new file mode 100644 index 0000000..8405a82 --- /dev/null +++ b/.changeset/archive-session-descendants.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a session list action to archive a session together with its descendant sessions in the same workspace. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 30617bf..67ae508 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; +export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index cb8a7c9..4dcd48a 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -71,6 +71,7 @@ export const sessionsApi = { abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }), stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }), archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }), + archiveWithDescendants: (sessionId: string) => request(`/api/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }), restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }), detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }), authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index dda4575..a654c03 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -46,6 +46,11 @@ function parseUnknownArray(value: unknown): unknown[] { return value; } +function arrayOfString(value: unknown, key: string): string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error(`Expected string array field: ${key}`); + return value; +} + export function parseMessagePage(value: unknown): MessagePage { if (Array.isArray(value)) return { messages: value, start: 0, total: value.length }; const record = requireRecord(value); @@ -446,10 +451,18 @@ export function parseStopped(value: unknown): { stopped: true } { return { stopped: true }; } -export function parseArchived(value: unknown): { archived: true } { +export function parseArchived(value: unknown): ArchiveSessionsResponse { const record = requireRecord(value); if (record["archived"] !== true) throw new Error("Expected archived response"); - return { archived: true }; + const sessionIds = record["sessionIds"] === undefined ? undefined : arrayOfString(record["sessionIds"], "sessionIds"); + const archivedCount = optionalNumber(record, "archivedCount"); + const skippedAlreadyArchivedCount = optionalNumber(record, "skippedAlreadyArchivedCount"); + return { + archived: true, + ...(sessionIds === undefined ? {} : { sessionIds }), + ...(archivedCount === undefined ? {} : { archivedCount }), + ...(skippedAlreadyArchivedCount === undefined ? {} : { skippedAlreadyArchivedCount }), + }; } export function parseRestored(value: unknown): { restored: true } { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index d6a7938..181c3f1 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -474,6 +474,7 @@ export class PiWebApp extends LitElement { .onStart=${() => openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} + .onArchiveWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} .onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index a12393d..c60b0bb 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -40,6 +40,7 @@ export class SessionList extends LitElement { this.openMenuSessionId = undefined; }; @property({ attribute: false }) onArchive?: (session: SessionInfo) => void; + @property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void; @property({ attribute: false }) onRestore?: (session: SessionInfo) => void; @property({ attribute: false }) onDelete?: (session: SessionInfo) => void; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; @@ -71,13 +72,14 @@ export class SessionList extends LitElement { const activeRows = sessionRowsForActiveTree(this.sessions); const activeIds = new Set(activeRows.map((row) => row.session.id)); const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !activeIds.has(session.id))); + const descendantCounts = unarchivedDescendantCounts(this.sessions); return html`
${this.renderHeading(activeRows.length + archivedRows.length)} - ${this.collapsed ? null : activeRows.map((row) => this.renderSession(row))} + ${this.collapsed ? null : activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))} ${this.collapsed ? null : archivedRows.length > 0 ? html`

- ${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row)) : null} + ${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null} ` : null}
`; @@ -95,7 +97,7 @@ export class SessionList extends LitElement { `; } - private renderSession(row: SessionRow) { + private renderSession(row: SessionRow, descendantCount: number) { const { session } = row; const cappedDepth = Math.min(row.depth, 2); return html` @@ -119,7 +121,10 @@ export class SessionList extends LitElement { ? html`` : session.archived === true ? html`` - : html``} + : html` + + ${descendantCount > 0 ? html`` : null} + `} ` : null} @@ -127,6 +132,11 @@ export class SessionList extends LitElement { `; } + private confirmArchiveWithDescendants(session: SessionInfo, descendantCount: number): void { + const noun = descendantCount === 1 ? "descendant session" : "descendant sessions"; + if (confirm(`Archive “${sessionLabel(session)}” and ${String(descendantCount)} ${noun}?`)) this.onArchiveWithDescendants?.(session); + } + private toggleMenu(sessionId: string, target: EventTarget | null) { if (this.openMenuSessionId === sessionId) { this.openMenuSessionId = undefined; @@ -157,6 +167,31 @@ export class SessionList extends LitElement { static override styles = listStyles; } +function unarchivedDescendantCounts(sessions: SessionInfo[]): Map { + const childrenByParentPath = new Map(); + for (const session of sessions) { + if (session.parentSessionPath === undefined) continue; + const children = childrenByParentPath.get(session.parentSessionPath) ?? []; + children.push(session); + childrenByParentPath.set(session.parentSessionPath, children); + } + + const countFor = (session: SessionInfo, seenPaths: Set): number => { + if (seenPaths.has(session.path)) return 0; + const nextSeenPaths = new Set(seenPaths); + nextSeenPaths.add(session.path); + let count = 0; + for (const child of childrenByParentPath.get(session.path) ?? []) { + if (nextSeenPaths.has(child.path)) continue; + if (child.archived !== true) count += 1; + count += countFor(child, nextSeenPaths); + } + return count; + }; + + return new Map(sessions.map((session) => [session.id, countFor(session, new Set())])); +} + function sessionRowsForActiveTree(sessions: SessionInfo[]): SessionRow[] { const byPath = new Map(sessions.map((session) => [session.path, session])); const visible = new Set(); diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index c968924..90dac63 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -193,6 +193,32 @@ describe("SessionController", () => { expect(urlUpdates).toEqual([undefined]); }); + it("archives selected session descendants and selects the next active session", async () => { + const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: oldSession.path }; + const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession, childSession, nextSession] }; + const api: typeof defaultApi = { + ...defaultApi, + archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [oldSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }), + messages: () => Promise.resolve(emptyPage), + status: (sessionId) => Promise.resolve(status(sessionId)), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.selectSession(oldSession, { updateUrl: false }); + await controller.archiveSessionWithDescendants(oldSession); + + expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true }); + expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true }); + expect(state.selectedSession?.id).toBe(nextSession.id); + }); + it("forgets archived selections when the archived section collapse clears selection", async () => { const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] }; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index bbf4b6b..a3e856d 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -5,7 +5,7 @@ import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage"; import { ChatTranscriptStore } from "../chatTranscriptStore"; import { isShellInput } from "../inputModes"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; -import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; +import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; import type { GetState, SetState, UpdateUrl } from "./types"; const MESSAGE_PAGE_SIZE = 100; @@ -238,6 +238,23 @@ export class SessionController { } } + async archiveSessionWithDescendants(session = this.getState().selectedSession) { + if (!session || isCachedNewSessionInfo(session)) return; + try { + const response = await this.api.archiveWithDescendants(session.id); + const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id]; + const state = this.getState(); + const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString()); + const selectionChange = selectionAfterArchivingSessions(sessions, state.selectedSession?.id, archivedIds); + this.setState({ sessions }); + + if (selectionChange.type === "select") await this.selectSession(selectionChange.session); + else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true }); + } catch (error) { + this.setState({ error: String(error) }); + } + } + async deleteCachedNewSession(session = this.getState().selectedSession) { if (!isCachedNewSessionInfo(session)) return; void this.api.stop(session.id).catch(() => { diff --git a/src/client/src/controllers/sessionSelection.test.ts b/src/client/src/controllers/sessionSelection.test.ts index 1d656be..5457877 100644 --- a/src/client/src/controllers/sessionSelection.test.ts +++ b/src/client/src/controllers/sessionSelection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { SessionInfo } from "../api"; -import { InMemorySessionSelectionMemory, markSessionArchived, selectPreferredSession, selectionAfterArchivingSession, shouldDeselectAfterArchivedCollapse } from "./sessionSelection"; +import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse } from "./sessionSelection"; describe("selectPreferredSession", () => { it("prefers an explicit target session by id", () => { @@ -78,6 +78,14 @@ describe("markSessionArchived", () => { expect(next).toEqual([{ ...sessions[0], archived: true, archivedAt: "later" }, sessions[1]]); expect(sessions[0]?.archived).toBeUndefined(); }); + + it("marks multiple matching sessions archived", () => { + const sessions = [testSession("s1"), testSession("s2"), testSession("s3")]; + + const next = markSessionsArchived(sessions, ["s1", "s3"], "later"); + + expect(next).toEqual([{ ...sessions[0], archived: true, archivedAt: "later" }, sessions[1], { ...sessions[2], archived: true, archivedAt: "later" }]); + }); }); describe("shouldDeselectAfterArchivedCollapse", () => { @@ -112,6 +120,10 @@ describe("selectionAfterArchivingSession", () => { it("clears selection when no active session remains", () => { expect(selectionAfterArchivingSession([testSession("s1")], "s1", "s1")).toEqual({ type: "clear" }); }); + + it("clears selection when archiving a selected subtree with no active sessions left", () => { + expect(selectionAfterArchivingSessions([testSession("s1"), testSession("s2")], "s2", ["s1", "s2"])).toEqual({ type: "clear" }); + }); }); function testSession(id: string): SessionInfo { diff --git a/src/client/src/controllers/sessionSelection.ts b/src/client/src/controllers/sessionSelection.ts index 7d199e6..ca05f6b 100644 --- a/src/client/src/controllers/sessionSelection.ts +++ b/src/client/src/controllers/sessionSelection.ts @@ -47,12 +47,22 @@ export type ArchiveSelectionChange = | { type: "clear" }; export function markSessionArchived(sessions: SessionInfo[], sessionId: string, archivedAt: string): SessionInfo[] { - return sessions.map((session) => session.id === sessionId ? { ...session, archived: true, archivedAt } : session); + return markSessionsArchived(sessions, [sessionId], archivedAt); +} + +export function markSessionsArchived(sessions: SessionInfo[], sessionIds: readonly string[], archivedAt: string): SessionInfo[] { + const archivedIds = new Set(sessionIds); + return sessions.map((session) => archivedIds.has(session.id) ? { ...session, archived: true, archivedAt } : session); } export function selectionAfterArchivingSession(sessions: SessionInfo[], selectedSessionId: string | undefined, archivedSessionId: string): ArchiveSelectionChange { - if (selectedSessionId !== archivedSessionId) return { type: "unchanged" }; + return selectionAfterArchivingSessions(sessions, selectedSessionId, [archivedSessionId]); +} - const nextSession = sessions.find((session) => session.id !== archivedSessionId && session.archived !== true); +export function selectionAfterArchivingSessions(sessions: SessionInfo[], selectedSessionId: string | undefined, archivedSessionIds: readonly string[]): ArchiveSelectionChange { + if (selectedSessionId === undefined || !archivedSessionIds.includes(selectedSessionId)) return { type: "unchanged" }; + + const archivedIds = new Set(archivedSessionIds); + const nextSession = sessions.find((session) => !archivedIds.has(session.id) && session.archived !== true); return nextSession === undefined ? { type: "clear" } : { type: "select", session: nextSession }; } diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index effa111..fd24e04 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -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(), { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 96386bc..abf7479 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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; type ModelRegistryInstance = ReturnType; @@ -408,12 +416,32 @@ export class PiSessionService { async archive(sessionId: string): Promise { 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 { + 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 { 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 { + const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]); + const candidates = new Map(); + const archivedById = new Map(); + + 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 { @@ -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(); diff --git a/src/server/sessions/sessionArchiveTree.test.ts b/src/server/sessions/sessionArchiveTree.test.ts new file mode 100644 index 0000000..f34db41 --- /dev/null +++ b/src/server/sessions/sessionArchiveTree.test.ts @@ -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 { + 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"]); + }); +}); diff --git a/src/server/sessions/sessionArchiveTree.ts b/src/server/sessions/sessionArchiveTree.ts new file mode 100644 index 0000000..af0a2f8 --- /dev/null +++ b/src/server/sessions/sessionArchiveTree.ts @@ -0,0 +1,47 @@ +export interface SessionArchiveTreeCandidate { + id: string; + path: string; + archived: boolean; + parentSessionPath?: string; +} + +export interface SessionArchiveTreePlan { + targets: T[]; + unarchivedTargets: T[]; + skippedAlreadyArchivedCount: number; +} + +export function findArchiveCandidateByIdOrPrefix(candidates: readonly T[], sessionId: string): T | undefined { + return candidates.find((candidate) => candidate.id === sessionId) ?? candidates.find((candidate) => candidate.id.startsWith(sessionId)); +} + +export function planSessionArchiveTree(root: T, candidates: readonly T[]): SessionArchiveTreePlan { + const targets = sessionArchiveSubtree(root, candidates); + const unarchivedTargets = targets.filter((target) => !target.archived); + return { + targets, + unarchivedTargets, + skippedAlreadyArchivedCount: targets.length - unarchivedTargets.length, + }; +} + +function sessionArchiveSubtree(root: T, candidates: readonly T[]): T[] { + const childrenByParentPath = new Map(); + 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) => { + 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; +} diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 332eb43..4de5b55 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -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); diff --git a/src/server/types.ts b/src/server/types.ts index ff3f853..633440a 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -2,6 +2,7 @@ export type { Project, Workspace, SessionInfo as ClientSession, + ArchiveSessionsResponse as ClientArchiveSessionsResponse, MessagePage as ClientMessagePage, SessionStatus as ClientSessionStatus, SessionModel as ClientSessionModel, diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 58394d4..266f61b 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -30,6 +30,13 @@ export interface SessionInfo { archivedAt?: string; } +export interface ArchiveSessionsResponse { + archived: true; + sessionIds?: string[]; + archivedCount?: number; + skippedAlreadyArchivedCount?: number; +} + export interface SessionActivity { sessionId: string; phase: "active" | "idle" | "error";