feat: add server session queue clearing

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:24:14 +02:00
parent 9c62613fb6
commit a1f749cdb6
21 changed files with 735 additions and 23 deletions
+67 -1
View File
@@ -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 { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
@@ -165,6 +165,55 @@ describe("session routes", () => {
}
});
it("clears a session queue with workspace context and returns fresh status", 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 response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/queue/clear", payload: { cwd: requestCwd } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
sessionId: "session-1",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
});
expect(routeService.clearQueueCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("maps archived queue-clear failures to a mutation error without requiring a body", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
routeService.clearQueueError = new Error("Archived sessions are read-only. Restore the session to continue.");
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/queue/clear" });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Archived sessions are read-only. Restore the session to continue." });
expect(routeService.clearQueueCalls).toEqual(["session-1"]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("normalizes cleanup requests for preview and execute routes", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
@@ -252,12 +301,14 @@ describe("session routes", () => {
class CapturingRouteSessionService extends PiSessionService {
readonly calls: unknown[] = [];
readonly reloadCalls: (string | PiSessionRef)[] = [];
readonly clearQueueCalls: (string | PiSessionRef)[] = [];
messagesResponse: unknown[] | MessagePage = [];
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
reloadError: Error | undefined;
clearQueueError: Error | undefined;
constructor(eventHub: SessionEventHub) {
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
@@ -289,6 +340,21 @@ class CapturingRouteSessionService extends PiSessionService {
return Promise.resolve();
}
override clearQueue(lookup: string | PiSessionRef): Promise<SessionStatus> {
this.clearQueueCalls.push(lookup);
if (this.clearQueueError !== undefined) return Promise.reject(this.clearQueueError);
return Promise.resolve({
sessionId: sessionIdFromLookup(lookup),
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
});
}
override messages(): Promise<unknown[] | MessagePage> {
return Promise.resolve(this.messagesResponse);
}