Archived
Merge remote-tracking branch 'origin/main' into feat/docker-runtime-host-admin
# Conflicts: # src/client/src/api.ts # src/client/src/api/parsers.test.ts # src/shared/piWebStatusParsing.test.ts
This commit is contained in:
+219
-4
@@ -11,11 +11,13 @@ import { RemoteMachineRequestError, type MachineClient } from "./machines/machin
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { MachineStore } from "./machines/machineStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import type { PiPackageService } from "./piPackageService.js";
|
||||
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
|
||||
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -23,6 +25,7 @@ let tempDir: string;
|
||||
let projectDir: string;
|
||||
let remoteClient: MachineClient | undefined;
|
||||
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
||||
let piPackageRequests: CapturedPiPackageRequest[];
|
||||
let piWebConfig: PiWebConfigValues;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -30,6 +33,7 @@ beforeEach(async () => {
|
||||
projectDir = join(tempDir, "project");
|
||||
remoteClient = undefined;
|
||||
sessionDaemonRequests = [];
|
||||
piPackageRequests = [];
|
||||
piWebConfig = {};
|
||||
app = await buildApp({
|
||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||
@@ -52,6 +56,7 @@ beforeEach(async () => {
|
||||
}),
|
||||
sessionDaemon: fakeSessionDaemon(),
|
||||
config: fakeConfigService(),
|
||||
piPackages: fakePiPackageService(),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
|
||||
@@ -122,10 +127,10 @@ describe("buildApp", () => {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
||||
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
||||
},
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
@@ -133,7 +138,7 @@ describe("buildApp", () => {
|
||||
const runtime = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
|
||||
|
||||
expect(runtime.statusCode).toBe(200);
|
||||
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||
});
|
||||
|
||||
@@ -155,6 +160,103 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("filters remote selected-machine config reads to machine-safe keys", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
|
||||
body: piWebConfigResponse(fullPiWebConfig()),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/config` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
expect(response.json<PiWebConfigResponse>()).toEqual({
|
||||
...piWebConfigResponse(fullPiWebConfig()),
|
||||
config: selectedMachinePiWebConfig(),
|
||||
effectiveConfig: selectedMachinePiWebConfig(),
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/config");
|
||||
});
|
||||
|
||||
it("merges remote selected-machine config updates into the target machine config", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
|
||||
});
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } },
|
||||
});
|
||||
|
||||
const expectedMerged: PiWebConfigValues = {
|
||||
...fullPiWebConfig(),
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/remote"] },
|
||||
uploads: { defaultFolder: "remote/uploads" },
|
||||
maxUploadBytes: 4096,
|
||||
spawnSessions: true,
|
||||
};
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { config: expectedMerged });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual({
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/remote"] },
|
||||
uploads: { defaultFolder: "remote/uploads" },
|
||||
maxUploadBytes: 4096,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>();
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { host: "0.0.0.0", allowedHosts: true, shortcuts: { "core:view.chat": "mod+1" }, spawnSessions: true } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
|
||||
expect(requestJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ method, path, body })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` });
|
||||
const installBody = { source: "npm:@acme/new-tools" };
|
||||
const installResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody });
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" });
|
||||
expect(installResponse.statusCode).toBe(200);
|
||||
expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody });
|
||||
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined);
|
||||
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
|
||||
});
|
||||
|
||||
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -389,6 +491,25 @@ describe("buildApp", () => {
|
||||
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
|
||||
});
|
||||
|
||||
it("serves Pi package management routes through the app wiring", async () => {
|
||||
const listResponse = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] });
|
||||
|
||||
const installResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } });
|
||||
expect(installResponse.statusCode).toBe(200);
|
||||
expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
|
||||
|
||||
const localAliasResponse = await app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } });
|
||||
expect(localAliasResponse.statusCode).toBe(200);
|
||||
expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" });
|
||||
expect(piPackageRequests).toEqual([
|
||||
{ action: "list" },
|
||||
{ action: "install", source: "npm:@acme/new-tools" },
|
||||
{ action: "remove", source: "npm:@acme/tools", scope: "user" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
@@ -398,6 +519,10 @@ describe("buildApp", () => {
|
||||
expect(pluginsResponse.statusCode).toBe(200);
|
||||
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
|
||||
const localMachinePluginsResponse = await app.inject({ method: "GET", url: "/api/machines/local/plugins" });
|
||||
expect(localMachinePluginsResponse.statusCode).toBe(200);
|
||||
expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
|
||||
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
|
||||
expect(assetResponse.statusCode).toBe(200);
|
||||
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
|
||||
@@ -407,6 +532,24 @@ describe("buildApp", () => {
|
||||
expect(missingResponse.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("proxies remote machine plugin lists for settings", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
|
||||
body: Readable.from([JSON.stringify({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/plugins` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
expect(response.json()).toEqual({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] });
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined);
|
||||
});
|
||||
|
||||
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -885,6 +1028,12 @@ interface CapturedSessionDaemonRequest {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
interface CapturedPiPackageRequest {
|
||||
action: "list" | "install" | "remove" | "update";
|
||||
source?: string;
|
||||
scope?: "user" | "project";
|
||||
}
|
||||
|
||||
function fakeConfigService() {
|
||||
return {
|
||||
read: () => piWebConfigResponse(piWebConfig),
|
||||
@@ -895,6 +1044,32 @@ function fakeConfigService() {
|
||||
};
|
||||
}
|
||||
|
||||
function fullPiWebConfig(): PiWebConfigValues {
|
||||
return {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.example.test"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true, settings: { note: "remote" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachinePiWebConfig(): PiWebConfigValues {
|
||||
return {
|
||||
plugins: { info: { enabled: true, settings: { note: "remote" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: join(tempDir, "config.json"),
|
||||
@@ -905,6 +1080,46 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface MachineConfigWriteBody {
|
||||
config: PiWebConfigValues;
|
||||
}
|
||||
|
||||
function configFromMachineConfigWriteBody(body: unknown): PiWebConfigValues {
|
||||
if (!isMachineConfigWriteBody(body)) throw new Error("Expected machine config write body");
|
||||
return body.config;
|
||||
}
|
||||
|
||||
function isMachineConfigWriteBody(value: unknown): value is MachineConfigWriteBody {
|
||||
if (!isRecord(value)) return false;
|
||||
return isRecord(value["config"]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function fakePiPackageService(): PiPackageService {
|
||||
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }];
|
||||
return {
|
||||
list: () => {
|
||||
piPackageRequests.push({ action: "list" });
|
||||
return Promise.resolve({ packages });
|
||||
},
|
||||
install: (source) => {
|
||||
piPackageRequests.push({ action: "install", source });
|
||||
return Promise.resolve({ action: "install", source, packages });
|
||||
},
|
||||
remove: (source, scope = "user") => {
|
||||
piPackageRequests.push({ action: "remove", source, scope });
|
||||
return Promise.resolve({ action: "remove", source, scope, removed: true, packages });
|
||||
},
|
||||
update: (source) => {
|
||||
piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) });
|
||||
return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeSessionDaemon(): SessionProxyDaemon {
|
||||
return {
|
||||
request: (method, path, body) => {
|
||||
|
||||
+9
-1
@@ -18,8 +18,10 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||
import { registerGitRoutes } from "./gitRoutes.js";
|
||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
@@ -34,6 +36,7 @@ export interface AppDependencies {
|
||||
machines?: MachineService;
|
||||
sessionDaemon?: SessionProxyDaemon;
|
||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||
piPackages?: PiPackageService;
|
||||
config?: PiWebConfigService;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
@@ -122,6 +125,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
@@ -145,7 +149,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins());
|
||||
registerPiPackageRoutes(app, piPackages);
|
||||
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
|
||||
registerConfigRoutes(app, configService);
|
||||
registerLocalMachineConfigRoutes(app, configService);
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
registerMachinePluginProxyRoutes(app, machines);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -18,6 +18,7 @@ beforeEach(async () => {
|
||||
};
|
||||
app = Fastify({ logger: false });
|
||||
registerConfigRoutes(app, service);
|
||||
registerLocalMachineConfigRoutes(app, service);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
@@ -92,8 +93,102 @@ describe("config routes", () => {
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters local machine config reads to selected-machine-safe keys", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/machines/local/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json<PiWebConfigResponse>()).toEqual({
|
||||
...responseFor(savedConfig, true),
|
||||
config: selectedMachineConfig(),
|
||||
effectiveConfig: selectedMachineConfig(),
|
||||
});
|
||||
});
|
||||
|
||||
it("merges local selected-machine config updates without dropping gateway-only keys", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } },
|
||||
});
|
||||
|
||||
const expectedConfig: PiWebConfigValues = {
|
||||
...fullConfig(),
|
||||
plugins: { info: { enabled: false } },
|
||||
uploads: { defaultFolder: "uploads/manual" },
|
||||
spawnSessions: true,
|
||||
};
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual(expectedConfig);
|
||||
expect(service.write).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual({
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads/manual" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe local selected-machine config keys before writing", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { host: "0.0.0.0", spawnSessions: true } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
|
||||
expect(savedConfig).toEqual(fullConfig());
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid local selected-machine config values before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { spawnSessions: "yes" } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config spawnSessions must be a boolean");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function fullConfig(): PiWebConfigValues {
|
||||
return {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.example.test"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true, settings: { note: "visible" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachineConfig(): PiWebConfigValues {
|
||||
return {
|
||||
plugins: { info: { enabled: true, settings: { note: "visible" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
|
||||
+113
-1
@@ -8,6 +8,17 @@ export interface PiWebConfigService {
|
||||
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
|
||||
}
|
||||
|
||||
export const SELECTED_MACHINE_CONFIG_KEYS = [
|
||||
"plugins",
|
||||
"pathAccess",
|
||||
"uploads",
|
||||
"maxUploadBytes",
|
||||
"spawnSessions",
|
||||
"subsessions",
|
||||
] as const satisfies readonly (keyof PiWebConfigValues)[];
|
||||
|
||||
const SELECTED_MACHINE_CONFIG_KEY_SET = new Set<string>(SELECTED_MACHINE_CONFIG_KEYS);
|
||||
|
||||
export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService {
|
||||
return {
|
||||
read: () => currentPiWebConfigResponse(options),
|
||||
@@ -50,6 +61,62 @@ export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigS
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLocalMachineConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void {
|
||||
app.get("/api/machines/local/config", async (_request, reply) => {
|
||||
try {
|
||||
return selectedMachineConfigResponse(await service.read());
|
||||
} catch (error) {
|
||||
return reply.code(500).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Body: { config?: unknown } | undefined }>("/api/machines/local/config", async (request, reply) => {
|
||||
try {
|
||||
const current = await service.read();
|
||||
const patch = parseSelectedMachineConfigRequest(request.body?.config);
|
||||
return selectedMachineConfigResponse(await service.write(mergeSelectedMachineConfig(current.config, patch)));
|
||||
} catch (error) {
|
||||
const status = isConfigValidationError(error) ? 400 : 500;
|
||||
return reply.code(status).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig {
|
||||
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
|
||||
}
|
||||
try {
|
||||
return pickSelectedMachineConfig(parseConfigRequest(value));
|
||||
} catch (error) {
|
||||
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSelectedMachineConfig(current: PiWebConfigValues, patch: PiWebConfigValues): PiWebConfig {
|
||||
return { ...current, ...pickSelectedMachineConfig(patch) };
|
||||
}
|
||||
|
||||
export function selectedMachineConfigResponse(response: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...response,
|
||||
config: pickSelectedMachineConfig(response.config),
|
||||
effectiveConfig: pickSelectedMachineConfig(response.effectiveConfig),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB config response"): PiWebConfigResponse {
|
||||
const record = requireResponseRecord(value, source);
|
||||
return {
|
||||
path: requireResponseString(record, "path", source),
|
||||
exists: requireResponseBoolean(record, "exists", source),
|
||||
config: parseConfigRequest(record["config"]),
|
||||
effectiveConfig: parseConfigRequest(record["effectiveConfig"]),
|
||||
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
|
||||
const config: PiWebConfig = {};
|
||||
@@ -88,6 +155,23 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
|
||||
return {
|
||||
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
|
||||
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
|
||||
...(config.uploads !== undefined ? { uploads: config.uploads } : {}),
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachineConfigErrorMessage(error: unknown): string {
|
||||
const message = errorMessage(error);
|
||||
if (message.startsWith("PI WEB config ")) return `PI WEB selected-machine config ${message.slice("PI WEB config ".length)}`;
|
||||
return `PI WEB selected-machine config ${message}`;
|
||||
}
|
||||
|
||||
function parseAllowedHostsRequest(value: unknown): string[] | true {
|
||||
if (value === true) return true;
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
||||
@@ -141,6 +225,34 @@ function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]
|
||||
}));
|
||||
}
|
||||
|
||||
function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): PiWebConfigEnvOverrides {
|
||||
const record = requireResponseRecord(value, `${source} envOverrides`);
|
||||
return {
|
||||
host: requireResponseBoolean(record, "host", source),
|
||||
port: requireResponseBoolean(record, "port", source),
|
||||
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
|
||||
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
|
||||
subsessions: requireResponseBoolean(record, "subsessions", source),
|
||||
};
|
||||
}
|
||||
|
||||
function requireResponseRecord(value: unknown, source: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error(`${source} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireResponseString(record: Record<string, unknown>, key: string, source: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") throw new Error(`${source} field must be a string: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireResponseBoolean(record: Record<string, unknown>, key: string, source: string): boolean {
|
||||
const value = record[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${source} field must be a boolean: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
|
||||
return {
|
||||
host: isEnvSet(env["PI_WEB_HOST"]),
|
||||
@@ -156,7 +268,7 @@ function isEnvSet(value: string | undefined): boolean {
|
||||
}
|
||||
|
||||
function isConfigValidationError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith("PI WEB config");
|
||||
return error instanceof Error && (error.message.startsWith("PI WEB config") || error.message.startsWith("PI WEB selected-machine config"));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
|
||||
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { RemoteMachineRequestError, type MachineClient, type MachineJsonResponse, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { MachineService } from "./machineService.js";
|
||||
|
||||
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
|
||||
@@ -23,7 +24,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
app.route<{ Params: { machineId: string }; Body: unknown }>({
|
||||
method: spec.method,
|
||||
url: `/api/machines/:machineId${spec.path}`,
|
||||
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
|
||||
handler: (request, reply) => proxyHttpRequest(machines, spec, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
|
||||
async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRouteSpec, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (machineId === "local") {
|
||||
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
|
||||
}
|
||||
@@ -45,19 +46,62 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
|
||||
}
|
||||
|
||||
try {
|
||||
const requestOptions = proxyRequestOptions(body, contentType);
|
||||
const remotePath = remoteApiPath(machineId, requestUrl);
|
||||
if (spec.path === "/config") return await proxySelectedMachineConfigRequest(client, machineId, method, remotePath, body, reply);
|
||||
|
||||
const requestOptions = proxyRequestOptions(spec, body, contentType);
|
||||
const upstream = requestOptions === undefined
|
||||
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
|
||||
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
|
||||
? await client.request(method, remotePath, body)
|
||||
: await client.request(method, remotePath, body, requestOptions);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
if (upstream.body === undefined) return await reply.send();
|
||||
return await reply.send(upstream.body);
|
||||
} catch (error) {
|
||||
if (isSelectedMachineConfigRequestError(error)) return reply.code(400).send({ error: errorMessage(error) });
|
||||
return sendGatewayError(reply, machineId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function proxySelectedMachineConfigRequest(client: MachineClient, machineId: string, method: string, remotePath: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (method === "GET") {
|
||||
return sendSelectedMachineConfigResponse(reply, await client.requestJson("GET", remotePath), machineId);
|
||||
}
|
||||
|
||||
if (method === "PUT") {
|
||||
const patch = parseSelectedMachineConfigRequest(configPayload(body));
|
||||
const currentResponse = await client.requestJson("GET", remotePath);
|
||||
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
|
||||
|
||||
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
|
||||
const merged = mergeSelectedMachineConfig(current.config, patch);
|
||||
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
|
||||
}
|
||||
|
||||
return reply.code(405).send({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
function configPayload(body: unknown): unknown {
|
||||
return isRecord(body) ? body["config"] : undefined;
|
||||
}
|
||||
|
||||
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
|
||||
}
|
||||
|
||||
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
return reply.send(upstream.body ?? { error: "Remote machine config request failed", machineId, statusCode: upstream.statusCode });
|
||||
}
|
||||
|
||||
function isSuccessfulStatus(statusCode: number): boolean {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
async function proxyWebSocket(machines: MachineService, machineId: string, requestUrl: string, socket: WebSocket): Promise<void> {
|
||||
if (machineId === "local") {
|
||||
socket.close(1011, "Local machine route is not registered for this endpoint");
|
||||
@@ -84,10 +128,14 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
|
||||
return `/api${compatPath}`;
|
||||
}
|
||||
|
||||
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||
if (!isRawProxyBody(body)) return undefined;
|
||||
const value = firstHeaderValue(contentType);
|
||||
return value === undefined || value === "" ? undefined : { contentType: value };
|
||||
function proxyRequestOptions(spec: Pick<FederatedHttpRouteSpec, "timeoutMs">, body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||
const options: MachineRequestOptions = {};
|
||||
if (spec.timeoutMs !== undefined) options.timeoutMs = spec.timeoutMs;
|
||||
if (isRawProxyBody(body)) {
|
||||
const value = firstHeaderValue(contentType);
|
||||
if (value !== undefined && value !== "") options.contentType = value;
|
||||
}
|
||||
return Object.keys(options).length === 0 ? undefined : options;
|
||||
}
|
||||
|
||||
function isRawProxyBody(body: unknown): boolean {
|
||||
@@ -106,6 +154,18 @@ function applySafeHeaders(reply: FastifyReply, headers: Record<string, string |
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectedMachineConfigRequestError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith("PI WEB selected-machine config");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply {
|
||||
const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502;
|
||||
const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable";
|
||||
@@ -113,6 +173,6 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown
|
||||
error: label,
|
||||
machineId,
|
||||
statusCode,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
detail: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
|
||||
import { isPiWebCapability } from "../../shared/capabilities.js";
|
||||
import { parsePiWebRuntimeResponse } from "../../shared/piWebStatusParsing.js";
|
||||
import { getPiWebRuntime } from "../piWebStatus.js";
|
||||
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
|
||||
import { MachineStore, type StoredMachine } from "./machineStore.js";
|
||||
@@ -151,7 +151,8 @@ export class MachineService {
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
const response = await this.clientFor(machine).requestJson("GET", "/api/pi-web/runtime", undefined, { timeoutMs: DEFAULT_REMOTE_HEALTH_TIMEOUT_MS });
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && isPiWebRuntimeResponse(response.body)) return machineRuntime(id, checkedAt, response.body);
|
||||
const runtime = parsePiWebRuntimeResponse(response.body);
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && runtime !== undefined) return machineRuntime(id, checkedAt, runtime);
|
||||
return { machineId: id, ok: false, checkedAt, error: `Remote runtime returned HTTP ${String(response.statusCode)}` };
|
||||
} catch (error) {
|
||||
return { machineId: id, ok: false, checkedAt, error: errorMessage(error) };
|
||||
@@ -241,16 +242,6 @@ function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
|
||||
return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]);
|
||||
}
|
||||
|
||||
function isPiWebRuntimeResponse(value: unknown): value is PiWebRuntimeResponse {
|
||||
if (!isRecord(value)) return false;
|
||||
const packageName = value["packageName"];
|
||||
const generatedAt = value["generatedAt"];
|
||||
const components = value["components"];
|
||||
const capabilities = value["capabilities"];
|
||||
if (typeof packageName !== "string" || typeof generatedAt !== "string" || !isRecord(components) || !isPiWebCapabilityArray(capabilities)) return false;
|
||||
return isPiWebRuntimeComponent(components["web"]) && isPiWebRuntimeComponent(components["sessiond"]);
|
||||
}
|
||||
|
||||
function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
|
||||
if (!isRecord(value)) return false;
|
||||
const component = value["component"];
|
||||
@@ -260,19 +251,6 @@ function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
|
||||
&& typeof value["available"] === "boolean";
|
||||
}
|
||||
|
||||
function isPiWebRuntimeComponent(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const component = value["component"];
|
||||
return (component === "web" || component === "sessiond")
|
||||
&& typeof value["label"] === "string"
|
||||
&& typeof value["available"] === "boolean"
|
||||
&& isPiWebCapabilityArray(value["capabilities"]);
|
||||
}
|
||||
|
||||
function isPiWebCapabilityArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.every(isPiWebCapability);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
||||
import type { PiPackageService } from "./piPackageService.js";
|
||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let service: PiPackageService;
|
||||
let serviceMocks: ReturnType<typeof fakePiPackageService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
serviceMocks = fakePiPackageService();
|
||||
service = serviceMocks.service;
|
||||
app = Fastify({ logger: false });
|
||||
registerPiPackageRoutes(app, service);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("registerPiPackageRoutes", () => {
|
||||
it("lists configured Pi packages", async () => {
|
||||
const response = await app.inject({ method: "GET", url: "/api/pi-packages" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
|
||||
expect(serviceMocks.list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("registers package routes under a custom API prefix", async () => {
|
||||
const prefixedApp = Fastify({ logger: false });
|
||||
const prefixedMocks = fakePiPackageService();
|
||||
registerPiPackageRoutes(prefixedApp, prefixedMocks.service, "/api/machines/local");
|
||||
await prefixedApp.ready();
|
||||
|
||||
try {
|
||||
const response = await prefixedApp.inject({ method: "GET", url: "/api/machines/local/pi-packages" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }] });
|
||||
expect(prefixedMocks.list).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await prefixedApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("installs a trimmed Pi package source without accepting a scope", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: " npm:@acme/new-tools " } });
|
||||
const scopedResponse = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools", scope: "project" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
|
||||
expect(scopedResponse.statusCode).toBe(400);
|
||||
expect(scopedResponse.json()).toEqual({ error: "Pi package install scope is not supported; installs use Pi's default package location" });
|
||||
expect(serviceMocks.install).toHaveBeenCalledOnce();
|
||||
expect(serviceMocks.install).toHaveBeenCalledWith("npm:@acme/new-tools");
|
||||
});
|
||||
|
||||
it("removes from an explicitly listed package scope", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "../project-tools", scope: "project" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ action: "remove", source: "../project-tools", scope: "project", removed: true });
|
||||
expect(serviceMocks.remove).toHaveBeenCalledWith("../project-tools", "project");
|
||||
});
|
||||
|
||||
it("updates all packages when source is omitted and one package when source is provided", async () => {
|
||||
const allResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update" });
|
||||
const oneResponse = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: " npm:@acme/tools " } });
|
||||
|
||||
expect(allResponse.statusCode).toBe(200);
|
||||
expect(oneResponse.statusCode).toBe(200);
|
||||
expect(serviceMocks.update).toHaveBeenNthCalledWith(1);
|
||||
expect(serviceMocks.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
|
||||
});
|
||||
|
||||
it("returns stable 400 errors for invalid requests before calling the service", async () => {
|
||||
const missingSource = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: {} });
|
||||
const blankSource = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: " " } });
|
||||
const invalidScope = await app.inject({ method: "POST", url: "/api/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "temporary" } });
|
||||
const invalidUpdate = await app.inject({ method: "POST", url: "/api/pi-packages/update", payload: { source: "" } });
|
||||
|
||||
expect(missingSource.statusCode).toBe(400);
|
||||
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
||||
expect(blankSource.statusCode).toBe(400);
|
||||
expect(invalidScope.statusCode).toBe(400);
|
||||
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
|
||||
expect(invalidUpdate.statusCode).toBe(400);
|
||||
expect(serviceMocks.install).not.toHaveBeenCalled();
|
||||
expect(serviceMocks.remove).not.toHaveBeenCalled();
|
||||
expect(serviceMocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns stable 500 errors for package-manager failures", async () => {
|
||||
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
|
||||
|
||||
const response = await app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/fails" } });
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json()).toEqual({ error: "install failed" });
|
||||
});
|
||||
});
|
||||
|
||||
function fakePiPackageService() {
|
||||
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||
const list = vi.fn<PiPackageService["list"]>(() => Promise.resolve({ packages: [...packages] }));
|
||||
const install = vi.fn<PiPackageService["install"]>((source) => Promise.resolve({ action: "install", source, packages: [...packages] }));
|
||||
const remove = vi.fn<PiPackageService["remove"]>((source, scope = "user") => Promise.resolve({ action: "remove", source, scope, removed: true, packages: [...packages] }));
|
||||
const update = vi.fn<PiPackageService["update"]>((source) => Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages: [...packages] }));
|
||||
const service: PiPackageService = { list, install, remove, update };
|
||||
return { service, list, install, remove, update };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { PiPackageScope } from "../shared/apiTypes.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
|
||||
class PiPackageRequestValidationError extends Error {}
|
||||
|
||||
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
|
||||
const routePrefix = normalizeRoutePrefix(prefix);
|
||||
|
||||
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
|
||||
try {
|
||||
return await service.list();
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/install`, async (request, reply) => {
|
||||
try {
|
||||
return await service.install(parseRequiredSourceRequest(request.body));
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/remove`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRequestObject(request.body);
|
||||
return await service.remove(parseRequiredSource(body["source"]), parseOptionalScope(body["scope"]));
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: unknown }>(`${routePrefix}/pi-packages/update`, async (request, reply) => {
|
||||
try {
|
||||
const source = parseOptionalUpdateSource(request.body);
|
||||
return source === undefined ? await service.update() : await service.update(source);
|
||||
} catch (error) {
|
||||
return sendPiPackageError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoutePrefix(prefix: string): string {
|
||||
const normalized = prefix.replace(/\/+$/u, "");
|
||||
return normalized === "" ? "/api" : normalized;
|
||||
}
|
||||
|
||||
function parseRequiredSourceRequest(body: unknown): string {
|
||||
const request = requireRequestObject(body);
|
||||
if (request["scope"] !== undefined || request["local"] !== undefined) {
|
||||
throw new PiPackageRequestValidationError("Pi package install scope is not supported; installs use Pi's default package location");
|
||||
}
|
||||
return parseRequiredSource(request["source"]);
|
||||
}
|
||||
|
||||
function parseRequiredSource(value: unknown): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new PiPackageRequestValidationError("Pi package source must be a non-empty string");
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parseOptionalUpdateSource(body: unknown): string | undefined {
|
||||
if (body === undefined) return undefined;
|
||||
const source = requireRequestObject(body)["source"];
|
||||
if (source === undefined) return undefined;
|
||||
return parseRequiredSource(source);
|
||||
}
|
||||
|
||||
function parseOptionalScope(value: unknown): PiPackageScope | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value !== "user" && value !== "project") throw new PiPackageRequestValidationError("Pi package scope must be \"user\" or \"project\"");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireRequestObject(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new PiPackageRequestValidationError("Pi package request body must be an object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
|
||||
const status = error instanceof PiPackageRequestValidationError ? 400 : 500;
|
||||
return reply.code(status).send({ error: errorMessage(error) });
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiPackageInfo } from "../shared/apiTypes.js";
|
||||
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js";
|
||||
|
||||
function fakeManager(packages: PiPackageInfo[] = []) {
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(() => Promise.resolve());
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
|
||||
return { manager, listConfiguredPackages, installAndPersist, removeAndPersist, update };
|
||||
}
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("DefaultPiPackageService", () => {
|
||||
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
|
||||
const fake = fakeManager([
|
||||
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
]);
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await expect(service.list()).resolves.toEqual({
|
||||
packages: [
|
||||
{ source: "npm:@acme/user-tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/user-tools" },
|
||||
{ source: "../project-tools", scope: "project", filtered: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("installs through the default Pi package-manager behavior without a local option", async () => {
|
||||
const fake = fakeManager([{ source: "npm:@acme/tools", scope: "user", filtered: false }]);
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
const response = await service.install("npm:@acme/tools");
|
||||
|
||||
expect(fake.installAndPersist).toHaveBeenCalledWith("npm:@acme/tools");
|
||||
expect(response).toEqual({ action: "install", source: "npm:@acme/tools", packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] });
|
||||
});
|
||||
|
||||
it("removes user packages by default and project packages only when the known scope is supplied", async () => {
|
||||
const fake = fakeManager();
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await service.remove("npm:@acme/user-tools");
|
||||
await service.remove("../project-tools", "project");
|
||||
|
||||
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(1, "npm:@acme/user-tools");
|
||||
expect(fake.removeAndPersist).toHaveBeenNthCalledWith(2, "../project-tools", { local: true });
|
||||
});
|
||||
|
||||
it("updates all configured packages or a single source", async () => {
|
||||
const fake = fakeManager();
|
||||
const service = new DefaultPiPackageService(fake.manager);
|
||||
|
||||
await service.update();
|
||||
await service.update("npm:@acme/tools");
|
||||
|
||||
expect(fake.update).toHaveBeenNthCalledWith(1);
|
||||
expect(fake.update).toHaveBeenNthCalledWith(2, "npm:@acme/tools");
|
||||
});
|
||||
|
||||
it("serializes package mutations in call order and lists after each mutation before starting the next", async () => {
|
||||
const firstMutation = deferred();
|
||||
const events: string[] = [];
|
||||
let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }];
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
|
||||
events.push(`list:${packages.map((configuredPackage) => configuredPackage.source).join(",")}`);
|
||||
return packages;
|
||||
});
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
|
||||
events.push(`install:start:${source}`);
|
||||
await firstMutation.promise;
|
||||
packages = [{ source, scope: "user", filtered: false }];
|
||||
events.push(`install:finish:${source}`);
|
||||
});
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>((source) => {
|
||||
events.push(`remove:start:${source}`);
|
||||
packages = [];
|
||||
events.push(`remove:finish:${source}`);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
|
||||
const flush = vi.fn<NonNullable<PiPackageManagerPort["flush"]>>(() => {
|
||||
events.push("flush");
|
||||
return Promise.resolve();
|
||||
});
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush };
|
||||
const service = new DefaultPiPackageService(manager);
|
||||
|
||||
const installPromise = service.install("npm:@acme/new-tools");
|
||||
const removePromise = service.remove("npm:@acme/new-tools");
|
||||
|
||||
await Promise.resolve();
|
||||
expect(installAndPersist).toHaveBeenCalledOnce();
|
||||
expect(removeAndPersist).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["install:start:npm:@acme/new-tools"]);
|
||||
|
||||
firstMutation.resolve();
|
||||
await expect(Promise.all([installPromise, removePromise])).resolves.toEqual([
|
||||
{ action: "install", source: "npm:@acme/new-tools", packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }] },
|
||||
{ action: "remove", source: "npm:@acme/new-tools", scope: "user", removed: true, packages: [] },
|
||||
]);
|
||||
expect(events).toEqual([
|
||||
"install:start:npm:@acme/new-tools",
|
||||
"install:finish:npm:@acme/new-tools",
|
||||
"flush",
|
||||
"list:npm:@acme/new-tools",
|
||||
"remove:start:npm:@acme/new-tools",
|
||||
"remove:finish:npm:@acme/new-tools",
|
||||
"flush",
|
||||
"list:",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not queue list requests behind an in-flight mutation", async () => {
|
||||
const mutation = deferred();
|
||||
const events: string[] = [];
|
||||
let packages: PiPackageInfo[] = [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }];
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
|
||||
events.push("list");
|
||||
return packages;
|
||||
});
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
|
||||
events.push(`install:start:${source}`);
|
||||
await mutation.promise;
|
||||
packages = [{ source, scope: "user", filtered: false }];
|
||||
events.push(`install:finish:${source}`);
|
||||
});
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>(() => Promise.resolve());
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update };
|
||||
const service = new DefaultPiPackageService(manager);
|
||||
|
||||
const installPromise = service.install("npm:@acme/new-tools");
|
||||
await Promise.resolve();
|
||||
|
||||
await expect(service.list()).resolves.toEqual({ packages: [{ source: "npm:@acme/old-tools", scope: "user", filtered: false }] });
|
||||
expect(events).toEqual(["install:start:npm:@acme/new-tools", "list"]);
|
||||
|
||||
mutation.resolve();
|
||||
await expect(installPromise).resolves.toEqual({
|
||||
action: "install",
|
||||
source: "npm:@acme/new-tools",
|
||||
packages: [{ source: "npm:@acme/new-tools", scope: "user", filtered: false }],
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the mutation queue after a mutation fails", async () => {
|
||||
const failingMutation = deferred();
|
||||
const events: string[] = [];
|
||||
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false }];
|
||||
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => {
|
||||
events.push("list");
|
||||
return packages;
|
||||
});
|
||||
const installAndPersist = vi.fn<PiPackageManagerPort["installAndPersist"]>(async (source) => {
|
||||
events.push(`install:start:${source}`);
|
||||
await failingMutation.promise;
|
||||
});
|
||||
const removeAndPersist = vi.fn<PiPackageManagerPort["removeAndPersist"]>(() => Promise.resolve(true));
|
||||
const update = vi.fn<PiPackageManagerPort["update"]>((source) => {
|
||||
events.push(`update:start:${source ?? "all"}`);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const flush = vi.fn<NonNullable<PiPackageManagerPort["flush"]>>(() => {
|
||||
events.push("flush");
|
||||
return Promise.resolve();
|
||||
});
|
||||
const manager: PiPackageManagerPort = { listConfiguredPackages, installAndPersist, removeAndPersist, update, flush };
|
||||
const service = new DefaultPiPackageService(manager);
|
||||
|
||||
const installPromise = service.install("npm:@acme/fails");
|
||||
const updatePromise = service.update("npm:@acme/tools");
|
||||
|
||||
await Promise.resolve();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["install:start:npm:@acme/fails"]);
|
||||
|
||||
failingMutation.reject(new Error("install failed"));
|
||||
await expect(installPromise).rejects.toThrow("install failed");
|
||||
await expect(updatePromise).resolves.toEqual({
|
||||
action: "update",
|
||||
source: "npm:@acme/tools",
|
||||
packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }],
|
||||
});
|
||||
expect(events).toEqual([
|
||||
"install:start:npm:@acme/fails",
|
||||
"update:start:npm:@acme/tools",
|
||||
"flush",
|
||||
"list",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
|
||||
|
||||
export interface PiPackageManagerPort {
|
||||
listConfiguredPackages(): PiPackageInfo[];
|
||||
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
|
||||
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
|
||||
update(source?: string): Promise<void>;
|
||||
flush?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PiPackageService {
|
||||
list(): Promise<PiPackagesResponse>;
|
||||
install(source: string): Promise<PiPackageMutationResponse>;
|
||||
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse>;
|
||||
update(source?: string): Promise<PiPackageMutationResponse>;
|
||||
}
|
||||
|
||||
export class DefaultPiPackageService implements PiPackageService {
|
||||
private mutationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly manager: PiPackageManagerPort) {}
|
||||
|
||||
list(): Promise<PiPackagesResponse> {
|
||||
return Promise.resolve({ packages: this.listPackages() });
|
||||
}
|
||||
|
||||
install(source: string): Promise<PiPackageMutationResponse> {
|
||||
return this.enqueueMutation(async () => {
|
||||
await this.manager.installAndPersist(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("install", { source });
|
||||
});
|
||||
}
|
||||
|
||||
remove(source: string, scope: PiPackageScope = "user"): Promise<PiPackageMutationResponse> {
|
||||
return this.enqueueMutation(async () => {
|
||||
const removed = scope === "project"
|
||||
? await this.manager.removeAndPersist(source, { local: true })
|
||||
: await this.manager.removeAndPersist(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("remove", { source, scope, removed });
|
||||
});
|
||||
}
|
||||
|
||||
update(source?: string): Promise<PiPackageMutationResponse> {
|
||||
return this.enqueueMutation(async () => {
|
||||
if (source === undefined) {
|
||||
await this.manager.update();
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("update", {});
|
||||
}
|
||||
|
||||
await this.manager.update(source);
|
||||
await this.flushSettings();
|
||||
return this.mutationResponse("update", { source });
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const queuedMutation = this.mutationQueue.then(operation);
|
||||
this.mutationQueue = queuedMutation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return queuedMutation;
|
||||
}
|
||||
|
||||
private mutationResponse(action: PiPackageMutationAction, metadata: Omit<PiPackageMutationResponse, "action" | "packages">): PiPackageMutationResponse {
|
||||
return { action, ...metadata, packages: this.listPackages() };
|
||||
}
|
||||
|
||||
private async flushSettings(): Promise<void> {
|
||||
await this.manager.flush?.();
|
||||
}
|
||||
|
||||
private listPackages(): PiPackageInfo[] {
|
||||
return this.manager.listConfiguredPackages().map((configuredPackage) => ({
|
||||
source: configuredPackage.source,
|
||||
scope: configuredPackage.scope,
|
||||
filtered: configuredPackage.filtered,
|
||||
...(configuredPackage.installedPath === undefined ? {} : { installedPath: configuredPackage.installedPath }),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService {
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
return new DefaultPiPackageService({
|
||||
listConfiguredPackages: () => manager.listConfiguredPackages(),
|
||||
installAndPersist: (source, options) => manager.installAndPersist(source, options),
|
||||
removeAndPersist: (source, options) => manager.removeAndPersist(source, options),
|
||||
update: (source) => manager.update(source),
|
||||
flush: () => settingsManager.flush(),
|
||||
});
|
||||
}
|
||||
@@ -89,6 +89,29 @@ describe("PiWebPluginService", () => {
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
|
||||
});
|
||||
|
||||
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
|
||||
const agentDir = join(tempDir, "agent");
|
||||
const firstPackageDir = join(tempDir, "first-package");
|
||||
const secondPackageDir = join(tempDir, "second-package");
|
||||
await writePlugin(firstPackageDir, {
|
||||
packageJson: { piWeb: { plugins: [{ id: "first", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
await writePlugin(secondPackageDir, {
|
||||
packageJson: { piWeb: { plugins: [{ id: "second", module: "pi-web-plugin.js" }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
await writePiPackageSettings(agentDir, [firstPackageDir]);
|
||||
const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDir });
|
||||
|
||||
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "first" }] });
|
||||
|
||||
await writePiPackageSettings(agentDir, [secondPackageDir]);
|
||||
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("discovers source checkout plugin packages without symlinks", async () => {
|
||||
await mkdir(join(tempDir, "src", "server"), { recursive: true });
|
||||
await writeFile(join(tempDir, "src", "server", "index.ts"), "export {};\n");
|
||||
@@ -212,6 +235,11 @@ describe("PiWebPluginService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function writePiPackageSettings(agentDir: string, packages: string[]): Promise<void> {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writePlugin(root: string, options: { packageJson: unknown; files: Record<string, string> }): Promise<void> {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "package.json"), `${JSON.stringify(options.packageJson, null, 2)}\n`);
|
||||
|
||||
@@ -69,22 +69,22 @@ interface PiWebPluginEntry {
|
||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||
|
||||
export class DefaultPiPackageProvider implements PiPackageProvider {
|
||||
private readonly packageManager: DefaultPackageManager;
|
||||
|
||||
constructor(cwd = process.cwd(), agentDir = getAgentDir()) {
|
||||
this.packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: SettingsManager.create(cwd, agentDir),
|
||||
});
|
||||
}
|
||||
constructor(private readonly cwd = process.cwd(), private readonly agentDir = getAgentDir()) {}
|
||||
|
||||
listPackages(): ConfiguredPiPackage[] {
|
||||
return this.packageManager.listConfiguredPackages();
|
||||
return this.createPackageManager().listConfiguredPackages();
|
||||
}
|
||||
|
||||
getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
|
||||
return this.packageManager.getInstalledPath(source, scope);
|
||||
return this.createPackageManager().getInstalledPath(source, scope);
|
||||
}
|
||||
|
||||
private createPackageManager(): DefaultPackageManager {
|
||||
return new DefaultPackageManager({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
settingsManager: SettingsManager.create(this.cwd, this.agentDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import type { PiWebComponentStatus } from "../shared/apiTypes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
|
||||
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
||||
const originalHome = process.env["HOME"];
|
||||
@@ -50,6 +51,24 @@ describe("PI WEB status", () => {
|
||||
expect(status).not.toHaveProperty("release");
|
||||
});
|
||||
|
||||
it("reports web-only capabilities from the web runtime", async () => {
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
runtimeVersion: "1.202605.7",
|
||||
installedVersion: "1.202605.8",
|
||||
stale: true,
|
||||
available: true,
|
||||
});
|
||||
|
||||
const runtime = await getPiWebRuntime(daemon);
|
||||
|
||||
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
});
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
|
||||
let workspace: string;
|
||||
let externalDirectories: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-"));
|
||||
externalDirectories = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await Promise.all([
|
||||
rm(workspace, { recursive: true, force: true }),
|
||||
...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
]);
|
||||
});
|
||||
|
||||
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
@@ -43,6 +48,59 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
expect(written.equals(pngBytes)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves generic files with sanitized original filenames", async () => {
|
||||
const pdfBytes = Buffer.from("PDF bytes");
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[
|
||||
{ kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" },
|
||||
{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" },
|
||||
],
|
||||
{ now: () => new Date("2026-06-13T12:05:01.123Z") },
|
||||
);
|
||||
|
||||
expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true);
|
||||
expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true);
|
||||
expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength });
|
||||
expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true);
|
||||
expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 });
|
||||
|
||||
expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true);
|
||||
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not overwrite an existing attachment name", async () => {
|
||||
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
|
||||
const first = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
const second = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }],
|
||||
{ now: fixedNow },
|
||||
);
|
||||
|
||||
expect(second[0]?.path).not.toBe(first[0]?.path);
|
||||
expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true);
|
||||
expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC");
|
||||
expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF");
|
||||
});
|
||||
|
||||
it("rejects unsafe custom folders", async () => {
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "/tmp/uploads" },
|
||||
)).rejects.toThrow(/Absolute paths/);
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "image", mimeType: "image/png", data: pngBase64 }],
|
||||
{ folder: "../uploads" },
|
||||
)).rejects.toThrow(/Path traversal/);
|
||||
});
|
||||
|
||||
it("honors a custom folder", async () => {
|
||||
const saved = await saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
@@ -52,6 +110,19 @@ describe("saveAttachmentsToWorkspace", () => {
|
||||
expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects attachment folders that resolve outside the workspace", async () => {
|
||||
const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-"));
|
||||
externalDirectories.push(outside);
|
||||
await mkdir(join(workspace, ".pi-web"));
|
||||
await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir");
|
||||
|
||||
await expect(saveAttachmentsToWorkspace(
|
||||
workspace,
|
||||
[{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }],
|
||||
)).rejects.toThrow(/Path escapes workspace/);
|
||||
await expect(readdir(outside)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for no attachments", async () => {
|
||||
expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent";
|
||||
import type { PromptAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import type { PromptAttachment, PromptImageAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js";
|
||||
import { extensionForImageMimeType } from "../../shared/promptAttachments.js";
|
||||
import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
import { ensureInside, isNodeErrorWithCode, resolveParentInsideWorkspace } from "../workspaces/pathSafety.js";
|
||||
|
||||
/**
|
||||
* Default workspace-relative folder used when saving pasted/dropped
|
||||
@@ -26,7 +26,7 @@ export interface InlineImage {
|
||||
* (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit
|
||||
* are dropped, matching pi's `[Image omitted]` behaviour.
|
||||
*/
|
||||
export async function attachmentsToInlineImages(attachments: PromptAttachment[]): Promise<InlineImage[]> {
|
||||
export async function attachmentsToInlineImages(attachments: PromptImageAttachment[]): Promise<InlineImage[]> {
|
||||
const results: InlineImage[] = [];
|
||||
for (const attachment of attachments) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
@@ -57,25 +57,83 @@ export async function saveAttachmentsToWorkspace(
|
||||
attachments: PromptAttachment[],
|
||||
options: SaveAttachmentsOptions = {},
|
||||
): Promise<SavedPromptAttachment[]> {
|
||||
const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER);
|
||||
const folder = options.folder ?? DEFAULT_ATTACHMENT_FOLDER;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(folderTarget, { recursive: true });
|
||||
const { root, target: requestedFolderTarget, relativePath: normalizedFolder } = await resolveParentInsideWorkspace(cwd, folder);
|
||||
await mkdir(requestedFolderTarget, { recursive: true });
|
||||
const folderTarget = await realpath(requestedFolderTarget);
|
||||
ensureInside(root, folderTarget);
|
||||
|
||||
const stamp = timestamp(now());
|
||||
const saved: SavedPromptAttachment[] = [];
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const bytes = Buffer.from(attachment.data, "base64");
|
||||
const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
const relativePath = `${folder}/${filename}`;
|
||||
await writeFile(join(folderTarget, filename), bytes);
|
||||
const filename = await writeUniqueAttachmentFile(folderTarget, attachmentFilename(attachment, stamp, index), bytes);
|
||||
const relativePath = normalizedFolder === "" ? filename : `${normalizedFolder}/${filename}`;
|
||||
saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength });
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
function normalizeFolder(folder: string): string {
|
||||
return folder.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/");
|
||||
async function writeUniqueAttachmentFile(folderTarget: string, filename: string, bytes: Buffer): Promise<string> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const candidate = attempt === 0 ? filename : addCollisionSuffix(filename, attempt + 1);
|
||||
try {
|
||||
await writeFile(join(folderTarget, candidate), bytes, { flag: "wx" });
|
||||
return candidate;
|
||||
} catch (error: unknown) {
|
||||
if (!isNodeErrorWithCode(error, "EEXIST")) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Unable to choose a unique attachment filename");
|
||||
}
|
||||
|
||||
function addCollisionSuffix(filename: string, suffix: number): string {
|
||||
const extension = extname(filename);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
return `${stem}-${String(suffix)}${extension}`;
|
||||
}
|
||||
|
||||
function attachmentFilename(attachment: PromptAttachment, stamp: string, index: number): string {
|
||||
const originalName = sanitizeOriginalFilename(attachment.name) ?? fallbackAttachmentFilename(attachment);
|
||||
return `attachment-${stamp}-${String(index + 1)}-${originalName}`;
|
||||
}
|
||||
|
||||
function fallbackAttachmentFilename(attachment: PromptAttachment): string {
|
||||
if (attachment.kind === "image") return `image.${extensionForImageMimeType(attachment.mimeType)}`;
|
||||
return "file.bin";
|
||||
}
|
||||
|
||||
const MAX_ORIGINAL_FILENAME_LENGTH = 96;
|
||||
|
||||
function sanitizeOriginalFilename(name: string | undefined): string | undefined {
|
||||
const trimmed = name?.trim();
|
||||
if (trimmed === undefined || trimmed === "") return undefined;
|
||||
const leaf = basename(trimmed.replace(/\\/g, "/"));
|
||||
const sanitized = stripControlCharacters(leaf)
|
||||
.normalize("NFKC")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/-+\./g, ".")
|
||||
.replace(/^\.+/, "")
|
||||
.replace(/[.-]+$/, "");
|
||||
if (sanitized === "") return undefined;
|
||||
return truncateFilename(sanitized, MAX_ORIGINAL_FILENAME_LENGTH);
|
||||
}
|
||||
|
||||
function stripControlCharacters(value: string): string {
|
||||
return Array.from(value).filter((character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function truncateFilename(filename: string, maxLength: number): string {
|
||||
if (filename.length <= maxLength) return filename;
|
||||
const extension = extname(filename);
|
||||
if (extension.length >= maxLength) return filename.slice(0, maxLength);
|
||||
const stem = filename.slice(0, filename.length - extension.length);
|
||||
return `${stem.slice(0, maxLength - extension.length)}${extension}`;
|
||||
}
|
||||
|
||||
function timestamp(date: Date): string {
|
||||
|
||||
@@ -20,7 +20,7 @@ export const BUILTIN_COMMANDS: ClientCommand[] = [
|
||||
{ name: "new", description: "Start a new session", source: "builtin" },
|
||||
{ name: "compact", description: "Manually compact session context", source: "builtin" },
|
||||
{ name: "resume", description: "Resume a different session", source: "builtin" },
|
||||
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes", source: "builtin" },
|
||||
{ name: "reload", description: "Reload Pi runtime resources for this session", source: "builtin" },
|
||||
{ name: "quit", description: "Quit pi", source: "builtin" },
|
||||
];
|
||||
|
||||
|
||||
@@ -72,6 +72,16 @@ describe("Pi session manager gateway", () => {
|
||||
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
|
||||
});
|
||||
|
||||
it("includes an absolute env-configured session directory in global listing", async () => {
|
||||
const envSessionDir = join(tempDir, "env-sessions");
|
||||
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
|
||||
await writeSessionFile(envSessionDir, "env-session", cwd);
|
||||
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } });
|
||||
|
||||
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
|
||||
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -34,6 +34,13 @@ export class SessionDirResolver {
|
||||
return defaultPiSessionsRoot(this.agentDir);
|
||||
}
|
||||
|
||||
globalEnvSessionDir(): string | undefined {
|
||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir === undefined || envSessionDir === "") return undefined;
|
||||
const expanded = expandTildePath(envSessionDir);
|
||||
return isAbsolute(expanded) ? expanded : undefined;
|
||||
}
|
||||
|
||||
resolve(cwd: string): SessionDirResolution {
|
||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir !== undefined && envSessionDir !== "") {
|
||||
@@ -68,8 +75,13 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
|
||||
return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession });
|
||||
}
|
||||
|
||||
listAll(): Promise<PiSessionListEntry[]> {
|
||||
return listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot());
|
||||
async listAll(): Promise<PiSessionListEntry[]> {
|
||||
const envSessionDir = this.resolver.globalEnvSessionDir();
|
||||
const [defaultSessions, envSessions] = await Promise.all([
|
||||
listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()),
|
||||
envSessionDir === undefined ? Promise.resolve([]) : listSessionsInDir(envSessionDir),
|
||||
]);
|
||||
return uniqueSessionsByPath([...defaultSessions, ...envSessions]);
|
||||
}
|
||||
|
||||
open(path: string): PiSessionManager {
|
||||
@@ -106,6 +118,12 @@ export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cw
|
||||
return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd));
|
||||
}
|
||||
|
||||
function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] {
|
||||
const byPath = new Map<string, PiSessionListEntry>();
|
||||
for (const session of sessions) byPath.set(session.path, session);
|
||||
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
}
|
||||
|
||||
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
|
||||
return join(agentDir, "sessions");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
@@ -52,12 +54,18 @@ function sessionRef(id: string, cwd = "/workspace") {
|
||||
return { id, cwd };
|
||||
}
|
||||
|
||||
function testModel(): NonNullable<PiAgentSession["model"]> {
|
||||
const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find("anthropic", "claude-3-5-sonnet-20241022");
|
||||
if (model === undefined) throw new Error("test model not found");
|
||||
return model;
|
||||
}
|
||||
|
||||
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
const promptCalls: { text: string; options: unknown }[] = [];
|
||||
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
|
||||
const bindExtensionCalls: unknown[] = [];
|
||||
const listeners: ((event: unknown) => void)[] = [];
|
||||
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls };
|
||||
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
|
||||
const session: TestSession = {
|
||||
sessionId,
|
||||
sessionFile: `/tmp/${sessionId}.jsonl`,
|
||||
@@ -88,6 +96,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
},
|
||||
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
|
||||
getContextUsage: () => undefined,
|
||||
reload: () => {
|
||||
calls.reload += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
prompt: (text: string, options: unknown) => {
|
||||
calls.prompt.push({ text, options });
|
||||
return Promise.resolve();
|
||||
@@ -115,6 +127,7 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
setSessionName: (name: string) => { session.sessionName = name; },
|
||||
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
|
||||
getUserMessagesForForking: () => [],
|
||||
agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } },
|
||||
...patch,
|
||||
};
|
||||
const runtime: PiSessionRuntime = {
|
||||
@@ -156,6 +169,23 @@ function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveS
|
||||
}
|
||||
|
||||
describe("PiSessionService", () => {
|
||||
it("exposes the session's agent.streamFn for one-off model calls", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const streamFn = vi.fn();
|
||||
const fake = fakeRuntime("stream-session", { agent: { streamFn } });
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
expect(fake.session.agent.streamFn).toBe(streamFn);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("starts sessions through an injected runtime creator", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime();
|
||||
@@ -185,6 +215,35 @@ describe("PiSessionService", () => {
|
||||
expect(fake.calls.dispose).toBe(1);
|
||||
});
|
||||
|
||||
it("reports persistence from actual session-file existence for fresh active sessions", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-"));
|
||||
const sessionFile = join(dir, "new-session.jsonl");
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("new-session", { sessionFile });
|
||||
let service: PiSessionService | undefined;
|
||||
try {
|
||||
service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const session = await service.start("/workspace");
|
||||
const createdEvent = hub.globalEvents.find((event) => event.type === "session.created");
|
||||
|
||||
expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false });
|
||||
expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } });
|
||||
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false });
|
||||
|
||||
await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8");
|
||||
|
||||
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true });
|
||||
} finally {
|
||||
await service?.dispose();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("opens legacy id-only lookups from the default session store gateway", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("legacy-session");
|
||||
@@ -346,7 +405,7 @@ describe("PiSessionService", () => {
|
||||
|
||||
const sessions = await service.list("/workspace");
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0]).toMatchObject({ id: "active" });
|
||||
expect(sessions[0]).toMatchObject({ id: "active", persisted: true });
|
||||
expect(sessions[0]?.archived).toBeUndefined();
|
||||
expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" });
|
||||
|
||||
@@ -446,6 +505,315 @@ 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[] = [];
|
||||
let listAllCalls = 0;
|
||||
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
|
||||
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([archived, otherArchived]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
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: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
|
||||
deleteArchivedMany: (sessionIds) => {
|
||||
deletedSessionIds.push(...sessionIds);
|
||||
return Promise.resolve([...sessionIds]);
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager(),
|
||||
list: () => Promise.resolve([]),
|
||||
listAll: () => {
|
||||
listAllCalls += 1;
|
||||
return Promise.resolve([
|
||||
listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"),
|
||||
listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"),
|
||||
]);
|
||||
},
|
||||
open: () => fakeSessionManager(),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
|
||||
expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
|
||||
expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]);
|
||||
expect(archivedInputs).toEqual([]);
|
||||
expect(deletedSessionIds).toEqual([]);
|
||||
|
||||
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
|
||||
expect(result.archivedSessionIds).toEqual(["execute-only"]);
|
||||
expect(result.deletedSessionIds).toEqual(["archived-old"]);
|
||||
expect(archivedInputs).toEqual(["execute-only"]);
|
||||
expect(deletedSessionIds).toEqual(["archived-old"]);
|
||||
|
||||
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[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
now: () => new Date("2026-06-25T00:00:00.000Z"),
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
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" });
|
||||
},
|
||||
restore: () => Promise.resolve(),
|
||||
isArchived: () => Promise.resolve(false),
|
||||
},
|
||||
sessionManager: {
|
||||
create: () => fakeSessionManager("/old-project"),
|
||||
list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
|
||||
listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
|
||||
open: () => fakeSessionManager("/old-project"),
|
||||
},
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.status("busy-open");
|
||||
const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } });
|
||||
|
||||
expect(result.archivedSessionIds).toEqual([]);
|
||||
expect(result.skippedBusySessionIds).toEqual(["busy-open"]);
|
||||
expect(archivedInputs).toEqual([]);
|
||||
expect(fake.calls.abort).toBe(0);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("runs /reload by refreshing the active runtime resources in place", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("runtime-reload-session");
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({
|
||||
type: "done",
|
||||
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
|
||||
});
|
||||
|
||||
expect(fake.calls.reload).toBe(1);
|
||||
expect(fake.calls.abort).toBe(0);
|
||||
expect(fake.calls.dispose).toBe(0);
|
||||
expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true);
|
||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true);
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
|
||||
const first = fakeRuntime("reload-session");
|
||||
const second = fakeRuntime("reload-session");
|
||||
@@ -601,6 +969,42 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("generates a session name for the first prompt via the session's agent.streamFn", async () => {
|
||||
const model = testModel();
|
||||
const streamCalls: unknown[] = [];
|
||||
const streamFn: StreamFn = (streamModel, context, options) => {
|
||||
streamCalls.push({ streamModel, context, options });
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Fix login bug" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: model.id,
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
stream.end(message);
|
||||
return stream;
|
||||
};
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("name-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt(sessionRef("name-session"), "Please fix the login bug");
|
||||
await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); });
|
||||
|
||||
expect(streamCalls).toHaveLength(1);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("includes queued message details in session status", async () => {
|
||||
const fake = fakeRuntime("status-session", {
|
||||
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
|
||||
@@ -820,6 +1224,28 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("uses the dispatching session's model as the spawned session's initial model", async () => {
|
||||
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
|
||||
const model = testModel();
|
||||
let initialModel: PiAgentSession["model"];
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialModel = options.initialModel;
|
||||
return fake.runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model });
|
||||
|
||||
expect(initialModel).toBe(model);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects an out-of-project target without starting a session", async () => {
|
||||
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
|
||||
|
||||
@@ -901,6 +1327,35 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("uses the parent session's model as the tracked child's initial model", async () => {
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
|
||||
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
|
||||
const model = testModel();
|
||||
const initialModels: PiAgentSession["model"][] = [];
|
||||
const runtimes = [parent.runtime, child.runtime];
|
||||
let index = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialModels.push(options.initialModel);
|
||||
const runtime = runtimes[index] ?? child.runtime;
|
||||
index += 1;
|
||||
return runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
archiveStore: emptyArchiveStore(),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model });
|
||||
|
||||
expect(initialModels).toEqual([undefined, model]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("persists tracked child links in the parent and child sessions", async () => {
|
||||
const parentPersisted: { customType: string; data?: unknown }[] = [];
|
||||
const childPersisted: { customType: string; data?: unknown }[] = [];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import type { Api, ImageContent, Model } from "@earendil-works/pi-ai";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSessionFromServices,
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type EditToolDetails,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||
@@ -27,13 +29,14 @@ 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";
|
||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||
import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js";
|
||||
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
|
||||
|
||||
/**
|
||||
@@ -101,6 +104,11 @@ interface PersistedChildSubsessionLink {
|
||||
spawnedSessionId: string;
|
||||
}
|
||||
|
||||
interface StartSessionOptions {
|
||||
parentSession?: string;
|
||||
initialModel?: AgentModel;
|
||||
}
|
||||
|
||||
function requirePromptText(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("Prompt text is required");
|
||||
return value;
|
||||
@@ -112,7 +120,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;
|
||||
|
||||
@@ -137,7 +149,20 @@ interface WorkspaceArchiveCandidate extends SessionArchiveTreeCandidate {
|
||||
activeSession?: PiAgentSession;
|
||||
}
|
||||
|
||||
type AgentModel = Model<Api>;
|
||||
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>;
|
||||
|
||||
export interface PiSessionManager {
|
||||
@@ -194,6 +219,7 @@ export interface PiAgentSession {
|
||||
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
|
||||
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
|
||||
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
|
||||
reload(): Promise<void>;
|
||||
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
|
||||
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
|
||||
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
|
||||
@@ -208,6 +234,15 @@ export interface PiAgentSession {
|
||||
setThinkingLevel(level: ClientThinkingLevel): void;
|
||||
cycleThinkingLevel(): ClientThinkingLevel | undefined;
|
||||
setSessionName(name: string): void;
|
||||
/**
|
||||
* Narrow re-expression of `AgentSession.agent` (an `@earendil-works/pi-agent-core`
|
||||
* `Agent`), exposing only `streamFn` — the resolved-auth/headers/retry "call this
|
||||
* model" function pi's own compaction/branch-summarization code uses internally.
|
||||
* Lets callers (e.g. session title generation) issue one-off model calls without
|
||||
* depending on pi-ai's deprecated `/compat` provider registry or leaking the full
|
||||
* `Agent`/`AgentSession` surface.
|
||||
*/
|
||||
agent: { streamFn: StreamFn };
|
||||
}
|
||||
|
||||
export interface PiSessionRuntime {
|
||||
@@ -222,29 +257,56 @@ interface CreateAgentRuntimeOptions {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
sessionManager: PiSessionManager;
|
||||
initialModel?: AgentModel;
|
||||
}
|
||||
|
||||
type CreateAgentRuntime = (createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise<PiSessionRuntime>;
|
||||
type PiWebCreateAgentSessionRuntimeFactory = (
|
||||
options: Parameters<CreateAgentSessionRuntimeFactory>[0] & { initialModel?: AgentModel }
|
||||
) => ReturnType<CreateAgentSessionRuntimeFactory>;
|
||||
|
||||
function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
|
||||
type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions) => Promise<PiSessionRuntime>;
|
||||
|
||||
function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
|
||||
if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager");
|
||||
return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
|
||||
const runtimeFactory = createRuntimeWithOneShotInitialModel(createRuntime, options.initialModel);
|
||||
return createAgentSessionRuntime(runtimeFactory, {
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
sessionManager: options.sessionManager,
|
||||
});
|
||||
}
|
||||
|
||||
function createRuntimeWithOneShotInitialModel(createRuntime: PiWebCreateAgentSessionRuntimeFactory, initialModel: AgentModel | undefined): CreateAgentSessionRuntimeFactory {
|
||||
// The inherited model belongs only to the session being spawned. Do not keep
|
||||
// reapplying it if that runtime later creates/forks/switches sessions itself.
|
||||
let pendingInitialModel = initialModel;
|
||||
return async (options) => {
|
||||
const model = pendingInitialModel;
|
||||
pendingInitialModel = undefined;
|
||||
return createRuntime({
|
||||
...options,
|
||||
...(model === undefined ? {} : { initialModel: model }),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise<SpawnSessionResult>;
|
||||
|
||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): CreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps): PiWebCreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel }) => {
|
||||
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
|
||||
const customTools = [
|
||||
createPiWebEditToolDefinition(cwd),
|
||||
...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
|
||||
...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
|
||||
];
|
||||
const options = sessionStartEvent === undefined
|
||||
? { services, sessionManager, customTools }
|
||||
: { services, sessionManager, sessionStartEvent, customTools };
|
||||
const result = await createAgentSessionFromServices(options);
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
customTools,
|
||||
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
|
||||
...(initialModel === undefined ? {} : { model: initialModel }),
|
||||
});
|
||||
return { ...result, services, diagnostics: services.diagnostics };
|
||||
};
|
||||
}
|
||||
@@ -277,7 +339,7 @@ export interface PiSessionServiceDependencies {
|
||||
archiveStore?: SessionArchiveRepository;
|
||||
agentDir?: string;
|
||||
sessionManager?: PiSessionManagerGateway;
|
||||
createRuntime?: CreateAgentSessionRuntimeFactory;
|
||||
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
|
||||
createAgentRuntime?: CreateAgentRuntime;
|
||||
modelRegistry?: ModelRegistryInstance;
|
||||
heartbeatIntervalMs?: number;
|
||||
@@ -298,6 +360,8 @@ export interface PiSessionServiceDependencies {
|
||||
subsessionsEnabled?: boolean;
|
||||
/** Structured logger for notable runtime events (e.g. spawns). */
|
||||
logger?: PiSessionLogger;
|
||||
/** Clock seam for cleanup planning tests. */
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export class PiSessionService {
|
||||
@@ -325,12 +389,13 @@ export class PiSessionService {
|
||||
private readonly archiveStore: SessionArchiveRepository;
|
||||
private readonly agentDir: string;
|
||||
private readonly sessionManager: PiSessionManagerGateway;
|
||||
private readonly createRuntime: CreateAgentSessionRuntimeFactory;
|
||||
private readonly createRuntime: PiWebCreateAgentSessionRuntimeFactory;
|
||||
private readonly createAgentRuntime: CreateAgentRuntime;
|
||||
private readonly modelRegistry: ModelRegistryInstance;
|
||||
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
|
||||
private readonly spawnTargets: SpawnTargetResolver | undefined;
|
||||
private readonly logger: PiSessionLogger;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
|
||||
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
||||
@@ -339,6 +404,7 @@ export class PiSessionService {
|
||||
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
|
||||
this.spawnTargets = deps.spawnTargets;
|
||||
this.logger = deps.logger ?? noopLogger;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
// Subsessions are a beta capability gated behind their own flag, and they
|
||||
// also require the spawn capability (they share its project-scope resolver).
|
||||
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
|
||||
@@ -369,6 +435,7 @@ export class PiSessionService {
|
||||
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
|
||||
this.publishStatus(session);
|
||||
},
|
||||
reloadSession: (session) => this.reloadSessionRuntime(session),
|
||||
},
|
||||
{ listSessionNames: (cwd) => this.listSessionNames(cwd) },
|
||||
);
|
||||
@@ -378,6 +445,52 @@ export class PiSessionService {
|
||||
return this.active.size;
|
||||
}
|
||||
|
||||
async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse> {
|
||||
return previewResponseFromPlan(await this.cleanupPlan(request));
|
||||
}
|
||||
|
||||
async cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse> {
|
||||
const plan = await this.cleanupPlan(request);
|
||||
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) {
|
||||
if (this.activeSessionHasWork(input.sessionId)) {
|
||||
skippedBusySessionIds.add(input.sessionId);
|
||||
continue;
|
||||
}
|
||||
await this.closeActive(input.sessionId);
|
||||
readyArchiveInputs.push(input);
|
||||
}
|
||||
await this.archiveStoreArchiveMany(readyArchiveInputs);
|
||||
archiveInputs.push(...readyArchiveInputs);
|
||||
|
||||
for (const record of plan.deleteRecords) {
|
||||
if (this.activeSessionHasWork(record.sessionId)) {
|
||||
skippedBusySessionIds.add(record.sessionId);
|
||||
continue;
|
||||
}
|
||||
await this.closeActive(record.sessionId);
|
||||
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,
|
||||
deleteRecords,
|
||||
thresholds: plan.thresholds,
|
||||
generatedAt: plan.generatedAt,
|
||||
skippedBusySessionIds: [...skippedBusySessionIds],
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
clearInterval(this.heartbeat);
|
||||
this.clearCompactionDrainTimers();
|
||||
@@ -417,20 +530,25 @@ export class PiSessionService {
|
||||
return [...unarchivedSessions, ...archivedSessions];
|
||||
}
|
||||
|
||||
async start(cwd: string, parentSession?: string): Promise<ClientSession> {
|
||||
const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
|
||||
async start(cwd: string, options: StartSessionOptions = {}): Promise<ClientSession> {
|
||||
const active = await this.create(
|
||||
this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }),
|
||||
cwd,
|
||||
options.initialModel === undefined ? {} : { initialModel: options.initialModel },
|
||||
);
|
||||
const { session } = active.runtime;
|
||||
const created: ClientSession = {
|
||||
id: session.sessionId,
|
||||
path: session.sessionFile ?? "",
|
||||
cwd,
|
||||
persisted: sessionFileExists(session.sessionFile),
|
||||
created: new Date().toISOString(),
|
||||
modified: new Date().toISOString(),
|
||||
messageCount: session.messages.length,
|
||||
firstMessage: "",
|
||||
// Include the parent so listeners can nest the new session in the tree
|
||||
// immediately, instead of showing it flat until the next reload.
|
||||
...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
|
||||
...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
|
||||
};
|
||||
// Broadcast so other clients (and the spawning agent's UI) can add the new
|
||||
// session to their list without a manual reload.
|
||||
@@ -447,7 +565,7 @@ export class PiSessionService {
|
||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
const created = await this.start(decision.cwd);
|
||||
const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
|
||||
await this.prompt(created.id, input.prompt);
|
||||
this.logger.info(
|
||||
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||
@@ -466,7 +584,10 @@ export class PiSessionService {
|
||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
const created = await this.start(decision.cwd, input.parentSessionFile);
|
||||
const created = await this.start(decision.cwd, {
|
||||
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
|
||||
...(input.model === undefined ? {} : { initialModel: input.model }),
|
||||
});
|
||||
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
||||
const link: TrackedSubsessionLink = {
|
||||
parentSessionId: input.parentSessionId,
|
||||
@@ -957,7 +1078,7 @@ export class PiSessionService {
|
||||
}
|
||||
|
||||
async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]> {
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
|
||||
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
|
||||
if (parsed.length === 0) return [];
|
||||
await this.assertWritable(ref);
|
||||
const active = await this.getActive(ref);
|
||||
@@ -1011,6 +1132,22 @@ export class PiSessionService {
|
||||
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
||||
}
|
||||
|
||||
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
|
||||
this.publishActivity(session, "reloading resources", "active");
|
||||
try {
|
||||
await session.reload();
|
||||
this.publishActivity(session, "resources reloaded", "idle");
|
||||
this.publishStatus(session);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "reload failed", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
this.publishStatus(session);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async archive(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
|
||||
@@ -1019,6 +1156,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());
|
||||
@@ -1029,7 +1230,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,
|
||||
@@ -1056,6 +1257,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);
|
||||
@@ -1093,6 +1349,95 @@ 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({
|
||||
sessions,
|
||||
archivedRecords,
|
||||
activeSessions: this.cleanupActiveSessionStatuses(),
|
||||
thresholds: request.thresholds,
|
||||
...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
|
||||
now: this.now(),
|
||||
});
|
||||
}
|
||||
|
||||
private cleanupActiveSessionStatuses(): { sessionId: string; hasActiveWork: boolean }[] {
|
||||
return [...new Set(this.active.values())].map((active) => ({
|
||||
sessionId: active.runtime.session.sessionId,
|
||||
hasActiveWork: this.hasActiveWork(active.runtime.session),
|
||||
}));
|
||||
}
|
||||
|
||||
private activeSessionHasWork(sessionId: string): boolean {
|
||||
const active = this.active.get(sessionId);
|
||||
return active !== undefined && this.hasActiveWork(active.runtime.session);
|
||||
}
|
||||
|
||||
private reconcilableSessionIds(cwd: string, listedSessionIds: string[], archivedById: Map<string, ArchivedSessionRecord>): string[] {
|
||||
const sessionIds = new Set(listedSessionIds);
|
||||
for (const active of new Set(this.active.values())) {
|
||||
@@ -1114,7 +1459,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> {
|
||||
@@ -1234,8 +1592,13 @@ export class PiSessionService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||
private async create(sessionManager: PiSessionManager, cwd: string, options: Pick<StartSessionOptions, "initialModel"> = {}): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, {
|
||||
cwd,
|
||||
agentDir: this.agentDir,
|
||||
sessionManager,
|
||||
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
||||
});
|
||||
await this.bindSessionExtensions(runtime.session);
|
||||
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||
this.bindRuntime(active);
|
||||
@@ -1346,7 +1709,7 @@ export class PiSessionService {
|
||||
const model = session.model;
|
||||
if (model === undefined) return;
|
||||
|
||||
void generateShortSessionName(this.modelRegistry, model, firstMessage).then((name) => {
|
||||
void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
|
||||
this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
|
||||
}).catch(() => {
|
||||
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
|
||||
@@ -1496,6 +1859,7 @@ export class PiSessionService {
|
||||
const contextUsage = session.getContextUsage();
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
persisted: sessionFileExists(session.sessionFile),
|
||||
...(model === undefined ? {} : { model }),
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
isStreaming: session.isStreaming,
|
||||
@@ -1523,6 +1887,53 @@ export class PiSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
function previewResponseFromPlan(plan: SessionCleanupPlan): ClientSessionCleanupPreviewResponse {
|
||||
return {
|
||||
generatedAt: plan.generatedAt,
|
||||
thresholds: plan.thresholds,
|
||||
projects: plan.projects,
|
||||
totals: plan.totals,
|
||||
...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -1541,6 +1952,7 @@ function clientSessionFromListEntry(session: PiSessionListEntry): ClientSession
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
cwd: session.cwd,
|
||||
persisted: true,
|
||||
...(session.name === undefined ? {} : { name: session.name }),
|
||||
created: session.created.toISOString(),
|
||||
modified: session.modified.toISOString(),
|
||||
@@ -1743,6 +2155,15 @@ function sessionPathsEqual(a: string, b: string): boolean {
|
||||
return cwdPathsEqual(a, b);
|
||||
}
|
||||
|
||||
function sessionFileExists(sessionFile: string | undefined): sessionFile is string {
|
||||
if (sessionFile === undefined || sessionFile === "") return false;
|
||||
try {
|
||||
return statSync(sessionFile).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionFileMatches(session: PiAgentSession, expectedSessionFile: string | undefined): boolean {
|
||||
const sessionFile = nonEmptyString(session.sessionFile);
|
||||
return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSessionCleanupRequest, normalizeSessionCleanupThresholds, planSessionCleanup } from "./sessionCleanup.js";
|
||||
import type { PiSessionListEntry } from "./piSessionService.js";
|
||||
import type { ArchivedSessionRecord } from "./sessionArchiveStore.js";
|
||||
|
||||
describe("session cleanup planning", () => {
|
||||
it("plans cleanup by strict cutoffs and groups counts by stored cwd", () => {
|
||||
const now = new Date("2026-06-25T00:00:00.000Z");
|
||||
const archivedRecords: ArchivedSessionRecord[] = [
|
||||
archivedRecord("already-archived", "/unregistered", "2026-06-20T00:00:00.000Z"),
|
||||
archivedRecord("delete-old", "/other", "2026-06-14T23:59:59.999Z"),
|
||||
archivedRecord("keep-exact", "/other", "2026-06-15T00:00:00.000Z"),
|
||||
];
|
||||
|
||||
const plan = planSessionCleanup({
|
||||
now,
|
||||
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 10 },
|
||||
archivedRecords,
|
||||
sessions: [
|
||||
sessionEntry("archive-old", "/unregistered", "2026-05-25T23:59:59.999Z"),
|
||||
sessionEntry("keep-exact", "/unregistered", "2026-05-26T00:00:00.000Z"),
|
||||
sessionEntry("keep-new", "/unregistered", "2026-05-26T00:00:00.001Z"),
|
||||
sessionEntry("already-archived", "/unregistered", "2026-05-01T00:00:00.000Z"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-old"]);
|
||||
expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-old"]);
|
||||
expect(plan.projects).toEqual([
|
||||
{ cwd: "/other", archiveCount: 0, deleteCount: 1 },
|
||||
{ cwd: "/unregistered", archiveCount: 1, deleteCount: 0 },
|
||||
]);
|
||||
expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
|
||||
});
|
||||
|
||||
it("filters cleanup candidates to selected project cwd paths", () => {
|
||||
const plan = planSessionCleanup({
|
||||
now: new Date("2026-06-25T00:00:00.000Z"),
|
||||
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 },
|
||||
projectCwds: ["/repo-a"],
|
||||
sessions: [
|
||||
sessionEntry("archive-a", "/repo-a", "2026-05-01T00:00:00.000Z"),
|
||||
sessionEntry("archive-b", "/repo-b", "2026-05-01T00:00:00.000Z"),
|
||||
],
|
||||
archivedRecords: [
|
||||
archivedRecord("delete-a", "/repo-a", "2026-05-01T00:00:00.000Z"),
|
||||
archivedRecord("delete-b", "/repo-b", "2026-05-01T00:00:00.000Z"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-a"]);
|
||||
expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-a"]);
|
||||
expect(plan.projects).toEqual([{ cwd: "/repo-a", archiveCount: 1, deleteCount: 1 }]);
|
||||
expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
|
||||
});
|
||||
|
||||
it("skips archive and delete candidates that are busy in memory", () => {
|
||||
const plan = planSessionCleanup({
|
||||
now: new Date("2026-06-25T00:00:00.000Z"),
|
||||
thresholds: { archiveIdleDays: 1, deleteArchivedDays: 1 },
|
||||
sessions: [sessionEntry("busy-open", "/repo", "2026-06-01T00:00:00.000Z")],
|
||||
archivedRecords: [archivedRecord("busy-archived", "/repo", "2026-06-01T00:00:00.000Z")],
|
||||
activeSessions: [
|
||||
{ sessionId: "busy-open", hasActiveWork: true },
|
||||
{ sessionId: "busy-archived", hasActiveWork: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(plan.archiveInputs).toHaveLength(0);
|
||||
expect(plan.deleteRecords).toHaveLength(0);
|
||||
expect(plan.skippedBusySessionIds).toEqual(["busy-archived", "busy-open"]);
|
||||
expect(plan.totals).toEqual({ archiveCount: 0, deleteCount: 0 });
|
||||
});
|
||||
|
||||
it("validates optional runtime thresholds", () => {
|
||||
expect(normalizeSessionCleanupThresholds({ archiveIdleDays: 30, deleteArchivedDays: null })).toEqual({ archiveIdleDays: 30 });
|
||||
expect(normalizeSessionCleanupThresholds({})).toEqual({});
|
||||
expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: -1 })).toThrow("archiveIdleDays field must be a non-negative integer");
|
||||
expect(() => normalizeSessionCleanupThresholds({ deleteArchivedDays: 1.5 })).toThrow("deleteArchivedDays field must be a non-negative integer");
|
||||
expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: "30" })).toThrow("archiveIdleDays field must be a non-negative integer");
|
||||
});
|
||||
|
||||
it("validates optional selected project cwd paths", () => {
|
||||
expect(normalizeSessionCleanupRequest({ archiveIdleDays: 30, projectCwds: ["/repo", "/repo"] })).toEqual({
|
||||
thresholds: { archiveIdleDays: 30 },
|
||||
projectCwds: ["/repo"],
|
||||
});
|
||||
expect(normalizeSessionCleanupRequest({ projectCwds: null })).toEqual({ thresholds: {} });
|
||||
expect(() => normalizeSessionCleanupRequest({ projectCwds: ["/repo", 1] })).toThrow("projectCwds field must be an array of strings");
|
||||
});
|
||||
});
|
||||
|
||||
function sessionEntry(id: string, cwd: string, modified: string): PiSessionListEntry {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
path: `/sessions/${id}.jsonl`,
|
||||
created: new Date("2026-01-01T00:00:00.000Z"),
|
||||
modified: new Date(modified),
|
||||
messageCount: 1,
|
||||
firstMessage: "hello",
|
||||
allMessagesText: "hello",
|
||||
};
|
||||
}
|
||||
|
||||
function archivedRecord(sessionId: string, cwd: string, archivedAt: string): ArchivedSessionRecord {
|
||||
return { sessionId, cwd, archivedAt };
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds } from "../../shared/apiTypes.js";
|
||||
import type { PiSessionListEntry } from "./piSessionService.js";
|
||||
import type { ArchivedSessionRecord, ArchiveSessionInput } from "./sessionArchiveStore.js";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CleanupActiveSessionStatus {
|
||||
sessionId: string;
|
||||
hasActiveWork: boolean;
|
||||
}
|
||||
|
||||
export interface PlanSessionCleanupInput {
|
||||
sessions: readonly PiSessionListEntry[];
|
||||
archivedRecords: readonly ArchivedSessionRecord[];
|
||||
activeSessions?: readonly CleanupActiveSessionStatus[];
|
||||
thresholds: SessionCleanupThresholds;
|
||||
projectCwds?: readonly string[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface SessionCleanupPlan extends SessionCleanupPreviewResponse {
|
||||
archiveInputs: ArchiveSessionInput[];
|
||||
deleteRecords: ArchivedSessionRecord[];
|
||||
skippedBusySessionIds: string[];
|
||||
}
|
||||
|
||||
export interface NormalizedSessionCleanupRequest {
|
||||
thresholds: SessionCleanupThresholds;
|
||||
/** Stored cwd paths to include. Undefined means all discovered projects/workspaces. */
|
||||
projectCwds?: string[];
|
||||
}
|
||||
|
||||
export function normalizeSessionCleanupRequest(record: Record<string, unknown>): NormalizedSessionCleanupRequest {
|
||||
const projectCwds = optionalProjectCwds(record);
|
||||
return {
|
||||
thresholds: normalizeSessionCleanupThresholds(record),
|
||||
...(projectCwds === undefined ? {} : { projectCwds }),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSessionCleanupThresholds(record: Record<string, unknown>): SessionCleanupThresholds {
|
||||
const thresholds: SessionCleanupThresholds = {};
|
||||
const archiveIdleDays = optionalDayThreshold(record, "archiveIdleDays");
|
||||
const deleteArchivedDays = optionalDayThreshold(record, "deleteArchivedDays");
|
||||
if (archiveIdleDays !== undefined) thresholds.archiveIdleDays = archiveIdleDays;
|
||||
if (deleteArchivedDays !== undefined) thresholds.deleteArchivedDays = deleteArchivedDays;
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
export function planSessionCleanup(input: PlanSessionCleanupInput): SessionCleanupPlan {
|
||||
const thresholds = copyThresholds(input.thresholds);
|
||||
const archiveCutoff = cutoffTime(input.now, thresholds.archiveIdleDays);
|
||||
const deleteCutoff = cutoffTime(input.now, thresholds.deleteArchivedDays);
|
||||
const archivedIds = new Set(input.archivedRecords.map((record) => record.sessionId));
|
||||
const includedCwds = input.projectCwds === undefined ? undefined : new Set(input.projectCwds);
|
||||
const busySessionIds = new Set((input.activeSessions ?? []).filter((session) => session.hasActiveWork).map((session) => session.sessionId));
|
||||
const skippedBusy = new Set<string>();
|
||||
const archiveInputs: ArchiveSessionInput[] = [];
|
||||
const deleteRecords: ArchivedSessionRecord[] = [];
|
||||
|
||||
if (archiveCutoff !== undefined) {
|
||||
for (const session of uniqueSessionsById(input.sessions)) {
|
||||
if (archivedIds.has(session.id)) continue;
|
||||
if (includedCwds !== undefined && !includedCwds.has(session.cwd)) continue;
|
||||
if (!isBefore(session.modified, archiveCutoff)) continue;
|
||||
if (busySessionIds.has(session.id)) {
|
||||
skippedBusy.add(session.id);
|
||||
continue;
|
||||
}
|
||||
archiveInputs.push(archiveInputFromListEntry(session));
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteCutoff !== undefined) {
|
||||
for (const record of input.archivedRecords) {
|
||||
if (includedCwds !== undefined && !includedCwds.has(record.cwd)) continue;
|
||||
if (!isTimestampBefore(record.archivedAt, deleteCutoff)) continue;
|
||||
if (busySessionIds.has(record.sessionId)) {
|
||||
skippedBusy.add(record.sessionId);
|
||||
continue;
|
||||
}
|
||||
deleteRecords.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...summarizeSessionCleanupTargets({ archiveInputs, deleteRecords, thresholds, generatedAt: input.now.toISOString(), skippedBusySessionIds: [...skippedBusy] }),
|
||||
archiveInputs,
|
||||
deleteRecords,
|
||||
skippedBusySessionIds: [...skippedBusy].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSessionCleanupTargets(input: {
|
||||
archiveInputs: readonly ArchiveSessionInput[];
|
||||
deleteRecords: readonly ArchivedSessionRecord[];
|
||||
thresholds: SessionCleanupThresholds;
|
||||
generatedAt: string;
|
||||
skippedBusySessionIds?: readonly string[];
|
||||
}): SessionCleanupPreviewResponse {
|
||||
const projectsByCwd = new Map<string, SessionCleanupProjectSummary>();
|
||||
let archiveCount = 0;
|
||||
let deleteCount = 0;
|
||||
|
||||
for (const session of input.archiveInputs) {
|
||||
archiveCount += 1;
|
||||
projectSummary(projectsByCwd, session.cwd).archiveCount += 1;
|
||||
}
|
||||
|
||||
for (const record of input.deleteRecords) {
|
||||
deleteCount += 1;
|
||||
projectSummary(projectsByCwd, record.cwd).deleteCount += 1;
|
||||
}
|
||||
|
||||
const skippedBusySessionIds = [...new Set(input.skippedBusySessionIds ?? [])].sort();
|
||||
return {
|
||||
generatedAt: input.generatedAt,
|
||||
thresholds: copyThresholds(input.thresholds),
|
||||
projects: [...projectsByCwd.values()].sort((a, b) => a.cwd.localeCompare(b.cwd)),
|
||||
totals: { archiveCount, deleteCount },
|
||||
...(skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds }),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSessionCleanupExecution(input: {
|
||||
archiveInputs: readonly ArchiveSessionInput[];
|
||||
deleteRecords: readonly ArchivedSessionRecord[];
|
||||
thresholds: SessionCleanupThresholds;
|
||||
generatedAt: string;
|
||||
skippedBusySessionIds?: readonly string[];
|
||||
}): SessionCleanupExecuteResponse {
|
||||
return {
|
||||
...summarizeSessionCleanupTargets(input),
|
||||
archivedSessionIds: input.archiveInputs.map((session) => session.sessionId),
|
||||
deletedSessionIds: input.deleteRecords.map((record) => record.sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalDayThreshold(record: Record<string, unknown>, field: keyof SessionCleanupThresholds): number | undefined {
|
||||
const value = record[field];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${field} field must be a non-negative integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalProjectCwds(record: Record<string, unknown>): string[] | undefined {
|
||||
const value = record["projectCwds"];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error("projectCwds field must be an array of strings");
|
||||
return [...new Set(value)];
|
||||
}
|
||||
|
||||
function cutoffTime(now: Date, days: number | undefined): number | undefined {
|
||||
return days === undefined ? undefined : now.getTime() - days * DAY_MS;
|
||||
}
|
||||
|
||||
function isBefore(value: Date, cutoff: number): boolean {
|
||||
const time = value.getTime();
|
||||
return Number.isFinite(time) && time < cutoff;
|
||||
}
|
||||
|
||||
function isTimestampBefore(value: string, cutoff: number): boolean {
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) && time < cutoff;
|
||||
}
|
||||
|
||||
function uniqueSessionsById(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] {
|
||||
const sessionsById = new Map<string, PiSessionListEntry>();
|
||||
for (const session of sessions) {
|
||||
const existing = sessionsById.get(session.id);
|
||||
if (existing === undefined || session.modified.getTime() > existing.modified.getTime()) sessionsById.set(session.id, session);
|
||||
}
|
||||
return [...sessionsById.values()];
|
||||
}
|
||||
|
||||
function archiveInputFromListEntry(session: PiSessionListEntry): ArchiveSessionInput {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
cwd: session.cwd,
|
||||
path: session.path,
|
||||
created: session.created.toISOString(),
|
||||
modified: session.modified.toISOString(),
|
||||
messageCount: session.messageCount,
|
||||
firstMessage: session.firstMessage,
|
||||
...(session.name === undefined ? {} : { name: session.name }),
|
||||
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
||||
};
|
||||
}
|
||||
|
||||
function projectSummary(projectsByCwd: Map<string, SessionCleanupProjectSummary>, cwd: string): SessionCleanupProjectSummary {
|
||||
const existing = projectsByCwd.get(cwd);
|
||||
if (existing !== undefined) return existing;
|
||||
const created = { cwd, archiveCount: 0, deleteCount: 0 };
|
||||
projectsByCwd.set(cwd, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function copyThresholds(thresholds: SessionCleanupThresholds): SessionCleanupThresholds {
|
||||
const copy: SessionCleanupThresholds = {};
|
||||
if (thresholds.archiveIdleDays !== undefined) copy.archiveIdleDays = thresholds.archiveIdleDays;
|
||||
if (thresholds.deleteArchivedDays !== undefined) copy.deleteArchivedDays = thresholds.deleteArchivedDays;
|
||||
return copy;
|
||||
}
|
||||
@@ -106,6 +106,30 @@ describe("SessionCommandService", () => {
|
||||
expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests");
|
||||
});
|
||||
|
||||
it("reloads runtime resources through the injected lifecycle callback", async () => {
|
||||
const active = activeSession();
|
||||
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
|
||||
|
||||
await expect(service.run("s1", "/reload")).resolves.toEqual({
|
||||
type: "done",
|
||||
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
|
||||
});
|
||||
expect(reloadSession).toHaveBeenCalledWith(active.runtime.session);
|
||||
});
|
||||
|
||||
it("rejects runtime reload while the session has active work", async () => {
|
||||
const active = activeSession({ isBashRunning: true });
|
||||
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
|
||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
|
||||
|
||||
await expect(service.run("s1", "/reload")).resolves.toEqual({
|
||||
type: "unsupported",
|
||||
message: "Cannot reload while the session is active. Stop current activity before reloading.",
|
||||
});
|
||||
expect(reloadSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates fork selection requests from newest message to oldest and responds with selected entry", async () => {
|
||||
const active = activeSession({
|
||||
getUserMessagesForForking: vi.fn(() => [
|
||||
|
||||
@@ -47,6 +47,12 @@ export interface CommandEventPublisher {
|
||||
publishGlobal?(event: Extract<SessionUiEvent, { type: "session.name" }>): void;
|
||||
}
|
||||
|
||||
export interface SessionCommandLifecycle<TSession extends CommandSession = CommandSession> {
|
||||
onCompactionStart?: (session: TSession) => void;
|
||||
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
|
||||
reloadSession?: (session: TSession) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SessionCommandNaming {
|
||||
listSessionNames?: (cwd: string) => Promise<readonly string[]>;
|
||||
}
|
||||
@@ -65,10 +71,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
private readonly getActive: GetCommandActiveSession<TSession>,
|
||||
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
|
||||
private readonly events: CommandEventPublisher,
|
||||
private readonly lifecycle: {
|
||||
onCompactionStart?: (session: TSession) => void;
|
||||
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
|
||||
} = {},
|
||||
private readonly lifecycle: SessionCommandLifecycle<TSession> = {},
|
||||
private readonly naming: SessionCommandNaming = {},
|
||||
) {}
|
||||
|
||||
@@ -93,6 +96,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
if (name === "session") return { type: "done", message: formatSessionStats(session) };
|
||||
if (name === "name") return this.nameSession(active, rest);
|
||||
if (name === "compact") return this.compact(session, rest);
|
||||
if (name === "reload") return this.reload(session);
|
||||
if (name === "clone") return this.clone(active);
|
||||
if (name === "fork") return this.fork(active);
|
||||
|
||||
@@ -140,6 +144,19 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
return { type: "done", message: "Compaction started…" };
|
||||
}
|
||||
|
||||
private async reload(session: TSession): Promise<ClientCommandResult> {
|
||||
if (sessionHasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
|
||||
if (this.lifecycle.reloadSession === undefined) return { type: "unsupported", message: "/reload is not available for this session runtime." };
|
||||
|
||||
try {
|
||||
await this.lifecycle.reloadSession(session);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { type: "unsupported", message: `Reload failed: ${message}` };
|
||||
}
|
||||
return { type: "done", message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes." };
|
||||
}
|
||||
|
||||
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
|
||||
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
|
||||
const leafId = active.runtime.session.sessionManager.getLeafId();
|
||||
|
||||
@@ -1,7 +1,70 @@
|
||||
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
||||
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cleanSessionName, fallbackSessionName } from "./sessionNameGenerator.js";
|
||||
import { cleanSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
|
||||
function fakeModel(): Model<Api> {
|
||||
return { id: "fake-model", name: "Fake Model", api: "anthropic-messages", provider: "anthropic", baseUrl: "https://example.test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 };
|
||||
}
|
||||
|
||||
function fakeAssistantMessage(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "fake-model",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function streamThatCompletes(text: string): StreamFn {
|
||||
return () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const message = fakeAssistantMessage({ content: [{ type: "text", text }] });
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
stream.end(message);
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
function streamThatErrors(): StreamFn {
|
||||
return () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const message = fakeAssistantMessage({ stopReason: "error", errorMessage: "boom" });
|
||||
stream.push({ type: "error", reason: "error", error: message });
|
||||
stream.end(message);
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
describe("sessionNameGenerator", () => {
|
||||
it("generates a session name by calling the injected streamFn", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const stream = streamThatCompletes('Title: "Fix the bug"');
|
||||
const streamFn: StreamFn = (model, context, options) => {
|
||||
calls.push({ model, context, options });
|
||||
return stream(model, context, options);
|
||||
};
|
||||
|
||||
const name = await generateShortSessionName(streamFn, fakeModel(), "Please fix the login bug");
|
||||
|
||||
expect(name).toBe("Fix the bug");
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns undefined when the stream reports an error", async () => {
|
||||
const streamFn = streamThatErrors();
|
||||
|
||||
const name = await generateShortSessionName(streamFn, fakeModel(), "Please fix the login bug");
|
||||
|
||||
expect(name).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cleans model-generated titles", () => {
|
||||
expect(cleanSessionName('Title: "Fix Session Naming."\nextra')).toBe("Fix Session Naming");
|
||||
});
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
||||
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
|
||||
const SESSION_NAME_TIMEOUT_MS = 10_000;
|
||||
const SESSION_NAME_MAX_INPUT_CHARS = 4_000;
|
||||
const SESSION_NAME_MAX_LENGTH = 60;
|
||||
const FALLBACK_SESSION_NAME_MAX_WORDS = 6;
|
||||
const PI_AI_COMPAT_MODULE = ["@earendil-works/pi-ai", "compat"].join("/");
|
||||
|
||||
interface SessionNameApiProvider {
|
||||
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
||||
}
|
||||
|
||||
interface PiAiProviderRegistryModule {
|
||||
getApiProvider?: (api: Api) => SessionNameApiProvider | undefined;
|
||||
}
|
||||
|
||||
type ModuleImporter = (specifier: string) => Promise<unknown>;
|
||||
|
||||
let piAiProviderRegistryModulePromise: Promise<PiAiProviderRegistryModule> | undefined;
|
||||
|
||||
export async function generateShortSessionName<TApi extends Api>(modelRegistry: ModelRegistry, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
|
||||
const providerRegistry = await getPiAiProviderRegistryModule();
|
||||
const provider = providerRegistry.getApiProvider?.(model.api);
|
||||
if (provider === undefined) return undefined;
|
||||
|
||||
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
||||
if (!auth.ok) return undefined;
|
||||
|
||||
const stream = provider.streamSimple(
|
||||
export async function generateShortSessionName<TApi extends Api>(streamFn: StreamFn, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
|
||||
const stream = await streamFn(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "Generate a concise title for a coding-agent chat session. Return only the title, with no quotes or punctuation wrapper.",
|
||||
@@ -41,8 +21,6 @@ export async function generateShortSessionName<TApi extends Api>(modelRegistry:
|
||||
maxTokens: 24,
|
||||
reasoning: "minimal",
|
||||
signal: AbortSignal.timeout(SESSION_NAME_TIMEOUT_MS),
|
||||
...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
|
||||
...(auth.headers === undefined ? {} : { headers: auth.headers }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -81,42 +59,6 @@ export function cleanSessionName(value: string): string | undefined {
|
||||
return title === "" ? undefined : title;
|
||||
}
|
||||
|
||||
async function getPiAiProviderRegistryModule(importer: ModuleImporter = (specifier) => import(specifier)): Promise<PiAiProviderRegistryModule> {
|
||||
piAiProviderRegistryModulePromise ??= loadPiAiProviderRegistryModule(importer);
|
||||
return piAiProviderRegistryModulePromise;
|
||||
}
|
||||
|
||||
async function loadPiAiProviderRegistryModule(importer: ModuleImporter): Promise<PiAiProviderRegistryModule> {
|
||||
const compatModule = await importOptionalPiAiModule(PI_AI_COMPAT_MODULE, importer);
|
||||
if (hasGetApiProvider(compatModule)) return compatModule;
|
||||
|
||||
const rootModule = await importer("@earendil-works/pi-ai");
|
||||
if (hasGetApiProvider(rootModule)) return rootModule;
|
||||
return {};
|
||||
}
|
||||
|
||||
async function importOptionalPiAiModule(specifier: string, importer: ModuleImporter): Promise<unknown> {
|
||||
try {
|
||||
return await importer(specifier);
|
||||
} catch (error) {
|
||||
if (isModuleUnavailableError(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasGetApiProvider(moduleValue: unknown): moduleValue is PiAiProviderRegistryModule {
|
||||
return typeof moduleValue === "object"
|
||||
&& moduleValue !== null
|
||||
&& "getApiProvider" in moduleValue
|
||||
&& typeof moduleValue.getApiProvider === "function";
|
||||
}
|
||||
|
||||
function isModuleUnavailableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = "code" in error ? error.code : undefined;
|
||||
return code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED";
|
||||
}
|
||||
|
||||
function textFromAssistant(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((part) => part.type === "text")
|
||||
|
||||
@@ -2,9 +2,11 @@ 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 { 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";
|
||||
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let service: PiSessionService;
|
||||
@@ -136,17 +138,124 @@ describe("session routes", () => {
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes cleanup requests for preview and execute routes", 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 previewResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup/preview", payload: { archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo-a", "/repo-a"] } });
|
||||
const executeResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: null, deleteArchivedDays: 7, projectCwds: ["/repo-b"] } });
|
||||
|
||||
expect(previewResponse.statusCode).toBe(200);
|
||||
expect(executeResponse.statusCode).toBe(200);
|
||||
expect(routeService.cleanupPreviewCalls).toEqual([{ thresholds: { archiveIdleDays: 30 }, projectCwds: ["/repo-a"] }]);
|
||||
expect(routeService.cleanupCalls).toEqual([{ thresholds: { deleteArchivedDays: 7 }, projectCwds: ["/repo-b"] }]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid cleanup thresholds 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/cleanup", payload: { archiveIdleDays: -1 } });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "archiveIdleDays field must be a non-negative integer" });
|
||||
expect(routeService.cleanupCalls).toEqual([]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
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 {
|
||||
readonly calls: unknown[] = [];
|
||||
readonly reloadCalls: (string | PiSessionRef)[] = [];
|
||||
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
||||
reloadError: Error | undefined;
|
||||
|
||||
constructor(eventHub: SessionEventHub) {
|
||||
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
|
||||
}
|
||||
|
||||
override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
|
||||
this.cleanupPreviewCalls.push(request);
|
||||
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
|
||||
}
|
||||
|
||||
override cleanup(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupExecuteResponse> {
|
||||
this.cleanupCalls.push(request);
|
||||
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,7 +1,9 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
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";
|
||||
import { normalizeSessionCleanupRequest } from "./sessionCleanup.js";
|
||||
|
||||
type SessionLookup = string | PiSessionRef;
|
||||
|
||||
@@ -46,6 +48,38 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body)));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.cleanup(normalizeSessionCleanupRequest(optionalRecord(request.body)));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
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)) };
|
||||
@@ -263,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);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,20 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
||||
|
||||
// The spawn tool's execute() never reads ctx, so an empty stub is sufficient.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub; execute() does not use ctx.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
|
||||
const ctx = {} as ExtensionContext;
|
||||
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
|
||||
const ctxWithModel = { model: dispatchModel } as ExtensionContext;
|
||||
|
||||
describe("createSpawnSessionToolDefinition", () => {
|
||||
it("passes the spawning cwd and params to the spawn callback and reports success", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctx);
|
||||
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxWithModel);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature" });
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel });
|
||||
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
|
||||
});
|
||||
@@ -27,11 +29,20 @@ describe("createSpawnSessionToolDefinition", () => {
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
||||
});
|
||||
|
||||
it("omits the inherited model when the dispatching session has no current model", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
await tool.execute("call-3", { prompt: "continue" }, undefined, undefined, ctx);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
||||
});
|
||||
|
||||
it("propagates the spawn callback error so the agent loop reports it", async () => {
|
||||
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
await expect(tool.execute("call-3", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
|
||||
await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
|
||||
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface SpawnSessionResult {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
|
||||
|
||||
export interface SpawnSessionInvocation {
|
||||
spawningCwd: string;
|
||||
prompt: string;
|
||||
cwd: string | undefined;
|
||||
/** Current model from the dispatching session, used as the spawned session's default. */
|
||||
model?: SpawnSessionModel;
|
||||
}
|
||||
|
||||
export interface SpawnSessionToolDeps {
|
||||
@@ -40,11 +44,16 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw
|
||||
description: "Start a new, independent pi-web session and send it an initial prompt. Use this to dispatch a fresh agent to continue work or follow a plan. The new session runs on its own and a human can interact with it; you do not receive its output.",
|
||||
promptSnippet: "spawn_session: start a new independent session with a first prompt",
|
||||
parameters: SpawnSessionParams,
|
||||
async execute(_toolCallId, params) {
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
// Failures throw: the agent loop turns the thrown message into an error
|
||||
// tool result the model sees, so the spawning agent can adapt (e.g. pick a
|
||||
// valid workspace) rather than crash.
|
||||
const result = await deps.spawn({ spawningCwd, prompt: params.prompt, cwd: params.cwd });
|
||||
const result = await deps.spawn({
|
||||
spawningCwd,
|
||||
prompt: params.prompt,
|
||||
cwd: params.cwd,
|
||||
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `Started session ${result.sessionId} in ${result.cwd}.` }],
|
||||
details: result,
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
|
||||
function ctxFor(sessionId: string, sessionFile: string | undefined): ExtensionContext {
|
||||
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
|
||||
|
||||
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext {
|
||||
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
|
||||
// The subsession tools only read sessionManager.getSessionId/getSessionFile.
|
||||
// The subsession tools only read sessionManager.getSessionId/getSessionFile and model.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
|
||||
return { sessionManager } as unknown as ExtensionContext;
|
||||
return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
function tools(deps: Partial<SubsessionToolDeps>) {
|
||||
@@ -36,7 +38,7 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
|
||||
const { spawn: spawnTool } = tools({ spawn });
|
||||
|
||||
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/repos/a",
|
||||
@@ -44,11 +46,27 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
parentSessionFile: "/sessions/parent-1.jsonl",
|
||||
prompt: "do it",
|
||||
cwd: "/repos/a-feature",
|
||||
model: dispatchModel,
|
||||
});
|
||||
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
|
||||
expect(firstText(result.content)).toContain("Started subsession child-1");
|
||||
});
|
||||
|
||||
it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-2", cwd: "/repos/a" }));
|
||||
const { spawn: spawnTool } = tools({ spawn });
|
||||
|
||||
await spawnTool.execute("call-modeless", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/repos/a",
|
||||
parentSessionId: "parent-1",
|
||||
parentSessionFile: undefined,
|
||||
prompt: "do it",
|
||||
cwd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("list_subsessions reports the caller's subsessions and their status", async () => {
|
||||
const list = vi.fn(() => Promise.resolve([
|
||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
|
||||
|
||||
/** Lifecycle phase of a tracked subsession as seen by its parent. */
|
||||
@@ -10,6 +10,8 @@ export interface SpawnSubsessionResult {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>;
|
||||
|
||||
export interface SpawnSubsessionInvocation {
|
||||
/** cwd of the session that invoked the tool (used for project-scope checks). */
|
||||
spawningCwd: string;
|
||||
@@ -19,6 +21,8 @@ export interface SpawnSubsessionInvocation {
|
||||
parentSessionFile: string | undefined;
|
||||
prompt: string;
|
||||
cwd: string | undefined;
|
||||
/** Current model from the dispatching session, used as the spawned session's default. */
|
||||
model?: SpawnSubsessionModel;
|
||||
}
|
||||
|
||||
export interface SubsessionSummary {
|
||||
@@ -180,7 +184,14 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const result = await deps.spawn({ spawningCwd, parentSessionId, parentSessionFile, prompt: params.prompt, cwd: params.cwd });
|
||||
const result = await deps.spawn({
|
||||
spawningCwd,
|
||||
parentSessionId,
|
||||
parentSessionFile,
|
||||
prompt: params.prompt,
|
||||
cwd: params.cwd,
|
||||
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `Started subsession ${result.sessionId} in ${result.cwd}. You will be notified when it stops working.` }],
|
||||
details: result,
|
||||
|
||||
@@ -4,6 +4,10 @@ export type {
|
||||
SessionRef as ClientSessionRef,
|
||||
SessionInfo as ClientSession,
|
||||
ArchiveSessionsResponse as ClientArchiveSessionsResponse,
|
||||
SessionCleanupRequest as ClientSessionCleanupRequest,
|
||||
SessionCleanupThresholds as ClientSessionCleanupThresholds,
|
||||
SessionCleanupPreviewResponse as ClientSessionCleanupPreviewResponse,
|
||||
SessionCleanupExecuteResponse as ClientSessionCleanupExecuteResponse,
|
||||
MessagePage as ClientMessagePage,
|
||||
SessionStatus as ClientSessionStatus,
|
||||
SessionModel as ClientSessionModel,
|
||||
|
||||
@@ -76,6 +76,37 @@ describe("file suggestions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for both git probes before falling back in all-file scope", async () => {
|
||||
let releaseUntracked: (() => void) | undefined;
|
||||
let resolved = false;
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.reject(new Error("not a git repository"));
|
||||
if (file === "git" && args.join(" ") === "ls-files --others --exclude-standard -z") {
|
||||
return new Promise<{ stdout: string }>((resolve) => {
|
||||
releaseUntracked = () => {
|
||||
resolve({ stdout: "" });
|
||||
};
|
||||
});
|
||||
}
|
||||
if (file === "rg") return Promise.resolve({ stdout: "sdk.md\n" });
|
||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||
},
|
||||
};
|
||||
|
||||
const suggestions = listFileSuggestions("/repo", "sdk", { scope: "all" }, deps).then((value) => {
|
||||
resolved = true;
|
||||
return value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(resolved).toBe(false);
|
||||
expect(releaseUntracked).toBeDefined();
|
||||
|
||||
releaseUntracked?.();
|
||||
await expect(suggestions).resolves.toEqual([{ path: "sdk.md", kind: "other" }]);
|
||||
});
|
||||
|
||||
it("keeps git untracked files in all-file scope when the broad scan misses them", async () => {
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
|
||||
@@ -253,13 +253,16 @@ async function listTrackedFiles(cwd: string, exec: CommandRunner): Promise<Clien
|
||||
}
|
||||
|
||||
async function listGitFiles(cwd: string, exec: CommandRunner): Promise<ClientFileSuggestion[]> {
|
||||
const [tracked, untracked] = await Promise.all([
|
||||
const [trackedResult, untrackedResult] = await Promise.allSettled([
|
||||
git(cwd, ["ls-files", "-z"], exec),
|
||||
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
|
||||
]);
|
||||
] as const);
|
||||
if (trackedResult.status === "rejected") throw trackedResult.reason;
|
||||
if (untrackedResult.status === "rejected") throw untrackedResult.reason;
|
||||
|
||||
return [
|
||||
...withDirectories(nulRecords(tracked), "tracked"),
|
||||
...withDirectories(nulRecords(untracked), "untracked"),
|
||||
...withDirectories(nulRecords(trackedResult.value), "tracked"),
|
||||
...withDirectories(nulRecords(untrackedResult.value), "untracked"),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user