Archived
feat: archive session descendants
This commit is contained in:
@@ -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.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
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";
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const sessionsApi = {
|
|||||||
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
||||||
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
||||||
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { 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" }),
|
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||||
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
|
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
|
||||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => {
|
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => {
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null;
|
return typeof value === "object" && value !== null;
|
||||||
@@ -46,6 +46,11 @@ function parseUnknownArray(value: unknown): unknown[] {
|
|||||||
return value;
|
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 {
|
export function parseMessagePage(value: unknown): MessagePage {
|
||||||
if (Array.isArray(value)) return { messages: value, start: 0, total: value.length };
|
if (Array.isArray(value)) return { messages: value, start: 0, total: value.length };
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
@@ -446,10 +451,18 @@ export function parseStopped(value: unknown): { stopped: true } {
|
|||||||
return { stopped: true };
|
return { stopped: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseArchived(value: unknown): { archived: true } {
|
export function parseArchived(value: unknown): ArchiveSessionsResponse {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
if (record["archived"] !== true) throw new Error("Expected archived response");
|
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 } {
|
export function parseRestored(value: unknown): { restored: true } {
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ export class PiWebApp extends LitElement {
|
|||||||
.onStart=${() => openChatAfter(() => this.sessions.startSession())}
|
.onStart=${() => openChatAfter(() => this.sessions.startSession())}
|
||||||
.onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
|
.onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
|
||||||
.onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
.onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
||||||
|
.onArchiveWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
|
||||||
.onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
|
.onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
|
||||||
.onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
|
.onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
|
||||||
.onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
.onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export class SessionList extends LitElement {
|
|||||||
this.openMenuSessionId = undefined;
|
this.openMenuSessionId = undefined;
|
||||||
};
|
};
|
||||||
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
|
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
|
||||||
|
@property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void;
|
||||||
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
|
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
|
||||||
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
|
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
|
||||||
@property({ attribute: false }) onDetachParent?: (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 activeRows = sessionRowsForActiveTree(this.sessions);
|
||||||
const activeIds = new Set(activeRows.map((row) => row.session.id));
|
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 archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !activeIds.has(session.id)));
|
||||||
|
const descendantCounts = unarchivedDescendantCounts(this.sessions);
|
||||||
return html`
|
return html`
|
||||||
<section>
|
<section>
|
||||||
${this.renderHeading(activeRows.length + archivedRows.length)}
|
${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.collapsed ? null : archivedRows.length > 0 ? html`
|
||||||
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
|
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
|
||||||
${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}
|
` : null}
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
@@ -95,7 +97,7 @@ export class SessionList extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSession(row: SessionRow) {
|
private renderSession(row: SessionRow, descendantCount: number) {
|
||||||
const { session } = row;
|
const { session } = row;
|
||||||
const cappedDepth = Math.min(row.depth, 2);
|
const cappedDepth = Math.min(row.depth, 2);
|
||||||
return html`
|
return html`
|
||||||
@@ -119,7 +121,10 @@ export class SessionList extends LitElement {
|
|||||||
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
||||||
: session.archived === true
|
: session.archived === true
|
||||||
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
|
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
|
||||||
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
|
: html`
|
||||||
|
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||||
|
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||||
|
`}
|
||||||
</div>
|
</div>
|
||||||
` : null}
|
` : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -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) {
|
private toggleMenu(sessionId: string, target: EventTarget | null) {
|
||||||
if (this.openMenuSessionId === sessionId) {
|
if (this.openMenuSessionId === sessionId) {
|
||||||
this.openMenuSessionId = undefined;
|
this.openMenuSessionId = undefined;
|
||||||
@@ -157,6 +167,31 @@ export class SessionList extends LitElement {
|
|||||||
static override styles = listStyles;
|
static override styles = listStyles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number> {
|
||||||
|
const childrenByParentPath = new Map<string, SessionInfo[]>();
|
||||||
|
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<string>): 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[] {
|
function sessionRowsForActiveTree(sessions: SessionInfo[]): SessionRow[] {
|
||||||
const byPath = new Map(sessions.map((session) => [session.path, session]));
|
const byPath = new Map(sessions.map((session) => [session.path, session]));
|
||||||
const visible = new Set<string>();
|
const visible = new Set<string>();
|
||||||
|
|||||||
@@ -193,6 +193,32 @@ describe("SessionController", () => {
|
|||||||
expect(urlUpdates).toEqual([undefined]);
|
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 () => {
|
it("forgets archived selections when the archived section collapse clears selection", async () => {
|
||||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
|
|||||||
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||||
import { isShellInput } from "../inputModes";
|
import { isShellInput } from "../inputModes";
|
||||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
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";
|
import type { GetState, SetState, UpdateUrl } from "./types";
|
||||||
|
|
||||||
const MESSAGE_PAGE_SIZE = 100;
|
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) {
|
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
||||||
if (!isCachedNewSessionInfo(session)) return;
|
if (!isCachedNewSessionInfo(session)) return;
|
||||||
void this.api.stop(session.id).catch(() => {
|
void this.api.stop(session.id).catch(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { SessionInfo } from "../api";
|
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", () => {
|
describe("selectPreferredSession", () => {
|
||||||
it("prefers an explicit target session by id", () => {
|
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(next).toEqual([{ ...sessions[0], archived: true, archivedAt: "later" }, sessions[1]]);
|
||||||
expect(sessions[0]?.archived).toBeUndefined();
|
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", () => {
|
describe("shouldDeselectAfterArchivedCollapse", () => {
|
||||||
@@ -112,6 +120,10 @@ describe("selectionAfterArchivingSession", () => {
|
|||||||
it("clears selection when no active session remains", () => {
|
it("clears selection when no active session remains", () => {
|
||||||
expect(selectionAfterArchivingSession([testSession("s1")], "s1", "s1")).toEqual({ type: "clear" });
|
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 {
|
function testSession(id: string): SessionInfo {
|
||||||
|
|||||||
@@ -47,12 +47,22 @@ export type ArchiveSelectionChange =
|
|||||||
| { type: "clear" };
|
| { type: "clear" };
|
||||||
|
|
||||||
export function markSessionArchived(sessions: SessionInfo[], sessionId: string, archivedAt: string): SessionInfo[] {
|
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 {
|
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 };
|
return nextSession === undefined ? { type: "clear" } : { type: "select", session: nextSession };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,46 @@ describe("PiSessionService", () => {
|
|||||||
await service.dispose();
|
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 () => {
|
it("reconciles workspace activity when listing only archived sessions", async () => {
|
||||||
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import {
|
|||||||
type CreateAgentSessionRuntimeFactory,
|
type CreateAgentSessionRuntimeFactory,
|
||||||
type EditToolDetails,
|
type EditToolDetails,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} 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 { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
||||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||||
import { SessionCommandService } from "./sessionCommandService.js";
|
import { SessionCommandService } from "./sessionCommandService.js";
|
||||||
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.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 { ActiveSession } from "./sessionRuntimeStore.js";
|
||||||
import type { AuthChange } from "./authService.js";
|
import type { AuthChange } from "./authService.js";
|
||||||
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||||
@@ -46,6 +47,13 @@ interface PiSessionListEntry {
|
|||||||
name?: string;
|
name?: string;
|
||||||
parentSessionPath?: string;
|
parentSessionPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceArchiveCandidate extends SessionArchiveTreeCandidate {
|
||||||
|
cwd: string;
|
||||||
|
listEntry?: PiSessionListEntry;
|
||||||
|
activeSession?: PiAgentSession;
|
||||||
|
}
|
||||||
|
|
||||||
type AgentModel = Model<Api>;
|
type AgentModel = Model<Api>;
|
||||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||||
|
|
||||||
@@ -408,12 +416,32 @@ export class PiSessionService {
|
|||||||
|
|
||||||
async archive(sessionId: string): Promise<void> {
|
async archive(sessionId: string): Promise<void> {
|
||||||
const session = await this.getOrOpen(sessionId);
|
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);
|
const archiveInput = await this.archiveInputForSession(session);
|
||||||
await this.closeActive(session.sessionId);
|
await this.closeActive(session.sessionId);
|
||||||
await this.archiveStore.archive(archiveInput);
|
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> {
|
async restore(sessionId: string): Promise<void> {
|
||||||
await this.closeActive(sessionId);
|
await this.closeActive(sessionId);
|
||||||
await this.archiveStore.restore(sessionId);
|
await this.archiveStore.restore(sessionId);
|
||||||
@@ -465,16 +493,41 @@ export class PiSessionService {
|
|||||||
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
|
if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted");
|
||||||
const listed = (await this.sessionManager.list(cwd)).find((candidate) => candidate.id === session.sessionId);
|
const listed = (await this.sessionManager.list(cwd)).find((candidate) => candidate.id === session.sessionId);
|
||||||
if (listed !== undefined) return archiveInputFromListEntry(listed);
|
if (listed !== undefined) return archiveInputFromListEntry(listed);
|
||||||
return {
|
return archiveInputFromActiveSession(session);
|
||||||
sessionId: session.sessionId,
|
}
|
||||||
cwd,
|
|
||||||
path: sessionFile,
|
private async workspaceArchiveCandidates(cwd: string): Promise<WorkspaceArchiveCandidate[]> {
|
||||||
created: new Date().toISOString(),
|
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
|
||||||
modified: new Date().toISOString(),
|
const candidates = new Map<string, WorkspaceArchiveCandidate>();
|
||||||
messageCount: session.messages.length,
|
const archivedById = new Map<string, ArchivedSessionRecord>();
|
||||||
firstMessage: "",
|
|
||||||
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
|
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[]> {
|
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 {
|
function clientSessionFromArchivedRecord(record: ArchivedSessionRecord, fallback: PiSessionListEntry | undefined): ClientSession | undefined {
|
||||||
const path = record.originalPath ?? fallback?.path;
|
const path = record.originalPath ?? fallback?.path;
|
||||||
const created = record.created ?? fallback?.created.toISOString();
|
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) => {
|
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
await sessions.restore(request.params.sessionId);
|
await sessions.restore(request.params.sessionId);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type {
|
|||||||
Project,
|
Project,
|
||||||
Workspace,
|
Workspace,
|
||||||
SessionInfo as ClientSession,
|
SessionInfo as ClientSession,
|
||||||
|
ArchiveSessionsResponse as ClientArchiveSessionsResponse,
|
||||||
MessagePage as ClientMessagePage,
|
MessagePage as ClientMessagePage,
|
||||||
SessionStatus as ClientSessionStatus,
|
SessionStatus as ClientSessionStatus,
|
||||||
SessionModel as ClientSessionModel,
|
SessionModel as ClientSessionModel,
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export interface SessionInfo {
|
|||||||
archivedAt?: string;
|
archivedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ArchiveSessionsResponse {
|
||||||
|
archived: true;
|
||||||
|
sessionIds?: string[];
|
||||||
|
archivedCount?: number;
|
||||||
|
skippedAlreadyArchivedCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionActivity {
|
export interface SessionActivity {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
phase: "active" | "idle" | "error";
|
phase: "active" | "idle" | "error";
|
||||||
|
|||||||
Reference in New Issue
Block a user