fix: preserve legacy session route lookups

This commit is contained in:
Federico Jaramillo Martinez
2026-06-10 22:42:55 +02:00
parent 71510444c4
commit b99143f757
13 changed files with 350 additions and 129 deletions
@@ -60,6 +60,16 @@ describe("SessionDirResolver", () => {
});
describe("Pi session manager gateway", () => {
it("lists legacy id-only sessions from the default Pi session store", async () => {
const otherCwd = join(tempDir, "other-workspace");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd);
await writeSessionFile(defaultPiSessionDir(otherCwd, agentDir), "session-b", otherCwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
});
it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => {
const sharedSessionDir = join(tempDir, "shared-sessions");
const otherCwd = join(tempDir, "other-workspace");
+23 -1
View File
@@ -1,3 +1,5 @@
import type { Dirent } from "node:fs";
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { getAgentDir, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
@@ -27,6 +29,10 @@ export class SessionDirResolver {
this.env = options.env ?? process.env;
}
defaultSessionsRoot(): string {
return defaultPiSessionsRoot(this.agentDir);
}
resolve(cwd: string): SessionDirResolution {
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
if (envSessionDir !== undefined && envSessionDir !== "") {
@@ -61,6 +67,10 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
return SessionManager.create(cwd, resolution.sessionDir);
}
listAll(): Promise<PiSessionListEntry[]> {
return listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot());
}
open(path: string): PiSessionManager {
return SessionManager.open(path, dirname(path));
}
@@ -70,6 +80,19 @@ export async function listSessionsInDir(sessionDir: string): Promise<PiSessionLi
return SessionManager.list("", sessionDir);
}
export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessionsRoot()): Promise<PiSessionListEntry[]> {
let entries: Dirent[];
try {
entries = await readdir(storeRoot, { withFileTypes: true });
} catch {
return [];
}
const sessionDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => join(storeRoot, entry.name));
const sessions = (await Promise.all(sessionDirs.map((dir) => listSessionsInDir(dir)))).flat();
return sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
}
export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cwd: string): PiSessionListEntry[] {
return sessions.filter((session) => session.cwd === cwd);
}
@@ -97,4 +120,3 @@ function expandTildePath(path: string): string {
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
return path;
}
@@ -164,6 +164,27 @@ describe("PiSessionService", () => {
expect(fake.calls.dispose).toBe(1);
});
it("opens legacy id-only lookups from the default session store gateway", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("legacy-session");
const open = vi.fn(() => fakeSessionManager());
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([sessionRecord("legacy-session")]),
open,
},
heartbeatIntervalMs: 60_000,
});
await expect(service.status("legacy")).resolves.toMatchObject({ sessionId: "legacy-session" });
expect(open).toHaveBeenCalledWith("/sessions/legacy-session.jsonl");
await service.dispose();
});
it("binds extensions again when the SDK runtime replaces the active session", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("session-1");
+9 -2
View File
@@ -103,6 +103,12 @@ export interface PiSessionManager {
export interface PiSessionManagerGateway {
list(cwd: string): Promise<PiSessionListEntry[]>;
create(cwd: string): PiSessionManager;
/**
* Legacy id-only lookup surface for older clients. This intentionally searches
* only Pi's default session store, because custom session directories require
* a cwd-scoped lookup.
*/
listAll?(): Promise<PiSessionListEntry[]>;
open(path: string): PiSessionManager;
}
@@ -671,8 +677,9 @@ export class PiSessionService {
const archived = await this.getArchived(ref);
if (archived?.archivePath !== undefined) return this.create(this.sessionManager.open(archived.archivePath), archived.cwd);
if (!isPiSessionRef(ref)) throw new Error("Session not found");
const match = (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id));
const match = isPiSessionRef(ref)
? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
: (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
if (!match) throw new Error("Session not found");
return this.create(this.sessionManager.open(match.path), match.cwd);
}
+73 -2
View File
@@ -2,7 +2,7 @@ import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
let app: FastifyInstance;
@@ -25,14 +25,81 @@ afterEach(async () => {
describe("session routes", () => {
it("rejects prompt payloads that omit text without opening a session", async () => {
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { cwd: "/repo", body: "Build the thing" } });
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Prompt text is required" });
expect(sessionManager.calls).toEqual({ create: 0, list: 0, listAll: 0, open: 0 });
});
it("keeps legacy per-session routes usable without cwd", 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 statusResponse = await routeApp.inject({ method: "GET", url: "/sessions/session-1/status" });
const promptResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { text: "hello" } });
expect(statusResponse.statusCode).toBe(200);
expect(promptResponse.statusCode).toBe(200);
expect(routeService.calls).toEqual(["session-1", { lookup: "session-1", text: "hello" }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("passes cwd when per-session routes include workspace context", 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 statusResponse = await routeApp.inject({ method: "GET", url: `/sessions/session-1/status?cwd=${encodeURIComponent("/repo")}` });
const promptResponse = await routeApp.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { cwd: "/repo", text: "hello" } });
expect(statusResponse.statusCode).toBe(200);
expect(promptResponse.statusCode).toBe(200);
expect(routeService.calls).toEqual([{ id: "session-1", cwd: "/repo" }, { lookup: { id: "session-1", cwd: "/repo" }, text: "hello" }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
});
class CapturingRouteSessionService extends PiSessionService {
readonly calls: unknown[] = [];
constructor(eventHub: SessionEventHub) {
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
}
override status(lookup: string | PiSessionRef) {
this.calls.push(lookup);
return Promise.resolve({
sessionId: sessionIdFromLookup(lookup),
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
});
}
override prompt(lookup: string | PiSessionRef, text: unknown): Promise<void> {
this.calls.push({ lookup, text });
return Promise.resolve();
}
}
class RejectingSessionManager implements PiSessionManagerGateway {
readonly calls = { create: 0, list: 0, listAll: 0, open: 0 };
@@ -56,3 +123,7 @@ class RejectingSessionManager implements PiSessionManagerGateway {
throw new Error("Session manager should not open sessions for invalid prompt payloads");
}
}
function sessionIdFromLookup(lookup: string | PiSessionRef): string {
return typeof lookup === "string" ? lookup : lookup.id;
}
+94 -80
View File
@@ -2,6 +2,8 @@ import type { FastifyInstance } from "fastify";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
type SessionLookup = string | PiSessionRef;
interface SessionQuery {
cwd?: string;
}
@@ -11,8 +13,6 @@ interface MessageQuery extends SessionQuery {
limit?: string;
}
class SessionRouteValidationError extends Error {}
interface PromptRequestBody {
cwd?: unknown;
text?: unknown;
@@ -25,9 +25,10 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
return sessions.list(request.query.cwd);
});
app.post<{ Body: { cwd: string } }>(`${prefix}/sessions`, async (request, reply) => {
app.post<{ Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
try {
return await sessions.start(request.body.cwd);
const body = requireRecord(request.body);
return await sessions.start(requireString(body, "cwd"));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
@@ -36,189 +37,185 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
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)) };
return await sessions.messages(sessionRefFromQuery(request.params.sessionId, request.query), page);
return await sessions.messages(sessionLookupFromQuery(request.params.sessionId, request.query), page);
} catch (error) {
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
return reply.code(404).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/status`, async (request, reply) => {
try {
return await sessions.status(sessionRefFromQuery(request.params.sessionId, request.query));
return await sessions.status(sessionLookupFromQuery(request.params.sessionId, request.query));
} catch (error) {
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
return reply.code(404).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/models`, async (request, reply) => {
try {
return { models: await sessions.availableModels(sessionRefFromQuery(request.params.sessionId, request.query)) };
return { models: await sessions.availableModels(sessionLookupFromQuery(request.params.sessionId, request.query)) };
} catch (error) {
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
return reply.code(404).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; provider?: unknown; modelId?: unknown } }>(`${prefix}/sessions/:sessionId/model`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; provider?: unknown; modelId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/model`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.setModel(sessionRefFromBody(request.params.sessionId, body), requireString(body, "provider"), requireString(body, "modelId"));
const body = optionalRecord(request.body);
return await sessions.setModel(sessionLookupFromBody(request.params.sessionId, body), requireString(body, "provider"), requireString(body, "modelId"));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; direction?: "forward" | "backward" } }>(`${prefix}/sessions/:sessionId/model/cycle`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; direction?: "forward" | "backward" } | undefined }>(`${prefix}/sessions/:sessionId/model/cycle`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const body = optionalRecord(request.body);
const direction = body["direction"];
if (direction !== undefined && direction !== "forward" && direction !== "backward") throw new Error("direction must be forward or backward");
return await sessions.cycleModel(sessionRefFromBody(request.params.sessionId, body), direction ?? "forward");
return await sessions.cycleModel(sessionLookupFromBody(request.params.sessionId, body), direction ?? "forward");
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/thinking-levels`, async (request, reply) => {
try {
return { levels: await sessions.availableThinkingLevels(sessionRefFromQuery(request.params.sessionId, request.query)) };
return { levels: await sessions.availableThinkingLevels(sessionLookupFromQuery(request.params.sessionId, request.query)) };
} catch (error) {
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
return reply.code(404).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; level?: unknown } }>(`${prefix}/sessions/:sessionId/thinking-level`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; level?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/thinking-level`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.setThinkingLevel(sessionRefFromBody(request.params.sessionId, body), requireThinkingLevel(body["level"]));
const body = optionalRecord(request.body);
return await sessions.setThinkingLevel(sessionLookupFromBody(request.params.sessionId, body), requireThinkingLevel(body["level"]));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/thinking-level/cycle`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/thinking-level/cycle`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.cycleThinkingLevel(sessionRefFromBody(request.params.sessionId, body));
const body = optionalRecord(request.body);
return await sessions.cycleThinkingLevel(sessionLookupFromBody(request.params.sessionId, body));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/commands`, async (request, reply) => {
try {
return await sessions.commands(sessionRefFromQuery(request.params.sessionId, request.query));
return await sessions.commands(sessionLookupFromQuery(request.params.sessionId, request.query));
} catch (error) {
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
return reply.code(404).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: PromptRequestBody | undefined }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
try {
const body = requireRecord(request.body);
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), body["text"], body["streamingBehavior"]);
const body = optionalRecord(request.body);
await sessions.prompt(sessionLookupFromBody(request.params.sessionId, body), body["text"], body["streamingBehavior"]);
return { accepted: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } }>(`${prefix}/sessions/:sessionId/shell`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/shell`, async (request, reply) => {
try {
const body = requireRecord(request.body);
await sessions.shell(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"));
const body = optionalRecord(request.body);
await sessions.shell(sessionLookupFromBody(request.params.sessionId, body), requireString(body, "text"));
return { accepted: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } }>(`${prefix}/sessions/:sessionId/commands/run`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/commands/run`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.runCommand(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"));
const body = optionalRecord(request.body);
return await sessions.runCommand(sessionLookupFromBody(request.params.sessionId, body), requireString(body, "text"));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; requestId?: unknown; value?: unknown } }>(`${prefix}/sessions/:sessionId/commands/respond`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; requestId?: unknown; value?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/commands/respond`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.respondToCommand(sessionRefFromBody(request.params.sessionId, body), requireString(body, "requestId"), requireString(body, "value"));
const body = optionalRecord(request.body);
return await sessions.respondToCommand(sessionLookupFromBody(request.params.sessionId, body), requireString(body, "requestId"), requireString(body, "value"));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/abort`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/abort`, async (request, reply) => {
try {
await sessions.abort(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
await sessions.abort(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
return { aborted: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/stop`, (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, (request, reply) => {
try {
sessions.stop(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
return { stopped: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/archive`, async (request, reply) => {
try {
await sessions.archive(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
await sessions.archive(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
return { archived: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/archive-tree`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/archive-tree`, async (request, reply) => {
try {
return await sessions.archiveTree(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
return await sessions.archiveTree(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/restore`, async (request, reply) => {
try {
await sessions.restore(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
await sessions.restore(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
return { restored: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.delete<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId`, async (request, reply) => {
try {
await sessions.deleteArchived(sessionRefFromQuery(request.params.sessionId, request.query));
await sessions.deleteArchived(sessionLookupFromQuery(request.params.sessionId, request.query));
return { deleted: true };
} catch (error) {
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
try {
await sessions.detachParent(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
await sessions.detachParent(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
return { detached: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
try {
const ref = sessionRefFromQuery(request.params.sessionId, request.query);
eventHub.add(ref.id, socket);
} catch {
socket.close();
}
const lookup = sessionLookupFromQuery(request.params.sessionId, request.query);
eventHub.add(sessionIdFromLookup(lookup), socket);
});
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
@@ -230,16 +227,28 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
});
}
function sessionRefFromQuery(id: string, query: SessionQuery): PiSessionRef {
const cwd = query.cwd;
if (cwd === undefined || cwd === "") throw new SessionRouteValidationError("cwd query parameter is required");
function sessionLookupFromQuery(id: string, query: SessionQuery): SessionLookup {
return sessionLookupFromCwd(id, query.cwd);
}
function sessionLookupFromBody(id: string, body: Record<string, unknown>): SessionLookup {
const cwd = body["cwd"];
if (cwd === undefined || cwd === "") return id;
if (typeof cwd !== "string") throw new Error("cwd field must be a string");
return { id, cwd };
}
function sessionRefFromBody(id: string, body: Record<string, unknown>): PiSessionRef {
const cwd = body["cwd"];
if (typeof cwd !== "string" || cwd === "") throw new Error("cwd field is required");
return { id, cwd };
function sessionLookupFromCwd(id: string, cwd: string | undefined): SessionLookup {
return cwd === undefined || cwd === "" ? id : { id, cwd };
}
function sessionIdFromLookup(lookup: SessionLookup): string {
return typeof lookup === "string" ? lookup : lookup.id;
}
function optionalRecord(value: unknown): Record<string, unknown> {
if (value === undefined || value === null) return {};
return requireRecord(value);
}
function requireRecord(value: unknown): Record<string, unknown> {
@@ -272,8 +281,13 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function readErrorStatus(error: unknown): 400 | 404 {
return error instanceof SessionRouteValidationError ? 400 : 404;
function mutationErrorStatus(error: unknown): 400 | 404 {
return isSessionNotFoundError(error) ? 404 : 400;
}
function isSessionNotFoundError(error: unknown): boolean {
const message = errorMessage(error);
return message === "Session not found" || message === "Archived session not found";
}
function isRecord(value: unknown): value is Record<string, unknown> {