Archived
feat: add bulk session mutations
This commit is contained in:
@@ -452,6 +452,160 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("bulk archives inactive sessions by cwd without opening runtimes", async () => {
|
||||
const recordsByCwd = new Map([
|
||||
["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]],
|
||||
["/two", [sessionRecord("c", "/two")]],
|
||||
]);
|
||||
const listCalls: string[] = [];
|
||||
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
|
||||
archiveMany,
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => {
|
||||
listCalls.push(cwd);
|
||||
return Promise.resolve(recordsByCwd.get(cwd) ?? []);
|
||||
},
|
||||
open,
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]);
|
||||
|
||||
expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] });
|
||||
expect(listCalls).toEqual(["/one", "/two"]);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(archiveMany).toHaveBeenCalledTimes(1);
|
||||
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("bulk archive reports per-session failures without aborting other archives", async () => {
|
||||
const busy = fakeRuntime("busy", { isStreaming: true });
|
||||
let createCalls = 0;
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: () => {
|
||||
createCalls += 1;
|
||||
return Promise.resolve(busy.runtime);
|
||||
},
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
|
||||
archiveMany,
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("busy"));
|
||||
const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]);
|
||||
|
||||
expect(createCalls).toBe(1);
|
||||
expect(busy.calls.abort).toBe(0);
|
||||
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]);
|
||||
expect(result.archivedSessionIds).toEqual(["ok"]);
|
||||
expect(result.failures).toEqual([
|
||||
{ sessionId: "busy", error: "Stop current session activity before archiving" },
|
||||
{ sessionId: "missing", error: "Session not found" },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => {
|
||||
const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" };
|
||||
const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" };
|
||||
const busy = fakeRuntime("busy-archived", { isStreaming: true });
|
||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(busy.runtime),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([busyRecord, idleRecord]),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined),
|
||||
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.resolve(),
|
||||
deleteArchivedMany,
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([sessionRecord("unarchived")]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status(sessionRef("busy-archived"));
|
||||
const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]);
|
||||
|
||||
expect(busy.calls.abort).toBe(0);
|
||||
expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]);
|
||||
expect(result.deletedSessionIds).toEqual(["idle-archived"]);
|
||||
expect(result.failures).toEqual([
|
||||
{ sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" },
|
||||
{ sessionId: "unarchived", error: "Archived session not found" },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => {
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
|
||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||
const listCalls: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([
|
||||
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
|
||||
{ sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
|
||||
{ sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" },
|
||||
]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
|
||||
archiveMany,
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.resolve(),
|
||||
deleteArchivedMany,
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => {
|
||||
listCalls.push(cwd);
|
||||
return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]);
|
||||
},
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]);
|
||||
|
||||
expect(listCalls).toEqual(["/workspace"]);
|
||||
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
|
||||
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]);
|
||||
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]);
|
||||
expect(result.failures).toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("previews session cleanup without mutating and executes a recomputed plan", async () => {
|
||||
const archivedInputs: string[] = [];
|
||||
const deletedSessionIds: string[] = [];
|
||||
@@ -463,15 +617,17 @@ describe("PiSessionService", () => {
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([archived, otherArchived]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: (input) => {
|
||||
archivedInputs.push(input.sessionId);
|
||||
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" });
|
||||
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
|
||||
archiveMany: (inputs) => {
|
||||
archivedInputs.push(...inputs.map((input) => input.sessionId));
|
||||
return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" })));
|
||||
},
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: (sessionId) => {
|
||||
deletedSessionIds.push(sessionId);
|
||||
return Promise.resolve();
|
||||
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
|
||||
deleteArchivedMany: (sessionIds) => {
|
||||
deletedSessionIds.push(...sessionIds);
|
||||
return Promise.resolve([...sessionIds]);
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
@@ -504,6 +660,48 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => {
|
||||
const listCalls: string[] = [];
|
||||
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
|
||||
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([
|
||||
{ sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
|
||||
{ sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
|
||||
]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
|
||||
archiveMany,
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
|
||||
deleteArchivedMany,
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: (cwd) => {
|
||||
listCalls.push(cwd);
|
||||
return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]);
|
||||
},
|
||||
listAll: () => Promise.resolve([]),
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
|
||||
|
||||
expect(listCalls).toEqual(["/old-project"]);
|
||||
expect(archiveMany).toHaveBeenCalledTimes(1);
|
||||
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
|
||||
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]);
|
||||
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("skips busy active sessions during cleanup execution", async () => {
|
||||
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
|
||||
const archivedInputs: string[] = [];
|
||||
|
||||
@@ -27,7 +27,7 @@ import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
|
||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||
import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
|
||||
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
@@ -118,7 +118,11 @@ function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefi
|
||||
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & { deleteArchived?: (sessionId: string) => Promise<void> };
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & {
|
||||
archiveMany?: (sessions: readonly ArchiveSessionInput[]) => Promise<ArchivedSessionRecord[]>;
|
||||
deleteArchived?: (sessionId: string) => Promise<void>;
|
||||
deleteArchivedMany?: (sessionIds: readonly string[]) => Promise<string[]>;
|
||||
};
|
||||
|
||||
export type PiSessionRef = ClientSessionRef;
|
||||
|
||||
@@ -143,6 +147,19 @@ interface WorkspaceArchiveCandidate extends SessionArchiveTreeCandidate {
|
||||
activeSession?: PiAgentSession;
|
||||
}
|
||||
|
||||
interface BulkSessionLookupContext {
|
||||
sessionsByCwd: Map<string, PiSessionListEntry[]>;
|
||||
allSessions?: readonly PiSessionListEntry[];
|
||||
}
|
||||
|
||||
interface BulkArchivePlanItem {
|
||||
input: ArchiveSessionInput;
|
||||
}
|
||||
|
||||
interface BulkDeletePlanItem {
|
||||
record: ArchivedSessionRecord;
|
||||
}
|
||||
|
||||
type AgentModel = NonNullable<SpawnSessionInvocation["model"]>;
|
||||
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
|
||||
|
||||
@@ -421,10 +438,12 @@ export class PiSessionService {
|
||||
|
||||
async cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse> {
|
||||
const plan = await this.cleanupPlan(request);
|
||||
if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
|
||||
if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined) throw new Error("Archive store does not support deletion");
|
||||
|
||||
const archiveInputs: ArchiveSessionInput[] = [];
|
||||
const readyArchiveInputs: ArchiveSessionInput[] = [];
|
||||
const deleteRecords: ArchivedSessionRecord[] = [];
|
||||
const readyDeleteRecords: ArchivedSessionRecord[] = [];
|
||||
const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
|
||||
|
||||
for (const input of plan.archiveInputs) {
|
||||
@@ -433,9 +452,10 @@ export class PiSessionService {
|
||||
continue;
|
||||
}
|
||||
await this.closeActive(input.sessionId);
|
||||
await this.archiveStore.archive(input);
|
||||
archiveInputs.push(input);
|
||||
readyArchiveInputs.push(input);
|
||||
}
|
||||
await this.archiveStoreArchiveMany(readyArchiveInputs);
|
||||
archiveInputs.push(...readyArchiveInputs);
|
||||
|
||||
for (const record of plan.deleteRecords) {
|
||||
if (this.activeSessionHasWork(record.sessionId)) {
|
||||
@@ -443,10 +463,11 @@ export class PiSessionService {
|
||||
continue;
|
||||
}
|
||||
await this.closeActive(record.sessionId);
|
||||
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
|
||||
await this.archiveStore.deleteArchived?.(record.sessionId);
|
||||
deleteRecords.push(record);
|
||||
readyDeleteRecords.push(record);
|
||||
}
|
||||
await this.ensureArchivedRecordsMoved(readyDeleteRecords);
|
||||
const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
|
||||
deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
|
||||
|
||||
return summarizeSessionCleanupExecution({
|
||||
archiveInputs,
|
||||
@@ -1105,6 +1126,70 @@ export class PiSessionService {
|
||||
await this.archiveStore.archive(archiveInput);
|
||||
}
|
||||
|
||||
async archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
|
||||
const uniqueRefs = uniqueBulkSessionRefs(refs);
|
||||
const [archivedRecords, sessionContext] = await Promise.all([
|
||||
this.archiveStore.list(),
|
||||
this.bulkSessionLookupContext(uniqueRefs),
|
||||
]);
|
||||
const failures: SessionBulkFailure[] = [];
|
||||
const alreadyArchivedSessionIds: string[] = [];
|
||||
const planItems: BulkArchivePlanItem[] = [];
|
||||
|
||||
for (const ref of uniqueRefs) {
|
||||
const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
|
||||
if (archived !== undefined) {
|
||||
alreadyArchivedSessionIds.push(archived.sessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const active = this.activeForLookup(bulkRefToLookup(ref));
|
||||
const listed = findListedSessionForBulkRef(sessionContext, ref);
|
||||
const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
|
||||
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
||||
failures.push({ sessionId: resolvedSessionId, error: "Stop current session activity before archiving" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (listed !== undefined) {
|
||||
planItems.push({ input: archiveInputFromListEntry(listed) });
|
||||
} else if (active !== undefined) {
|
||||
planItems.push({ input: archiveInputFromActiveSession(active.runtime.session) });
|
||||
} else {
|
||||
failures.push({ sessionId: ref.id, error: "Session not found" });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const readyInputs: ArchiveSessionInput[] = [];
|
||||
for (const item of planItems) {
|
||||
try {
|
||||
await this.closeActive(item.input.sessionId);
|
||||
readyInputs.push(item.input);
|
||||
} catch (error: unknown) {
|
||||
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const archivedSessionIds = [...alreadyArchivedSessionIds];
|
||||
try {
|
||||
const archived = await this.archiveStoreArchiveMany(readyInputs);
|
||||
archivedSessionIds.push(...archived.map((record) => record.sessionId));
|
||||
} catch (error: unknown) {
|
||||
for (const input of readyInputs) failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
archivedSessionIds: uniqueStrings(archivedSessionIds),
|
||||
failures,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async archiveTree(ref: PiSessionLookup): Promise<ClientArchiveSessionsResponse> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
|
||||
@@ -1115,7 +1200,7 @@ export class PiSessionService {
|
||||
|
||||
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);
|
||||
await this.archiveStoreArchiveMany(archiveInputs);
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
@@ -1142,6 +1227,61 @@ export class PiSessionService {
|
||||
await this.archiveStore.deleteArchived(record.sessionId);
|
||||
}
|
||||
|
||||
async deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
|
||||
if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined) throw new Error("Archive store does not support deletion");
|
||||
|
||||
const uniqueRefs = uniqueBulkSessionRefs(refs);
|
||||
const archivedRecords = await this.archiveStore.list();
|
||||
const failures: SessionBulkFailure[] = [];
|
||||
const planItems: BulkDeletePlanItem[] = [];
|
||||
|
||||
for (const ref of uniqueRefs) {
|
||||
const record = findArchivedRecordForBulkRef(archivedRecords, ref);
|
||||
if (record === undefined) {
|
||||
failures.push({ sessionId: ref.id, error: "Archived session not found" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
|
||||
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
||||
failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
|
||||
continue;
|
||||
}
|
||||
planItems.push({ record });
|
||||
}
|
||||
|
||||
const readyRecords: ArchivedSessionRecord[] = [];
|
||||
for (const item of planItems) {
|
||||
try {
|
||||
await this.closeActive(item.record.sessionId);
|
||||
readyRecords.push(item.record);
|
||||
} catch (error: unknown) {
|
||||
failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
|
||||
failures.push(...moveFailures);
|
||||
const moveFailureIds = new Set(moveFailures.map((failure) => failure.sessionId));
|
||||
const deleteIds = readyRecords
|
||||
.map((record) => record.sessionId)
|
||||
.filter((sessionId) => !moveFailureIds.has(sessionId));
|
||||
|
||||
let deletedSessionIds: string[] = [];
|
||||
try {
|
||||
deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
|
||||
} catch (error: unknown) {
|
||||
for (const sessionId of deleteIds) failures.push({ sessionId, error: errorMessage(error) });
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
deletedSessionIds,
|
||||
failures,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async reload(ref: PiSessionLookup): Promise<void> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
@@ -1179,6 +1319,71 @@ export class PiSessionService {
|
||||
});
|
||||
}
|
||||
|
||||
private async bulkSessionLookupContext(refs: readonly SessionBulkMutationRef[]): Promise<BulkSessionLookupContext> {
|
||||
const cwdSet = new Set<string>();
|
||||
let needsAllSessions = false;
|
||||
for (const ref of refs) {
|
||||
if (ref.cwd === undefined) needsAllSessions = true;
|
||||
else cwdSet.add(ref.cwd);
|
||||
}
|
||||
|
||||
const [sessionsByCwd, allSessions] = await Promise.all([
|
||||
this.listSessionsByCwd([...cwdSet]),
|
||||
needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
|
||||
]);
|
||||
return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
|
||||
}
|
||||
|
||||
private async listSessionsByCwd(cwds: readonly string[]): Promise<Map<string, PiSessionListEntry[]>> {
|
||||
const uniqueCwds = uniqueStrings(cwds);
|
||||
const entries = await Promise.all(uniqueCwds.map(async (cwd) => [cwd, await this.sessionManager.list(cwd)] as const));
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
private async archiveStoreArchiveMany(inputs: readonly ArchiveSessionInput[]): Promise<ArchivedSessionRecord[]> {
|
||||
if (inputs.length === 0) return [];
|
||||
if (this.archiveStore.archiveMany !== undefined) return this.archiveStore.archiveMany(inputs);
|
||||
const records: ArchivedSessionRecord[] = [];
|
||||
for (const input of inputs) records.push(await this.archiveStore.archive(input));
|
||||
return records;
|
||||
}
|
||||
|
||||
private async archiveStoreDeleteArchivedMany(sessionIds: readonly string[]): Promise<string[]> {
|
||||
if (sessionIds.length === 0) return [];
|
||||
if (this.archiveStore.deleteArchivedMany !== undefined) return this.archiveStore.deleteArchivedMany(sessionIds);
|
||||
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
|
||||
for (const sessionId of sessionIds) await this.archiveStore.deleteArchived(sessionId);
|
||||
return [...sessionIds];
|
||||
}
|
||||
|
||||
private async moveLegacyArchivedRecordsForDelete(records: readonly ArchivedSessionRecord[]): Promise<SessionBulkFailure[]> {
|
||||
const legacyRecords = records.filter((record) => record.archivePath === undefined);
|
||||
if (legacyRecords.length === 0) return [];
|
||||
|
||||
let sessionsByCwd: Map<string, PiSessionListEntry[]>;
|
||||
try {
|
||||
sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
|
||||
} catch (error: unknown) {
|
||||
return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
|
||||
}
|
||||
|
||||
const moveInputs = legacyRecords
|
||||
.map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
|
||||
.filter(isDefined)
|
||||
.map(archiveInputFromListEntry);
|
||||
if (moveInputs.length === 0) return [];
|
||||
|
||||
try {
|
||||
await this.archiveStoreArchiveMany(moveInputs);
|
||||
return [];
|
||||
} catch (error: unknown) {
|
||||
const failedIds = new Set(moveInputs.map((input) => input.sessionId));
|
||||
return legacyRecords
|
||||
.filter((record) => failedIds.has(record.sessionId))
|
||||
.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupPlan(request: NormalizedSessionCleanupRequest) {
|
||||
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
|
||||
return planSessionCleanup({
|
||||
@@ -1224,7 +1429,20 @@ export class PiSessionService {
|
||||
private async ensureArchivedRecordMoved(record: ArchivedSessionRecord): Promise<ArchivedSessionRecord> {
|
||||
const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
|
||||
if (session === undefined) return record;
|
||||
return this.archiveStore.archive(archiveInputFromListEntry(session));
|
||||
const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
|
||||
return moved ?? record;
|
||||
}
|
||||
|
||||
private async ensureArchivedRecordsMoved(records: readonly ArchivedSessionRecord[]): Promise<void> {
|
||||
const legacyRecords = records.filter((record) => record.archivePath === undefined);
|
||||
if (legacyRecords.length === 0) return;
|
||||
|
||||
const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
|
||||
const moveInputs = legacyRecords
|
||||
.map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
|
||||
.filter(isDefined)
|
||||
.map(archiveInputFromListEntry);
|
||||
await this.archiveStoreArchiveMany(moveInputs);
|
||||
}
|
||||
|
||||
private async archiveInputForSession(session: PiAgentSession): Promise<ArchiveSessionInput> {
|
||||
@@ -1648,6 +1866,43 @@ function previewResponseFromPlan(plan: SessionCleanupPlan): ClientSessionCleanup
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueBulkSessionRefs(refs: readonly SessionBulkMutationRef[]): SessionBulkMutationRef[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: SessionBulkMutationRef[] = [];
|
||||
for (const ref of refs) {
|
||||
const key = `${ref.cwd ?? ""}\0${ref.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(ref);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function bulkRefToLookup(ref: SessionBulkMutationRef): PiSessionLookup {
|
||||
return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
|
||||
}
|
||||
|
||||
function findArchivedRecordForBulkRef(records: readonly ArchivedSessionRecord[], ref: SessionBulkMutationRef): ArchivedSessionRecord | undefined {
|
||||
return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
|
||||
}
|
||||
|
||||
function findListedSessionForBulkRef(context: BulkSessionLookupContext, ref: SessionBulkMutationRef): PiSessionListEntry | undefined {
|
||||
if (ref.cwd !== undefined) return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
|
||||
return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
|
||||
}
|
||||
|
||||
function findSessionByIdOrPrefix(sessions: readonly PiSessionListEntry[], sessionId: string): PiSessionListEntry | undefined {
|
||||
return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel {
|
||||
if (model === undefined) return {};
|
||||
const name = getString(model, "name");
|
||||
|
||||
@@ -71,6 +71,53 @@ describe("SessionArchiveStore", () => {
|
||||
expect(await exists(record.archivePath)).toBe(false);
|
||||
await expect(store.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("archives and permanently deletes sessions in batches", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-batch-"));
|
||||
tempRoots.push(root);
|
||||
const activeDir = join(root, "active");
|
||||
await mkdir(activeDir, { recursive: true });
|
||||
const sourceA = join(activeDir, "2026-01-01_a.jsonl");
|
||||
const sourceB = join(activeDir, "2026-01-01_b.jsonl");
|
||||
await writeFile(sourceA, "a\n", "utf8");
|
||||
await writeFile(sourceB, "b\n", "utf8");
|
||||
|
||||
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
|
||||
const records = await store.archiveMany([
|
||||
{
|
||||
sessionId: "a",
|
||||
cwd: "/workspace",
|
||||
path: sourceA,
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:01:00.000Z",
|
||||
messageCount: 1,
|
||||
firstMessage: "a",
|
||||
},
|
||||
{
|
||||
sessionId: "b",
|
||||
cwd: "/workspace",
|
||||
path: sourceB,
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:02:00.000Z",
|
||||
messageCount: 2,
|
||||
firstMessage: "b",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(records.map((record) => record.sessionId)).toEqual(["a", "b"]);
|
||||
expect(await exists(sourceA)).toBe(false);
|
||||
expect(await exists(sourceB)).toBe(false);
|
||||
await expect(store.list()).resolves.toMatchObject([{ sessionId: "a" }, { sessionId: "b" }]);
|
||||
|
||||
const archivePaths = records.map((record) => record.archivePath);
|
||||
if (archivePaths.some((path) => path === undefined)) throw new Error("Expected archive paths");
|
||||
await expect(store.deleteArchivedMany(["a", "b", "missing"])).resolves.toEqual(["a", "b"]);
|
||||
for (const archivePath of archivePaths) {
|
||||
if (archivePath === undefined) throw new Error("Expected archive path");
|
||||
expect(await exists(archivePath)).toBe(false);
|
||||
}
|
||||
await expect(store.list()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
|
||||
@@ -53,24 +53,39 @@ export class SessionArchiveStore {
|
||||
}
|
||||
|
||||
async archive(session: ArchiveSessionInput): Promise<ArchivedSessionRecord> {
|
||||
const [record] = await this.archiveMany([session]);
|
||||
if (record === undefined) throw new Error("Archive operation did not produce a record");
|
||||
return record;
|
||||
}
|
||||
|
||||
async archiveMany(sessions: readonly ArchiveSessionInput[]): Promise<ArchivedSessionRecord[]> {
|
||||
if (sessions.length === 0) return [];
|
||||
return this.exclusive(async () => {
|
||||
const data = await this.read();
|
||||
const existingIndex = data.sessions.findIndex((record) => record.sessionId === session.sessionId);
|
||||
const existing = existingIndex === -1 ? undefined : data.sessions[existingIndex];
|
||||
const archivePath = existing?.archivePath ?? this.archivePathFor(session);
|
||||
const record = archiveRecordFromInput(session, {
|
||||
archivedAt: existing?.archivedAt ?? new Date().toISOString(),
|
||||
originalPath: existing?.originalPath ?? session.path,
|
||||
archivePath,
|
||||
});
|
||||
const records: ArchivedSessionRecord[] = [];
|
||||
const filesToRemove: { source: string; archivePath: string }[] = [];
|
||||
|
||||
await copySessionFileToArchive(session.path, archivePath);
|
||||
for (const session of sessions) {
|
||||
const existingIndex = data.sessions.findIndex((record) => record.sessionId === session.sessionId);
|
||||
const existing = existingIndex === -1 ? undefined : data.sessions[existingIndex];
|
||||
const archivePath = existing?.archivePath ?? this.archivePathFor(session);
|
||||
const record = archiveRecordFromInput(session, {
|
||||
archivedAt: existing?.archivedAt ?? new Date().toISOString(),
|
||||
originalPath: existing?.originalPath ?? session.path,
|
||||
archivePath,
|
||||
});
|
||||
|
||||
await copySessionFileToArchive(session.path, archivePath);
|
||||
|
||||
if (existingIndex === -1) data.sessions.push(record);
|
||||
else data.sessions[existingIndex] = record;
|
||||
records.push(record);
|
||||
filesToRemove.push({ source: session.path, archivePath });
|
||||
}
|
||||
|
||||
if (existingIndex === -1) data.sessions.push(record);
|
||||
else data.sessions[existingIndex] = record;
|
||||
await this.write(data);
|
||||
await removeActiveSessionFile(session.path, archivePath);
|
||||
return record;
|
||||
for (const file of filesToRemove) await removeActiveSessionFile(file.source, file.archivePath);
|
||||
return records;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,14 +105,25 @@ export class SessionArchiveStore {
|
||||
}
|
||||
|
||||
async deleteArchived(sessionId: string): Promise<void> {
|
||||
await this.exclusive(async () => {
|
||||
const data = await this.read();
|
||||
const record = data.sessions.find((session) => session.sessionId === sessionId);
|
||||
if (record === undefined) return;
|
||||
await this.deleteArchivedMany([sessionId]);
|
||||
}
|
||||
|
||||
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
|
||||
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
|
||||
async deleteArchivedMany(sessionIds: readonly string[]): Promise<string[]> {
|
||||
const targetIds = uniqueStrings(sessionIds);
|
||||
if (targetIds.length === 0) return [];
|
||||
return this.exclusive(async () => {
|
||||
const data = await this.read();
|
||||
const targetIdSet = new Set(targetIds);
|
||||
const records = data.sessions.filter((session) => targetIdSet.has(session.sessionId));
|
||||
if (records.length === 0) return [];
|
||||
|
||||
for (const record of records) {
|
||||
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
|
||||
}
|
||||
const sessions = data.sessions.filter((session) => !targetIdSet.has(session.sessionId));
|
||||
await this.write({ sessions });
|
||||
const deletedIds = new Set(records.map((record) => record.sessionId));
|
||||
return targetIds.filter((sessionId) => deletedIds.has(sessionId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,6 +280,10 @@ function safeFileName(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "session";
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
|
||||
import type { SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
|
||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||
@@ -178,6 +178,49 @@ describe("session routes", () => {
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes bulk archive and delete requests with normalized session refs", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService(eventHub);
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const requestCwd = resolve("/repo");
|
||||
const archiveResponse = await routeApp.inject({ method: "POST", url: "/sessions/bulk/archive", payload: { sessions: [{ id: "s1", cwd: requestCwd }, { id: "s2" }] } });
|
||||
const deleteResponse = await routeApp.inject({ method: "POST", url: "/sessions/bulk/delete-archived", payload: { sessions: [{ id: "s1", cwd: requestCwd }] } });
|
||||
|
||||
expect(archiveResponse.statusCode).toBe(200);
|
||||
expect(archiveResponse.json()).toMatchObject({ archived: true, archivedSessionIds: ["s1", "s2"], failures: [] });
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
expect(deleteResponse.json()).toMatchObject({ deleted: true, deletedSessionIds: ["s1"], failures: [] });
|
||||
expect(routeService.bulkArchiveCalls).toEqual([[{ id: "s1", cwd: requestCwd }, { id: "s2" }]]);
|
||||
expect(routeService.bulkDeleteCalls).toEqual([[{ id: "s1", cwd: requestCwd }]]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed bulk mutation bodies before calling the service", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService(eventHub);
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const response = await routeApp.inject({ method: "POST", url: "/sessions/bulk/archive", payload: { sessions: [{ cwd: "/repo" }] } });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "id field must be a string" });
|
||||
expect(routeService.bulkArchiveCalls).toEqual([]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
class CapturingRouteSessionService extends PiSessionService {
|
||||
@@ -185,6 +228,8 @@ class CapturingRouteSessionService extends PiSessionService {
|
||||
readonly reloadCalls: (string | PiSessionRef)[] = [];
|
||||
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
||||
reloadError: Error | undefined;
|
||||
|
||||
constructor(eventHub: SessionEventHub) {
|
||||
@@ -201,6 +246,16 @@ class CapturingRouteSessionService extends PiSessionService {
|
||||
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] });
|
||||
}
|
||||
|
||||
override archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
|
||||
this.bulkArchiveCalls.push([...refs]);
|
||||
return Promise.resolve({ archived: true, archivedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
|
||||
}
|
||||
|
||||
override deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
|
||||
this.bulkDeleteCalls.push([...refs]);
|
||||
return Promise.resolve({ deleted: true, deletedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
|
||||
}
|
||||
|
||||
override reload(lookup: string | PiSessionRef): Promise<void> {
|
||||
this.reloadCalls.push(lookup);
|
||||
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { SessionCleanupRequest } from "../../shared/apiTypes.js";
|
||||
import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanupRequest } from "../../shared/apiTypes.js";
|
||||
import { normalizeRequestCwd } from "../workingDirectory.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
|
||||
@@ -64,6 +64,22 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: SessionBulkMutationRequest | undefined }>(`${prefix}/sessions/bulk/archive`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.archiveMany(bulkMutationRefsFromBody(request.body));
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: SessionBulkMutationRequest | undefined }>(`${prefix}/sessions/bulk/delete-archived`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.deleteArchivedMany(bulkMutationRefsFromBody(request.body));
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
|
||||
try {
|
||||
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
|
||||
@@ -281,6 +297,23 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
});
|
||||
}
|
||||
|
||||
function bulkMutationRefsFromBody(body: SessionBulkMutationRequest | undefined): SessionBulkMutationRef[] {
|
||||
const record = requireRecord(body);
|
||||
const sessions = record["sessions"];
|
||||
if (!Array.isArray(sessions)) throw new Error("sessions field must be an array");
|
||||
return sessions.map(parseBulkMutationRef);
|
||||
}
|
||||
|
||||
function parseBulkMutationRef(value: unknown): SessionBulkMutationRef {
|
||||
const record = requireRecord(value);
|
||||
const id = requireString(record, "id").trim();
|
||||
if (id === "") throw new Error("id field must not be empty");
|
||||
const cwd = record["cwd"];
|
||||
if (cwd === undefined || cwd === "") return { id };
|
||||
if (typeof cwd !== "string") throw new Error("cwd field must be a string");
|
||||
return { id, cwd: normalizeRequestCwd(cwd) };
|
||||
}
|
||||
|
||||
function sessionLookupFromQuery(id: string, query: SessionQuery): SessionLookup {
|
||||
return sessionLookupFromCwd(id, query.cwd);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user