Archived
Merge remote-tracking branch 'origin/main' into investigate/issue-12-session-dir
# Conflicts: # src/client/src/api.ts # src/client/src/api/clients.ts # src/client/src/api/federatedRouteContract.test.ts # src/server/sessions/piSessionService.ts # src/server/sessions/sessionRoutes.ts
This commit is contained in:
+75
-12
@@ -12,6 +12,7 @@ import { MachineService } from "./machines/machineService.js";
|
||||
import { MachineStore } from "./machines/machineStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
|
||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
@@ -36,22 +37,20 @@ beforeEach(async () => {
|
||||
return remoteClient;
|
||||
},
|
||||
now: () => new Date("2026-05-25T00:00:00.000Z"),
|
||||
localStatus: () => Promise.resolve({
|
||||
localRuntime: () => Promise.resolve({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", stale: false, available: true },
|
||||
web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
|
||||
messages: [],
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
}),
|
||||
}),
|
||||
sessionDaemon: fakeSessionDaemon(),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }),
|
||||
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 }] }),
|
||||
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
||||
},
|
||||
clientDist: false,
|
||||
@@ -109,6 +108,31 @@ describe("buildApp", () => {
|
||||
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
|
||||
});
|
||||
|
||||
it("reports effective machine runtime capabilities for remote machines", 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" },
|
||||
body: {
|
||||
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] },
|
||||
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
},
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
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(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
|
||||
});
|
||||
|
||||
it("proxies allowlisted remote HTTP routes through the selected machine", 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 }>();
|
||||
@@ -297,11 +321,11 @@ describe("buildApp", () => {
|
||||
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);
|
||||
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] });
|
||||
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
|
||||
|
||||
const pluginsResponse = await app.inject({ method: "GET", url: "/api/plugins" });
|
||||
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", enabled: true }] });
|
||||
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 assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
|
||||
expect(assetResponse.statusCode).toBe(200);
|
||||
@@ -318,7 +342,7 @@ describe("buildApp", () => {
|
||||
const requestJson = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local" }] },
|
||||
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
|
||||
}));
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
@@ -331,7 +355,7 @@ describe("buildApp", () => {
|
||||
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
expect(manifestResponse.json()).toEqual({
|
||||
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local" }],
|
||||
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
|
||||
|
||||
@@ -343,6 +367,45 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123");
|
||||
});
|
||||
|
||||
it("drops unsafe remote machine plugin manifest modules", 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 }>();
|
||||
remoteClient = fakeRemoteClient({
|
||||
requestJson: vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: {
|
||||
plugins: [
|
||||
{ id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" },
|
||||
{ id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" },
|
||||
{ id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" },
|
||||
],
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
|
||||
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
expect(manifestResponse.json()).toEqual({
|
||||
plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects remote machine plugin asset traversal 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 request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns stable errors for invalid project requests", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
+11
-4
@@ -17,7 +17,8 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||
@@ -91,8 +92,13 @@ 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 machines = deps.machines ?? new MachineService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
|
||||
});
|
||||
const machines = deps.machines ?? new MachineService(undefined, {
|
||||
localRuntime: () => getPiWebRuntime(sessionDaemon),
|
||||
});
|
||||
|
||||
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
|
||||
|
||||
@@ -104,8 +110,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
return reply.type(asset.contentType).send(asset.content);
|
||||
});
|
||||
|
||||
app.get("/api/pi-web/status", async () => getPiWebStatus());
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
|
||||
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
|
||||
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());
|
||||
registerConfigRoutes(app, deps.config);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ interface RemotePluginManifestEntry {
|
||||
module: string;
|
||||
source?: string;
|
||||
scope?: string;
|
||||
machineSpecific?: boolean;
|
||||
}
|
||||
|
||||
interface RemotePluginManifest {
|
||||
@@ -59,8 +60,14 @@ export async function proxyMachinePluginAsset(machines: MachinePluginProxyMachin
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestPath = remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl);
|
||||
if (requestPath === undefined) {
|
||||
await reply.code(400).send({ error: "Invalid remote PI WEB plugin asset path" });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await client.request("GET", remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl));
|
||||
const upstream = await client.request("GET", requestPath);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
if (upstream.body === undefined) await reply.send();
|
||||
@@ -87,29 +94,57 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
|
||||
|
||||
function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined {
|
||||
if (!isPiWebPluginId(pluginId)) return undefined;
|
||||
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
|
||||
const base = new URL(prefix, "http://pi-web.local");
|
||||
try {
|
||||
const url = new URL(module, "http://pi-web.local");
|
||||
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
|
||||
if (url.pathname.startsWith(prefix)) {
|
||||
return { path: url.pathname.slice(prefix.length), query: url.search };
|
||||
}
|
||||
if (!module.startsWith("/") && !/^https?:\/\//iu.test(module)) {
|
||||
const [path, query = ""] = module.split("?", 2);
|
||||
if (path !== undefined && path !== "") return { path, query: query === "" ? "" : `?${query}` };
|
||||
}
|
||||
const url = new URL(module, base);
|
||||
if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined;
|
||||
const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length));
|
||||
return path === undefined ? undefined : { path, query: url.search };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string {
|
||||
function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string | undefined {
|
||||
const path = safeRemotePluginAssetPath(assetPath);
|
||||
if (path === undefined) return undefined;
|
||||
const query = requestUrl.includes("?") ? requestUrl.slice(requestUrl.indexOf("?")) : "";
|
||||
return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${encodePathSegments(assetPath)}${query}`;
|
||||
return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${path}${query}`;
|
||||
}
|
||||
|
||||
function encodePathSegments(path: string): string {
|
||||
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
||||
function safeRemotePluginAssetPath(path: string): string | undefined {
|
||||
const segments: string[] = [];
|
||||
for (const rawSegment of path.split("/")) {
|
||||
const segment = safeRemotePluginAssetPathSegment(rawSegment);
|
||||
if (segment === undefined) return undefined;
|
||||
if (segment === "") continue;
|
||||
segments.push(segment);
|
||||
}
|
||||
if (segments.length === 0) return undefined;
|
||||
return segments.map((segment) => encodeURIComponent(segment)).join("/");
|
||||
}
|
||||
|
||||
function safeRemotePluginAssetPathSegment(rawSegment: string): string | undefined {
|
||||
if (rawSegment === "" || rawSegment === ".") return "";
|
||||
if (/%(?:2f|5c)/iu.test(rawSegment)) return undefined;
|
||||
let segment: string;
|
||||
try {
|
||||
segment = decodeURIComponent(rawSegment);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (segment === "" || segment === ".") return "";
|
||||
if (segment === ".." || segment.includes("/") || segment.includes("\\") || hasControlCharacter(segment)) return undefined;
|
||||
return segment;
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseRemoteManifest(value: unknown): RemotePluginManifest {
|
||||
@@ -124,11 +159,18 @@ function parseRemoteManifest(value: unknown): RemotePluginManifest {
|
||||
module: entry["module"],
|
||||
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
|
||||
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
|
||||
...(parseRemoteMachineSpecific(entry["machineSpecific"])),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRemoteMachineSpecific(value: unknown): { machineSpecific?: boolean } {
|
||||
if (value === undefined) return {};
|
||||
if (typeof value !== "boolean") throw new Error("Invalid remote PI WEB plugin manifest entry");
|
||||
return { machineSpecific: value };
|
||||
}
|
||||
|
||||
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined) continue;
|
||||
|
||||
@@ -18,6 +18,12 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
|
||||
return health;
|
||||
});
|
||||
|
||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
|
||||
const runtime = await machines.runtime(request.params.machineId);
|
||||
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||
return runtime;
|
||||
});
|
||||
|
||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
|
||||
const machine = await machines.get(request.params.machineId);
|
||||
if (machine === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MachineService } from "./machineService.js";
|
||||
import { MachineStore, machineStorePath } from "./machineStore.js";
|
||||
|
||||
@@ -69,6 +69,34 @@ describe("MachineService", () => {
|
||||
await expect(service.add({ name: "Bad", baseUrl: "https://example.test", headers: { Connection: "close" } })).rejects.toThrow("not allowed");
|
||||
});
|
||||
|
||||
it("uses the lightweight runtime check for local machine health", async () => {
|
||||
const localRuntime = vi.fn(() => Promise.resolve({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web" as const, label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [] },
|
||||
sessiond: { component: "sessiond" as const, label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [] },
|
||||
},
|
||||
capabilities: [],
|
||||
}));
|
||||
const healthService = new MachineService(new MachineStore(storePath), {
|
||||
localRuntime,
|
||||
now: () => new Date("2026-05-25T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const health = await healthService.health("local");
|
||||
|
||||
expect(localRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(health).toEqual({
|
||||
machineId: "local",
|
||||
ok: true,
|
||||
checkedAt: "2026-05-25T00:00:00.000Z",
|
||||
status: "online",
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", stale: false, available: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not allow local machine mutation", async () => {
|
||||
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
|
||||
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Machine, MachineHealth, PiWebComponentStatus, PiWebStatusResponse } from "../../shared/apiTypes.js";
|
||||
import { getPiWebStatus } from "../piWebStatus.js";
|
||||
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
|
||||
import { isPiWebCapability } from "../../shared/capabilities.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";
|
||||
|
||||
@@ -13,10 +14,11 @@ export interface CreateMachineInput {
|
||||
export type UpdateMachineInput = Partial<CreateMachineInput>;
|
||||
|
||||
export interface MachineServiceDependencies {
|
||||
localStatus?: () => Promise<PiWebStatusResponse>;
|
||||
localRuntime?: () => Promise<PiWebRuntimeResponse>;
|
||||
remoteClientFactory?: (machine: StoredMachine) => MachineClient;
|
||||
now?: () => Date;
|
||||
healthCacheTtlMs?: number;
|
||||
runtimeCacheTtlMs?: number;
|
||||
}
|
||||
|
||||
const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z";
|
||||
@@ -24,6 +26,7 @@ const DEFAULT_HEALTH_CACHE_TTL_MS = 5_000;
|
||||
|
||||
export class MachineService {
|
||||
private readonly healthCache = new Map<string, { expiresAt: number; health: MachineHealth }>();
|
||||
private readonly runtimeCache = new Map<string, { expiresAt: number; runtime: MachineRuntime }>();
|
||||
|
||||
constructor(private readonly store = new MachineStore(), private readonly deps: MachineServiceDependencies = {}) {}
|
||||
|
||||
@@ -52,14 +55,20 @@ export class MachineService {
|
||||
if (input.token !== undefined) patch.token = input.token;
|
||||
if (input.headers !== undefined) patch.headers = validateHeaders(input.headers);
|
||||
const stored = await this.store.update(id, patch);
|
||||
if (stored !== undefined) this.healthCache.delete(id);
|
||||
if (stored !== undefined) {
|
||||
this.healthCache.delete(id);
|
||||
this.runtimeCache.delete(id);
|
||||
}
|
||||
return stored === undefined ? undefined : publicMachine(stored);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
if (id === "local") throw new Error("Local machine cannot be deleted");
|
||||
const removed = await this.store.remove(id);
|
||||
if (removed) this.healthCache.delete(id);
|
||||
if (removed) {
|
||||
this.healthCache.delete(id);
|
||||
this.runtimeCache.delete(id);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -84,11 +93,29 @@ export class MachineService {
|
||||
return health;
|
||||
}
|
||||
|
||||
async runtime(id: string): Promise<MachineRuntime | undefined> {
|
||||
const cached = this.runtimeCache.get(id);
|
||||
const now = this.now().getTime();
|
||||
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
|
||||
|
||||
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
|
||||
if (runtime === undefined) return undefined;
|
||||
this.runtimeCache.set(id, { expiresAt: now + (this.deps.runtimeCacheTtlMs ?? DEFAULT_HEALTH_CACHE_TTL_MS), runtime });
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private async localHealth(): Promise<MachineHealth> {
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
const status = await (this.deps.localStatus ?? getPiWebStatus)();
|
||||
return { machineId: "local", ok: true, checkedAt, status: "online", web: status.components.web, sessiond: status.components.sessiond };
|
||||
const runtime = await (this.deps.localRuntime ?? getPiWebRuntime)();
|
||||
return {
|
||||
machineId: "local",
|
||||
ok: true,
|
||||
checkedAt,
|
||||
status: "online",
|
||||
web: componentStatusFromRuntime(runtime.components.web),
|
||||
sessiond: componentStatusFromRuntime(runtime.components.sessiond),
|
||||
};
|
||||
} catch (error) {
|
||||
return { machineId: "local", ok: false, checkedAt, status: "error", error: errorMessage(error) };
|
||||
}
|
||||
@@ -109,6 +136,28 @@ export class MachineService {
|
||||
}
|
||||
}
|
||||
|
||||
private async localRuntime(): Promise<MachineRuntime> {
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
return machineRuntime("local", checkedAt, await (this.deps.localRuntime ?? getPiWebRuntime)());
|
||||
} catch (error) {
|
||||
return { machineId: "local", ok: false, checkedAt, error: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
private async remoteRuntime(id: string): Promise<MachineRuntime | undefined> {
|
||||
const machine = await this.storedRemote(id);
|
||||
if (machine === undefined) return undefined;
|
||||
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);
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
private clientFor(machine: StoredMachine): MachineClient {
|
||||
return this.deps.remoteClientFactory?.(machine) ?? new RemoteMachineClient(machine);
|
||||
}
|
||||
@@ -162,6 +211,29 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function componentStatusFromRuntime(runtime: PiWebRuntimeComponent): PiWebComponentStatus {
|
||||
return {
|
||||
component: runtime.component,
|
||||
label: runtime.label,
|
||||
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
|
||||
stale: false,
|
||||
available: runtime.available,
|
||||
...(runtime.error === undefined ? {} : { error: runtime.error }),
|
||||
};
|
||||
}
|
||||
|
||||
function machineRuntime(machineId: string, checkedAt: string, runtime: PiWebRuntimeResponse): MachineRuntime {
|
||||
return {
|
||||
machineId,
|
||||
ok: true,
|
||||
checkedAt,
|
||||
packageName: runtime.packageName,
|
||||
generatedAt: runtime.generatedAt,
|
||||
components: runtime.components,
|
||||
capabilities: runtime.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
|
||||
if (!isRecord(value)) return false;
|
||||
const components = value["components"];
|
||||
@@ -169,6 +241,16 @@ 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"];
|
||||
@@ -178,6 +260,19 @@ 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);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("PiWebPluginService", () => {
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
|
||||
await expect(service.manifest()).resolves.toEqual({
|
||||
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local" })],
|
||||
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
|
||||
});
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
|
||||
@@ -35,6 +35,18 @@ describe("PiWebPluginService", () => {
|
||||
expect(asset?.content.toString("utf8")).toContain("export default");
|
||||
});
|
||||
|
||||
it("includes machine-specific preferences in plugin manifests", async () => {
|
||||
await writePlugin(join(tempDir, "plugins", "updates"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
|
||||
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] });
|
||||
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
|
||||
});
|
||||
|
||||
it("discovers Pi package plugins through an injected package provider", async () => {
|
||||
const packageDir = join(tempDir, "pkg");
|
||||
await writePlugin(packageDir, {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface PiWebPluginManifestEntry {
|
||||
module: string;
|
||||
source: string;
|
||||
scope: PiWebPluginScope;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
export interface ConfiguredPiPackage {
|
||||
@@ -38,6 +39,7 @@ interface PluginRecord {
|
||||
version: string;
|
||||
source: string;
|
||||
scope: PiWebPluginScope;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
interface PiWebPluginServiceOptions {
|
||||
@@ -61,6 +63,7 @@ interface PiWebPackageConfig {
|
||||
interface PiWebPluginEntry {
|
||||
id: string;
|
||||
module: string;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||
@@ -102,7 +105,7 @@ export class PiWebPluginService {
|
||||
return {
|
||||
plugins: (await this.plugins()).plugins
|
||||
.filter((plugin) => plugin.enabled)
|
||||
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })),
|
||||
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +138,7 @@ export class PiWebPluginService {
|
||||
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
|
||||
source: plugin.source,
|
||||
scope: plugin.scope,
|
||||
machineSpecific: plugin.machineSpecific,
|
||||
enabled: config.plugins?.[plugin.id]?.enabled !== false,
|
||||
};
|
||||
}
|
||||
@@ -228,7 +232,7 @@ async function discoverPluginEntries(root: string, config: PiWebPackageConfig):
|
||||
const entryPath = join(root, entry.module);
|
||||
const entryStat = await stat(entryPath).catch(() => undefined);
|
||||
if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`);
|
||||
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)) });
|
||||
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)), machineSpecific: entry.machineSpecific });
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
@@ -248,7 +252,7 @@ async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig
|
||||
}
|
||||
|
||||
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
|
||||
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
|
||||
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module, machineSpecific? } entries`);
|
||||
const plugins = piWeb["plugins"];
|
||||
if (plugins === undefined) return [];
|
||||
if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);
|
||||
@@ -259,10 +263,26 @@ function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string)
|
||||
const module = entry["module"];
|
||||
if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
|
||||
if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
|
||||
return { id, module };
|
||||
return { id, module, machineSpecific: parseMachineSpecific(entry["machineSpecific"], packagePath, id) };
|
||||
});
|
||||
}
|
||||
|
||||
function parseMachineSpecific(value: unknown, packagePath: string, pluginId: string): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB plugin machineSpecific value for ${pluginId} in ${packagePath}: ${formatUnknownValue(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatUnknownValue(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
|
||||
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
|
||||
if (records.has(plugin.id)) {
|
||||
warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
|
||||
|
||||
+136
-41
@@ -1,12 +1,14 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||
import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js";
|
||||
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
|
||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||
@@ -32,6 +34,8 @@ interface NativeServiceCommands {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const serviceRefs: Record<ServiceId, NativeServiceRef> = {
|
||||
sessiond: {
|
||||
id: "sessiond",
|
||||
@@ -61,10 +65,35 @@ interface PackageInfo {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PiWebStatusDaemon {
|
||||
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
|
||||
}
|
||||
|
||||
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
|
||||
|
||||
const runtimePackageInfo = readPackageInfoSync();
|
||||
|
||||
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
|
||||
return {
|
||||
component,
|
||||
label: component === "web" ? "Web/UI" : "Session daemon",
|
||||
runtimeVersion: runtimePackageInfo?.version ?? DEFAULT_VERSION,
|
||||
available: true,
|
||||
capabilities: [...capabilities],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebRuntimeResponse> {
|
||||
const web = getPiWebRuntimeComponent("web", WEB_RUNTIME_CAPABILITIES);
|
||||
const sessiond = await getSessiondRuntimeComponent(daemon);
|
||||
return {
|
||||
packageName: PI_WEB_PACKAGE_NAME,
|
||||
generatedAt: new Date().toISOString(),
|
||||
components: { web, sessiond },
|
||||
capabilities: effectivePiWebCapabilities({ web, sessiond }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise<PiWebComponentStatus> {
|
||||
const [installed, installation] = await Promise.all([
|
||||
readInstalledPackageInfo(),
|
||||
@@ -83,7 +112,7 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
|
||||
export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
|
||||
const [web, sessiond] = await Promise.all([
|
||||
getPiWebComponentStatus("web"),
|
||||
getSessiondComponentStatus(daemon),
|
||||
@@ -95,12 +124,12 @@ export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()):
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
const versionStatus = await getPiWebVersionStatus(daemon);
|
||||
const { web, sessiond } = versionStatus.components;
|
||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
|
||||
const components = { web, sessiond };
|
||||
const commands = commandsFor(components);
|
||||
const commands = await commandsFor(components);
|
||||
const messages = buildMessages(components, release, commands);
|
||||
return {
|
||||
...versionStatus,
|
||||
@@ -187,18 +216,21 @@ async function detectPiPackageInstallation(realRoot: string, displayPath: string
|
||||
}
|
||||
|
||||
async function detectNpmGlobalInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
|
||||
const npmRoot = npmGlobalRoot();
|
||||
const npmRoot = await npmGlobalRoot();
|
||||
if (npmRoot === undefined) return undefined;
|
||||
const realNpmRoot = await realPathOrSelf(npmRoot);
|
||||
if (!isSameOrWithin(realNpmRoot, realRoot)) return undefined;
|
||||
return { kind: "npm-global", path: displayPath, npmRoot };
|
||||
}
|
||||
|
||||
function npmGlobalRoot(): string | undefined {
|
||||
const result = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
|
||||
if (result.status !== 0) return undefined;
|
||||
const root = result.stdout.trim();
|
||||
return root === "" ? undefined : root;
|
||||
async function npmGlobalRoot(): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("npm", ["root", "-g"], { encoding: "utf8" });
|
||||
const root = stdout.trim();
|
||||
return root === "" ? undefined : root;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function packageRootPath(): string {
|
||||
@@ -214,21 +246,78 @@ function isSameOrWithin(parent: string, candidate: string): boolean {
|
||||
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
|
||||
}
|
||||
|
||||
async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise<PiWebComponentStatus> {
|
||||
async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent> {
|
||||
try {
|
||||
const upstream = await daemon.request("GET", "/health");
|
||||
const upstream = await daemon.request("GET", "/runtime");
|
||||
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
|
||||
return unavailableSessiond(`health check returned HTTP ${String(upstream.statusCode)}`);
|
||||
return await legacySessiondRuntimeComponent(daemon) ?? unavailableSessiondRuntime(`runtime check returned HTTP ${String(upstream.statusCode)}`);
|
||||
}
|
||||
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
|
||||
const version = isRecord(parsed) ? parsed["version"] : undefined;
|
||||
const component = parsePiWebComponentStatus(version);
|
||||
return component ?? unavailableSessiond("health response did not include version information");
|
||||
const runtime = parsePiWebRuntimeComponent(parsed);
|
||||
if (runtime !== undefined) return runtime;
|
||||
const legacyVersion = isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
|
||||
if (legacyVersion !== undefined) return runtimeComponentFromStatus(legacyVersion);
|
||||
return await legacySessiondRuntimeComponent(daemon) ?? unavailableSessiondRuntime("runtime response did not include valid runtime information");
|
||||
} catch (error) {
|
||||
return unavailableSessiondRuntime(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus> {
|
||||
try {
|
||||
const upstream = await daemon.request("GET", "/runtime");
|
||||
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
|
||||
return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(`runtime check returned HTTP ${String(upstream.statusCode)}`);
|
||||
}
|
||||
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
|
||||
const legacyVersion = isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
|
||||
if (legacyVersion !== undefined) return legacyVersion;
|
||||
const runtime = parsePiWebRuntimeComponent(parsed);
|
||||
if (runtime?.available !== true) return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(runtime?.error ?? "runtime response did not include valid runtime information");
|
||||
const status = await getPiWebComponentStatus("sessiond");
|
||||
return { ...status, ...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }), available: true };
|
||||
} catch (error) {
|
||||
return unavailableSessiond(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function legacySessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<PiWebRuntimeComponent | undefined> {
|
||||
const status = await legacySessiondComponentStatus(daemon);
|
||||
return status === undefined ? undefined : runtimeComponentFromStatus(status);
|
||||
}
|
||||
|
||||
async function legacySessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus | undefined> {
|
||||
try {
|
||||
const upstream = await daemon.request("GET", "/health");
|
||||
if (upstream.statusCode < 200 || upstream.statusCode >= 300) return undefined;
|
||||
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
|
||||
return isRecord(parsed) ? parsePiWebComponentStatus(parsed["version"]) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeComponentFromStatus(status: PiWebComponentStatus): PiWebRuntimeComponent {
|
||||
return {
|
||||
component: status.component,
|
||||
label: status.label,
|
||||
...(status.runtimeVersion === undefined ? {} : { runtimeVersion: status.runtimeVersion }),
|
||||
available: status.available,
|
||||
capabilities: [],
|
||||
...(status.error === undefined ? {} : { error: status.error }),
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableSessiondRuntime(error: string): PiWebRuntimeComponent {
|
||||
return {
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
available: false,
|
||||
capabilities: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableSessiond(error: string): PiWebComponentStatus {
|
||||
return {
|
||||
component: "sessiond",
|
||||
@@ -282,15 +371,17 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
|
||||
return version;
|
||||
}
|
||||
|
||||
function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] {
|
||||
async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
|
||||
const installation = preferredInstallation(components);
|
||||
const serviceCommands = nativeServiceCommands();
|
||||
const cliCommands = piWebCliCommands(installation);
|
||||
const [serviceCommands, cliCommands] = await Promise.all([
|
||||
nativeServiceCommands(),
|
||||
piWebCliCommands(installation),
|
||||
]);
|
||||
const restart = restartCommandFor(installation, serviceCommands, cliCommands);
|
||||
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
|
||||
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
|
||||
const status = serviceCommands.status ?? cliCommands.status;
|
||||
const update = updateCommandFor(installation, restart);
|
||||
const update = await updateCommandFor(installation, restart);
|
||||
|
||||
return {
|
||||
...(update === undefined ? {} : { update }),
|
||||
@@ -308,8 +399,8 @@ function preferredInstallation(components: PiWebStatusResponse["components"]): P
|
||||
return web ?? sessiond;
|
||||
}
|
||||
|
||||
function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands {
|
||||
if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {};
|
||||
async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
|
||||
if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
|
||||
return { restart: "pi-web restart", status: "pi-web status" };
|
||||
}
|
||||
|
||||
@@ -318,22 +409,22 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
|
||||
return cliCommands.restart ?? serviceCommands.restart;
|
||||
}
|
||||
|
||||
function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined {
|
||||
async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): Promise<string | undefined> {
|
||||
if (restartCommand === undefined) return undefined;
|
||||
if (installation?.kind === "pi-package") {
|
||||
if (!hasCommand("pi")) return undefined;
|
||||
if (!(await hasCommand("pi"))) return undefined;
|
||||
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
|
||||
}
|
||||
if (installation?.kind === "local" && installation.path !== undefined) {
|
||||
if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined;
|
||||
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
|
||||
return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
|
||||
}
|
||||
if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined;
|
||||
if (installation?.kind !== "npm-global" || !(await hasCommand("npm"))) return undefined;
|
||||
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
|
||||
}
|
||||
|
||||
function nativeServiceCommands(): NativeServiceCommands {
|
||||
const backend = nativeServiceBackend();
|
||||
async function nativeServiceCommands(): Promise<NativeServiceCommands> {
|
||||
const backend = await nativeServiceBackend();
|
||||
if (backend === undefined) return {};
|
||||
const installed = installedServiceIds(backend);
|
||||
if (installed.size === 0) return {};
|
||||
@@ -349,9 +440,9 @@ function nativeServiceCommands(): NativeServiceCommands {
|
||||
};
|
||||
}
|
||||
|
||||
function nativeServiceBackend(): NativeServiceBackendKind | undefined {
|
||||
if (process.platform === "linux" && hasCommand("systemctl")) return "systemd";
|
||||
if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd";
|
||||
async function nativeServiceBackend(): Promise<NativeServiceBackendKind | undefined> {
|
||||
if (process.platform === "linux" && await hasCommand("systemctl")) return "systemd";
|
||||
if (process.platform === "darwin" && await hasCommand("launchctl")) return "launchd";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -385,19 +476,23 @@ function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: Na
|
||||
return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
|
||||
}
|
||||
|
||||
function isGitCheckoutWithUpstream(path: string): boolean {
|
||||
return hasCommand("git")
|
||||
&& commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
|
||||
&& commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
||||
async function isGitCheckoutWithUpstream(path: string): Promise<boolean> {
|
||||
return await hasCommand("git")
|
||||
&& await commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
|
||||
&& await commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
||||
}
|
||||
|
||||
function hasCommand(command: string): boolean {
|
||||
function hasCommand(command: string): Promise<boolean> {
|
||||
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
|
||||
}
|
||||
|
||||
function commandSucceeds(command: string, args: string[]): boolean {
|
||||
const result = spawnSync(command, args, { encoding: "utf8" });
|
||||
return result.status === 0;
|
||||
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync(command, args, { encoding: "utf8" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
|
||||
describe("createPiWebStatusCache", () => {
|
||||
it("serves cached status while it is fresh", async () => {
|
||||
const now = 1_000;
|
||||
const load = vi.fn(() => Promise.resolve(status("first")));
|
||||
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns stale status immediately while refreshing in the background", async () => {
|
||||
let now = 1_000;
|
||||
const load = vi.fn()
|
||||
.mockResolvedValueOnce(status("first"))
|
||||
.mockResolvedValueOnce(status("second"));
|
||||
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||
now = 1_101;
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
|
||||
await waitForMicrotasks();
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent cold loads", async () => {
|
||||
const deferred = createDeferred<PiWebStatusResponse>();
|
||||
const load = vi.fn(() => deferred.promise);
|
||||
const cache = createPiWebStatusCache(load);
|
||||
|
||||
const first = cache.get();
|
||||
const second = cache.get();
|
||||
deferred.resolve(status("ready"));
|
||||
|
||||
await expect(first).resolves.toMatchObject({ generatedAt: "ready" });
|
||||
await expect(second).resolves.toMatchObject({ generatedAt: "ready" });
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function status(generatedAt: string): PiWebStatusResponse {
|
||||
return {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt,
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
|
||||
const DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
export interface PiWebStatusCacheOptions {
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface PiWebStatusCache {
|
||||
get(): Promise<PiWebStatusResponse>;
|
||||
refresh(): Promise<PiWebStatusResponse>;
|
||||
}
|
||||
|
||||
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
|
||||
const now = options.now ?? Date.now;
|
||||
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
|
||||
let pending: Promise<PiWebStatusResponse> | undefined;
|
||||
|
||||
const refresh = (): Promise<PiWebStatusResponse> => {
|
||||
pending ??= Promise.resolve()
|
||||
.then(load)
|
||||
.then((status) => {
|
||||
cached = { status, expiresAt: now() + ttlMs };
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
|
||||
return {
|
||||
async get(): Promise<PiWebStatusResponse> {
|
||||
if (cached !== undefined) {
|
||||
if (cached.expiresAt > now()) return cached.status;
|
||||
void refresh().catch((error: unknown) => { options.onError?.(error); });
|
||||
return cached.status;
|
||||
}
|
||||
return refresh();
|
||||
},
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
+19
-7
@@ -13,7 +13,8 @@ import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
|
||||
import { sessiondSocketPath } from "../sessiond/config.js";
|
||||
import { TerminalService } from "./terminals/terminalService.js";
|
||||
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
||||
import { getPiWebComponentStatus } from "./piWebStatus.js";
|
||||
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
|
||||
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
await app.register(fastifyWebsocket);
|
||||
@@ -29,12 +30,23 @@ registerAuthRoutes(app, auth);
|
||||
registerSessionRoutes(app, sessions, eventHub);
|
||||
registerTerminalRoutes(app, terminals);
|
||||
|
||||
app.get("/health", async () => ({
|
||||
ok: true,
|
||||
activeSessions: sessions.activeCount(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
version: await getPiWebComponentStatus("sessiond"),
|
||||
}));
|
||||
app.get("/health", () => {
|
||||
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
|
||||
return {
|
||||
ok: true,
|
||||
activeSessions: sessions.activeCount(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
version: {
|
||||
component: runtime.component,
|
||||
label: runtime.label,
|
||||
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
|
||||
stale: false,
|
||||
available: runtime.available,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal: NodeJS.Signals): Promise<void> {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon: Session
|
||||
};
|
||||
|
||||
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
|
||||
app.get(`${prefix}/sessiond/runtime`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/runtime` }, reply));
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||
bridgeSockets(socket, daemon.connectWebSocket(stripPrefix(request.url, prefix)));
|
||||
|
||||
@@ -49,8 +49,9 @@ function sessionRef(id: string, cwd = "/workspace") {
|
||||
|
||||
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
const promptCalls: { text: string; options: unknown }[] = [];
|
||||
const bindExtensionCalls: unknown[] = [];
|
||||
const listeners: ((event: unknown) => void)[] = [];
|
||||
const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls };
|
||||
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls };
|
||||
const session: TestSession = {
|
||||
sessionId,
|
||||
sessionFile: `/tmp/${sessionId}.jsonl`,
|
||||
@@ -75,6 +76,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
if (index !== -1) listeners.splice(index, 1);
|
||||
};
|
||||
},
|
||||
bindExtensions: (bindings: unknown) => {
|
||||
calls.bindExtensions.push(bindings);
|
||||
return Promise.resolve();
|
||||
},
|
||||
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,
|
||||
prompt: (text: string, options: unknown) => {
|
||||
@@ -149,6 +154,7 @@ describe("PiSessionService", () => {
|
||||
const session = await service.start("/workspace");
|
||||
|
||||
expect(createCalls).toBe(1);
|
||||
expect(fake.calls.bindExtensions).toHaveLength(1);
|
||||
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
|
||||
expect(service.activeCount()).toBe(1);
|
||||
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
|
||||
@@ -158,6 +164,59 @@ describe("PiSessionService", () => {
|
||||
expect(fake.calls.dispose).toBe(1);
|
||||
});
|
||||
|
||||
it("binds extensions again when the SDK runtime replaces the active session", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("session-1");
|
||||
const replacement = fakeRuntime("session-2");
|
||||
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
|
||||
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session });
|
||||
await rebindSession?.(replacement.session);
|
||||
|
||||
expect(fake.calls.bindExtensions).toHaveLength(1);
|
||||
expect(replacement.calls.bindExtensions).toHaveLength(1);
|
||||
expect(service.activeCount()).toBe(1);
|
||||
expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" });
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("publishes extension errors reported while binding session extensions", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("extension-session", {
|
||||
bindExtensions: (bindings) => {
|
||||
bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" });
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
expect(hub.sessionEvents).toContainEqual({
|
||||
sessionId: "extension-session",
|
||||
event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" },
|
||||
});
|
||||
const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session");
|
||||
expect(extensionErrorActivity).toMatchObject({
|
||||
type: "activity.update",
|
||||
activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" },
|
||||
});
|
||||
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("clears stale active activity once a previously active session becomes idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
let service: PiSessionService | undefined;
|
||||
@@ -318,6 +377,33 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("permanently deletes archived sessions through the archive store", async () => {
|
||||
const deletedSessionIds: string[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
|
||||
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
|
||||
: 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: (sessionId) => {
|
||||
deletedSessionIds.push(sessionId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
sessionManager: sessionGateway([sessionRecord("active")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.deleteArchived("arch")).resolves.toBeUndefined();
|
||||
await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found");
|
||||
|
||||
expect(deletedSessionIds).toEqual(["archived"]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reconciles workspace activity when listing only archived sessions", async () => {
|
||||
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
@@ -365,6 +451,20 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects malformed prompt text before opening the runtime", async () => {
|
||||
const fake = fakeRuntime("prompt-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
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" }],
|
||||
|
||||
@@ -54,7 +54,18 @@ interface QueuedPrompt {
|
||||
text: string;
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
|
||||
function requirePromptText(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("Prompt text is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === "steer" || value === "followUp") return value;
|
||||
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived"> & { deleteArchived?: (sessionId: string) => Promise<void> };
|
||||
|
||||
export type PiSessionRef = ClientSessionRef;
|
||||
|
||||
@@ -95,6 +106,17 @@ export interface PiSessionManagerGateway {
|
||||
open(path: string): PiSessionManager;
|
||||
}
|
||||
|
||||
interface PiExtensionError {
|
||||
extensionPath: string;
|
||||
event: string;
|
||||
error: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
interface PiExtensionBindings {
|
||||
onError?: (error: PiExtensionError) => void;
|
||||
}
|
||||
|
||||
export interface PiAgentSession {
|
||||
modelRegistry: ModelRegistryInstance;
|
||||
sessionManager: PiSessionManager;
|
||||
@@ -113,6 +135,7 @@ export interface PiAgentSession {
|
||||
promptTemplates: readonly { name: string; description?: string }[];
|
||||
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
|
||||
subscribe(listener: (event: unknown) => void): () => void;
|
||||
bindExtensions(bindings: PiExtensionBindings): Promise<void>;
|
||||
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 };
|
||||
@@ -377,22 +400,24 @@ export class PiSessionService {
|
||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async prompt(ref: PiSessionLookup, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
||||
async prompt(ref: PiSessionLookup, text: unknown, streamingBehavior?: unknown): Promise<void> {
|
||||
const promptText = requirePromptText(text);
|
||||
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.maybeGenerateSessionName(session, text);
|
||||
this.maybeGenerateSessionName(session, promptText);
|
||||
const isQueued = session.isStreaming || session.isCompacting;
|
||||
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
|
||||
if (isQueued && this.hasQueuedMessageText(session, text)) {
|
||||
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
|
||||
if (isQueued && this.hasQueuedMessageText(session, promptText)) {
|
||||
this.publishActivity(session, "duplicate queued message ignored", "active");
|
||||
this.publishStatus(session);
|
||||
return;
|
||||
}
|
||||
if (session.isCompacting) {
|
||||
this.enqueuePromptDuringCompaction(session, text, behavior ?? "followUp");
|
||||
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp");
|
||||
return;
|
||||
}
|
||||
void this.submitPrompt(session, text, behavior);
|
||||
void this.submitPrompt(session, promptText, behavior);
|
||||
}
|
||||
|
||||
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise<void> {
|
||||
@@ -497,6 +522,16 @@ export class PiSessionService {
|
||||
await this.archiveStore.restore(archived.sessionId);
|
||||
}
|
||||
|
||||
async deleteArchived(ref: PiSessionLookup): Promise<void> {
|
||||
const record = await this.getArchived(ref);
|
||||
if (record === undefined) throw new Error("Archived session not found");
|
||||
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
|
||||
|
||||
await this.closeActive(record.sessionId);
|
||||
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
|
||||
await this.archiveStore.deleteArchived(record.sessionId);
|
||||
}
|
||||
|
||||
async detachParent(ref: PiSessionLookup): Promise<void> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
const sessionFile = session.sessionFile;
|
||||
@@ -541,6 +576,12 @@ 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));
|
||||
}
|
||||
|
||||
private async archiveInputForSession(session: PiAgentSession): Promise<ArchiveSessionInput> {
|
||||
const cwd = session.sessionManager.getCwd();
|
||||
const sessionFile = session.sessionFile;
|
||||
@@ -655,17 +696,28 @@ export class PiSessionService {
|
||||
|
||||
private async create(sessionManager: PiSessionManager, cwd: string): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
|
||||
await this.bindSessionExtensions(runtime.session);
|
||||
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||
this.bindRuntime(active);
|
||||
runtime.setRebindSession(() => {
|
||||
runtime.setRebindSession(async (session) => {
|
||||
await this.bindSessionExtensions(session);
|
||||
this.bindRuntime(active);
|
||||
return Promise.resolve();
|
||||
});
|
||||
this.active.set(runtime.session.sessionId, active);
|
||||
this.publishStatus(runtime.session);
|
||||
return active;
|
||||
}
|
||||
|
||||
private async bindSessionExtensions(session: PiAgentSession): Promise<void> {
|
||||
await session.bindExtensions({
|
||||
onError: (error) => {
|
||||
const message = `${error.extensionPath}: ${error.error}`;
|
||||
this.publishActivity(session, "extension error", "error", message);
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private bindRuntime(active: ActiveSession<PiSessionRuntime>): void {
|
||||
active.unsubscribe();
|
||||
const { session } = active.runtime;
|
||||
|
||||
@@ -44,6 +44,33 @@ describe("SessionArchiveStore", () => {
|
||||
expect(await exists(record.archivePath)).toBe(false);
|
||||
await expect(store.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("permanently deletes archived session files and records", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-delete-"));
|
||||
tempRoots.push(root);
|
||||
const activeDir = join(root, "active");
|
||||
await mkdir(activeDir, { recursive: true });
|
||||
const sourcePath = join(activeDir, "2026-01-01_s1.jsonl");
|
||||
await writeFile(sourcePath, "session contents\n", "utf8");
|
||||
|
||||
const store = new SessionArchiveStore(join(root, "archived-sessions.json"), join(root, "archived-files"));
|
||||
const record = await store.archive({
|
||||
sessionId: "s1",
|
||||
cwd: "/workspace",
|
||||
path: sourcePath,
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
modified: "2026-01-01T00:01:00.000Z",
|
||||
messageCount: 2,
|
||||
firstMessage: "hello",
|
||||
});
|
||||
|
||||
if (record.archivePath === undefined) throw new Error("Expected archive path");
|
||||
await store.deleteArchived("s1");
|
||||
|
||||
expect(await exists(sourcePath)).toBe(false);
|
||||
expect(await exists(record.archivePath)).toBe(false);
|
||||
await expect(store.list()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
|
||||
@@ -88,6 +88,18 @@ 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;
|
||||
|
||||
if (record.archivePath !== undefined && await pathExists(record.archivePath)) await unlink(record.archivePath);
|
||||
const sessions = data.sessions.filter((session) => session.sessionId !== sessionId);
|
||||
await this.write({ sessions });
|
||||
});
|
||||
}
|
||||
|
||||
async isArchived(sessionId: string): Promise<boolean> {
|
||||
return (await this.get(sessionId)) !== undefined;
|
||||
}
|
||||
|
||||
@@ -15,4 +15,8 @@ describe("sessionNameGenerator", () => {
|
||||
expect(fallbackSessionName('<skill name="x" location="/x">\nDo x\n</skill>\n\nCheck the UI now'))
|
||||
.toBe("Check the UI now");
|
||||
});
|
||||
|
||||
it("skips fallback names when the first request is missing", () => {
|
||||
expect(fallbackSessionName(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,9 @@ export async function generateShortSessionName<TApi extends Api>(modelRegistry:
|
||||
return cleanSessionName(finalMessage === undefined ? streamedText : textFromAssistant(finalMessage));
|
||||
}
|
||||
|
||||
export function fallbackSessionName(firstMessage: string): string | undefined {
|
||||
export function fallbackSessionName(firstMessage: unknown): string | undefined {
|
||||
if (typeof firstMessage !== "string") return undefined;
|
||||
|
||||
return cleanSessionName(firstMessage
|
||||
.replace(/<skill name="[^"]+" location="[^"]+">[\s\S]*?<\/skill>/g, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
|
||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let service: PiSessionService;
|
||||
let sessionManager: RejectingSessionManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyWebsocket);
|
||||
sessionManager = new RejectingSessionManager();
|
||||
const eventHub = new SessionEventHub();
|
||||
service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 });
|
||||
registerSessionRoutes(app, service, eventHub);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await service.dispose();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("session routes", () => {
|
||||
it("rejects prompt payloads that omit text without opening a session", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { cwd: "/repo", body: "Build the thing" } });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Prompt text is required" });
|
||||
expect(sessionManager.calls).toEqual({ create: 0, list: 0, listAll: 0, open: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
class RejectingSessionManager implements PiSessionManagerGateway {
|
||||
readonly calls = { create: 0, list: 0, listAll: 0, open: 0 };
|
||||
|
||||
list() {
|
||||
this.calls.list += 1;
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
create(): never {
|
||||
this.calls.create += 1;
|
||||
throw new Error("Session manager should not create sessions for invalid prompt payloads");
|
||||
}
|
||||
|
||||
listAll() {
|
||||
this.calls.listAll += 1;
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
open(): never {
|
||||
this.calls.open += 1;
|
||||
throw new Error("Session manager should not open sessions for invalid prompt payloads");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ interface MessageQuery extends SessionQuery {
|
||||
|
||||
class SessionRouteValidationError extends Error {}
|
||||
|
||||
interface PromptRequestBody {
|
||||
cwd?: unknown;
|
||||
text?: unknown;
|
||||
streamingBehavior?: unknown;
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
|
||||
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
@@ -106,12 +112,10 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; text?: unknown; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
|
||||
app.post<{ Params: { sessionId: string }; Body: PromptRequestBody | undefined }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRecord(request.body);
|
||||
const streamingBehavior = body["streamingBehavior"];
|
||||
if (streamingBehavior !== undefined && streamingBehavior !== "steer" && streamingBehavior !== "followUp") throw new Error("streamingBehavior must be steer or followUp");
|
||||
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), requireString(body, "text"), streamingBehavior);
|
||||
await sessions.prompt(sessionRefFromBody(request.params.sessionId, body), body["text"], body["streamingBehavior"]);
|
||||
return { accepted: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
@@ -190,6 +194,15 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.deleteArchived(sessionRefFromQuery(request.params.sessionId, request.query));
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
return reply.code(readErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } }>(`${prefix}/sessions/:sessionId/detach-parent`, async (request, reply) => {
|
||||
try {
|
||||
await sessions.detachParent(sessionRefFromBody(request.params.sessionId, requireRecord(request.body)));
|
||||
|
||||
Reference in New Issue
Block a user