feat: add bulk session mutations

This commit is contained in:
Federico Jaramillo Martinez
2026-06-29 13:34:03 +02:00
parent a87479815a
commit d165d69f73
18 changed files with 998 additions and 77 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, p
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+17
View File
@@ -78,6 +78,23 @@ describe("session API compatibility", () => {
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] });
});
it("posts bulk session mutation requests through the selected machine", async () => {
const archived = { archived: true, archivedSessionIds: ["s 1"], failures: [{ sessionId: "s 2", error: "busy" }], generatedAt: "now" };
const deleted = { deleted: true, deletedSessionIds: ["s 1"], failures: [], generatedAt: "later" };
const fetchMock = stubSequenceFetch([jsonResponse(archived), jsonResponse(deleted)]);
await expect(sessionsApi.archiveMany([{ id: "s 1", cwd: "/repo" }, "s 2"], "remote a")).resolves.toEqual(archived);
await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/bulk/archive");
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] });
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/bulk/delete-archived");
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
});
it("keeps legacy session-id calls free of cwd context", async () => {
const fetchMock = stubJsonFetch({ accepted: true });
+15 -1
View File
@@ -1,4 +1,4 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -32,6 +32,8 @@ import {
parseReloaded,
parseRestored,
parseSavedAttachments,
parseSessionBulkArchiveResponse,
parseSessionBulkDeleteArchivedResponse,
parseSessionCleanupExecuteResponse,
parseSessionCleanupPreviewResponse,
parseSessionInfo,
@@ -85,6 +87,16 @@ function sessionBody(session: SessionLookup, fields: Record<string, unknown> = {
return JSON.stringify(cwd === undefined || cwd === "" ? fields : { cwd, ...fields });
}
function sessionBulkMutationBody(sessions: readonly SessionLookup[]): string {
return JSON.stringify({ sessions: sessions.map(sessionBulkMutationRef) });
}
function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef {
const id = sessionId(session);
const cwd = sessionCwd(session);
return cwd === undefined || cwd === "" ? { id } : { id, cwd };
}
export const piWebApi = {
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
@@ -156,6 +168,8 @@ export const sessionsApi = {
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
@@ -48,6 +48,8 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)),
ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)),
ignoreParseFailure(sessionsApi.archiveMany([session], machineId)),
ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)),
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)),
+22 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
@@ -69,6 +69,27 @@ describe("API parsers", () => {
expect(() => parseSessionCleanupExecuteResponse({ generatedAt: "now", thresholds: {}, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: ["s1"], deletedSessionIds: [1] })).toThrow("Expected string array field: deletedSessionIds");
});
it("parses bulk session mutation responses", () => {
const failure = { sessionId: "busy", error: "Session is busy" };
expect(parseSessionBulkArchiveResponse({ archived: true, archivedSessionIds: ["s1"], failures: [failure], generatedAt: "now" })).toEqual({
archived: true,
archivedSessionIds: ["s1"],
failures: [failure],
generatedAt: "now",
});
expect(parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: ["s2"], failures: [], generatedAt: "later" })).toEqual({
deleted: true,
deletedSessionIds: ["s2"],
failures: [],
generatedAt: "later",
});
});
it("rejects malformed bulk session mutation responses", () => {
expect(() => parseSessionBulkArchiveResponse({ archived: true, archivedSessionIds: ["s1"], failures: [{ sessionId: "s2" }], generatedAt: "now" })).toThrow("Expected string field: error");
expect(() => parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: [1], failures: [], generatedAt: "now" })).toThrow("Expected string array field: deletedSessionIds");
});
it("validates session status including optional model and nullable context usage", () => {
expect(parseSessionStatus({
sessionId: "s1",
+28 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -212,6 +212,33 @@ export function parseSessionCleanupExecuteResponse(value: unknown): SessionClean
};
}
export function parseSessionBulkArchiveResponse(value: unknown): SessionBulkArchiveResponse {
const record = requireRecord(value);
if (record["archived"] !== true) throw new Error("Expected bulk archived response");
return {
archived: true,
archivedSessionIds: arrayOfString(record["archivedSessionIds"], "archivedSessionIds"),
failures: arrayOf(parseSessionBulkFailure)(record["failures"]),
generatedAt: requireString(record, "generatedAt"),
};
}
export function parseSessionBulkDeleteArchivedResponse(value: unknown): SessionBulkDeleteArchivedResponse {
const record = requireRecord(value);
if (record["deleted"] !== true) throw new Error("Expected bulk deleted response");
return {
deleted: true,
deletedSessionIds: arrayOfString(record["deletedSessionIds"], "deletedSessionIds"),
failures: arrayOf(parseSessionBulkFailure)(record["failures"]),
generatedAt: requireString(record, "generatedAt"),
};
}
function parseSessionBulkFailure(value: unknown): SessionBulkFailure {
const record = requireRecord(value);
return { sessionId: requireString(record, "sessionId"), error: requireString(record, "error") };
}
function parseSessionCleanupThresholds(value: unknown): SessionCleanupThresholds {
const record = requireRecord(value);
return {
@@ -649,6 +649,90 @@ describe("SessionController", () => {
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("uses true bulk archive when the selected runtime supports it and applies partial failures", async () => {
const failedSession = { ...oldSession, id: "failed-session", path: "/tmp/failed-session.jsonl" };
const archiveCalls: { ids: string[]; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
sessions: [oldSession, failedSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
};
const api: typeof defaultApi = {
...defaultApi,
archiveMany: (sessions, machineId) => {
archiveCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
return Promise.resolve({ archived: true, archivedSessionIds: [oldSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
},
archive: () => { throw new Error("single archive should not be used"); },
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.selectSession(oldSession, { updateUrl: false });
await controller.archiveSessions([oldSession, failedSession]);
expect(archiveCalls).toEqual([{ ids: [oldSession.id, failedSession.id], machineId: "local" }]);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
expect(state.sessions.find((session) => session.id === failedSession.id)?.archived).toBeUndefined();
expect(state.selectedSession?.id).toBe(failedSession.id);
expect(state.error).toBe("Archive failed for 1 session: failed-session: busy");
});
it("throttles per-session archive fallback when bulk mutations are unsupported", async () => {
const sessions = Array.from({ length: 6 }, (_value, index) => ({ ...oldSession, id: `session-${String(index)}`, path: `/tmp/session-${String(index)}.jsonl` }));
const resolvers: (() => void)[] = [];
const startedIds: string[] = [];
let activeCount = 0;
let maxActiveCount = 0;
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions };
const api: typeof defaultApi = {
...defaultApi,
archive: (session) => new Promise((resolve) => {
activeCount += 1;
maxActiveCount = Math.max(maxActiveCount, activeCount);
startedIds.push(sessionLookupId(session));
resolvers.push(() => {
activeCount -= 1;
resolve({ archived: true });
});
}),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
const archive = controller.archiveSessions(sessions);
await Promise.resolve();
expect(startedIds).toHaveLength(4);
resolvers.shift()?.();
await Promise.resolve();
await Promise.resolve();
expect(startedIds).toHaveLength(5);
for (const resolve of resolvers.splice(0)) resolve();
await Promise.resolve();
await Promise.resolve();
for (const resolve of resolvers.splice(0)) resolve();
await archive;
expect(maxActiveCount).toBe(4);
expect(state.sessions.every((session) => session.archived === true)).toBe(true);
});
it("deletes selected archived sessions in bulk and selects the next current session", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
@@ -684,6 +768,42 @@ describe("SessionController", () => {
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("uses true bulk delete when supported and keeps partial failures visible", async () => {
const deletedSession = { ...oldSession, archived: true, archivedAt: "later" };
const failedSession = { ...oldSession, id: "failed-archived", path: "/tmp/failed-archived.jsonl", archived: true, archivedAt: "later" };
const deleteCalls: { ids: string[]; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: deletedSession,
sessions: [deletedSession, failedSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
};
const api: typeof defaultApi = {
...defaultApi,
deleteArchivedMany: (sessions, machineId) => {
deleteCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
return Promise.resolve({ deleted: true, deletedSessionIds: [deletedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
},
deleteArchived: () => { throw new Error("single delete should not be used"); },
messages: () => Promise.resolve(emptyPage),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.deleteArchivedSessions([deletedSession, failedSession]);
expect(deleteCalls).toEqual([{ ids: [deletedSession.id, failedSession.id], machineId: "local" }]);
expect(state.sessions.map((session) => session.id)).toEqual([failedSession.id]);
expect(state.selectedSession?.id).toBe(failedSession.id);
expect(state.error).toBe("Delete failed for 1 session: failed-archived: busy");
});
it("applies cleanup execution results and refreshes the current workspace sessions", async () => {
const archivedAt = "2026-06-25T12:00:00.000Z";
const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" };
+99 -34
View File
@@ -1,4 +1,4 @@
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus } from "../api";
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus } from "../api";
import type { AppState } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages";
@@ -14,6 +14,7 @@ import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchiv
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
const MESSAGE_PAGE_SIZE = 100;
const BULK_FALLBACK_CONCURRENCY = 4;
export interface SessionEventSocket {
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
@@ -27,6 +28,12 @@ export interface SessionControllerDependencies {
transcripts?: ChatTranscriptStore;
}
interface BulkSessionMutationResult {
succeededIds: string[];
failures: string[];
generatedAt?: string;
}
export class SessionController {
private readonly socket: SessionEventSocket;
private readonly api: typeof defaultApi;
@@ -313,22 +320,22 @@ export class SessionController {
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived !== true && !isCachedNewSessionInfo(session));
if (candidates.length === 0) return;
const machineId = selectedMachineId(this.getState());
const results = await Promise.allSettled(candidates.map(async (session) => {
await this.api.archive(session, machineId);
return session.id;
}));
const archivedIds = fulfilledValues(results);
if (archivedIds.length > 0) {
const state = this.getState();
const nextSessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
this.setState({ sessions: nextSessions });
try {
const machineId = selectedMachineId(this.getState());
const { succeededIds: archivedIds, failures, generatedAt } = await this.archiveSessionBatch(candidates, machineId);
if (archivedIds.length > 0) {
const state = this.getState();
const nextSessions = markSessionsArchived(state.sessions, archivedIds, generatedAt ?? new Date().toISOString());
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
this.setState({ sessions: nextSessions });
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
}
this.applyBulkSessionFailures("Archive", failures);
} catch (error) {
this.setState({ error: `Archive failed: ${errorMessage(error)}` });
}
this.applyBulkSessionError("Archive", results);
}
async deleteArchivedSessions(sessions: readonly SessionInfo[]): Promise<void> {
@@ -341,23 +348,51 @@ export class SessionController {
this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." });
return;
}
const results = await Promise.allSettled(candidates.map(async (session) => {
try {
const { succeededIds: deletedIds, failures } = await this.deleteArchivedSessionBatch(candidates, machineId);
if (deletedIds.length > 0) {
const deletedIdSet = new Set(deletedIds);
const state = this.getState();
const nextSessions = state.sessions.filter((session) => !deletedIdSet.has(session.id));
this.setState({ sessions: nextSessions });
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0];
if (next !== undefined) await this.selectSession(next);
else this.deselectSession({ forgetRememberedSelection: true });
}
}
this.applyBulkSessionFailures("Delete", failures);
} catch (error) {
this.setState({ error: `Delete failed: ${errorMessage(error)}` });
}
}
private async archiveSessionBatch(sessions: readonly SessionInfo[], machineId: string): Promise<BulkSessionMutationResult> {
const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsBulkMutations)) {
const response = await this.api.archiveMany(sessions, machineId);
return { succeededIds: response.archivedSessionIds, failures: bulkFailureMessages(response.failures), generatedAt: response.generatedAt };
}
const results = await allSettledWithConcurrency(sessions, BULK_FALLBACK_CONCURRENCY, async (session) => {
await this.api.archive(session, machineId);
return session.id;
});
return { succeededIds: fulfilledValues(results), failures: settledSessionFailureMessages(sessions, results) };
}
private async deleteArchivedSessionBatch(sessions: readonly SessionInfo[], machineId: string): Promise<BulkSessionMutationResult> {
const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsBulkMutations)) {
const response = await this.api.deleteArchivedMany(sessions, machineId);
return { succeededIds: response.deletedSessionIds, failures: bulkFailureMessages(response.failures) };
}
const results = await allSettledWithConcurrency(sessions, BULK_FALLBACK_CONCURRENCY, async (session) => {
await this.api.deleteArchived(session, machineId);
return session.id;
}));
const deletedIds = fulfilledValues(results);
if (deletedIds.length > 0) {
const deletedIdSet = new Set(deletedIds);
const state = this.getState();
const nextSessions = state.sessions.filter((session) => !deletedIdSet.has(session.id));
this.setState({ sessions: nextSessions });
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0];
if (next !== undefined) await this.selectSession(next);
else this.deselectSession({ forgetRememberedSelection: true });
}
}
this.applyBulkSessionError("Delete", results);
});
return { succeededIds: fulfilledValues(results), failures: settledSessionFailureMessages(sessions, results) };
}
async applySessionCleanupResult(result: SessionCleanupExecuteResponse, machineId = selectedMachineId(this.getState())): Promise<void> {
@@ -581,8 +616,7 @@ export class SessionController {
}
}
private applyBulkSessionError(action: string, results: readonly PromiseSettledResult<string>[]): void {
const failures = rejectedReasons(results);
private applyBulkSessionFailures(action: string, failures: readonly string[]): void {
if (failures.length === 0) return;
this.setState({ error: `${action} failed for ${String(failures.length)} session${failures.length === 1 ? "" : "s"}: ${failures.join("; ")}` });
}
@@ -845,8 +879,39 @@ function fulfilledValues<T>(results: readonly PromiseSettledResult<T>[]): T[] {
return results.filter(isFulfilled).map((result) => result.value);
}
function rejectedReasons(results: readonly PromiseSettledResult<unknown>[]): string[] {
return results.filter(isRejected).map((result) => errorMessage(result.reason));
function bulkFailureMessages(failures: readonly SessionBulkFailure[]): string[] {
return failures.map((failure) => `${failure.sessionId}: ${failure.error}`);
}
function settledSessionFailureMessages(sessions: readonly SessionInfo[], results: readonly PromiseSettledResult<unknown>[]): string[] {
return results.flatMap((result, index) => {
if (!isRejected(result)) return [];
const sessionId = sessions[index]?.id ?? "unknown";
return [`${sessionId}: ${errorMessage(result.reason)}`];
});
}
async function allSettledWithConcurrency<T, R>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
const indexedItems = items.map((item, index) => ({ item, index }));
const results: PromiseSettledResult<R>[] = [];
let nextIndex = 0;
async function runWorker(): Promise<void> {
while (nextIndex < indexedItems.length) {
const entry = indexedItems[nextIndex];
if (entry === undefined) return;
nextIndex += 1;
try {
results[entry.index] = { status: "fulfilled", value: await worker(entry.item) };
} catch (reason) {
results[entry.index] = { status: "rejected", reason };
}
}
}
const workerCount = Math.min(Math.max(1, concurrency), indexedItems.length);
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
return results;
}
function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {