feat: route profile consumers through sessiond

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 23:09:32 +02:00
parent 141cda93c8
commit 97e0afc6fa
15 changed files with 637 additions and 118 deletions
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
import type { SessionDaemonRequestClient } from "../sessiond/sessionDaemonClient.js";
import {
ActiveAgentProfileAccessError,
requireActiveAgentProfile,
SessionDaemonActiveAgentProfileProvider,
} from "./activeAgentProfileProvider.js";
const firstProfile = activeProfile("a", "first-agent", "/state/first");
const secondProfile = activeProfile("b", "second-agent", "/state/second");
describe("SessionDaemonActiveAgentProfileProvider", () => {
it("queries sessiond on every read and observes a new daemon profile epoch", async () => {
const request = vi.fn<SessionDaemonRequestClient["request"]>()
.mockResolvedValueOnce(runtimeResponse(firstProfile))
.mockResolvedValueOnce(runtimeResponse(secondProfile));
const provider = new SessionDaemonActiveAgentProfileProvider({ request });
await expect(provider.getActiveAgentProfile()).resolves.toEqual({ status: "available", profile: firstProfile });
await expect(provider.getActiveAgentProfile()).resolves.toEqual({ status: "available", profile: secondProfile });
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenNthCalledWith(1, "GET", "/runtime");
expect(request).toHaveBeenNthCalledWith(2, "GET", "/runtime");
});
it("preserves invalid protocol and daemon unavailability as distinct results", async () => {
const invalidRequest = vi.fn<SessionDaemonRequestClient["request"]>().mockResolvedValue({
statusCode: 200,
headers: { "content-type": "application/json" },
body: "not-json",
});
const unavailableRequest = vi.fn<SessionDaemonRequestClient["request"]>().mockRejectedValue(new Error("connect ECONNREFUSED"));
await expect(new SessionDaemonActiveAgentProfileProvider({ request: invalidRequest }).getActiveAgentProfile()).resolves.toEqual({
status: "invalid",
error: "session daemon runtime response was not valid JSON",
});
await expect(new SessionDaemonActiveAgentProfileProvider({ request: unavailableRequest }).getActiveAgentProfile()).resolves.toEqual({
status: "unavailable",
error: "connect ECONNREFUSED",
});
});
});
describe("requireActiveAgentProfile", () => {
it.each(["invalid", "unavailable"] as const)("fails closed for an %s active profile", async (status) => {
const provider = {
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} profile` } as const),
};
const error = await requireActiveAgentProfile(provider).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ActiveAgentProfileAccessError);
expect(error).toMatchObject({ profileStatus: status, message: `Active agent profile is ${status}: ${status} profile` });
});
});
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
function runtimeResponse(profile: ActiveAgentProfileDescriptor) {
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
component: "sessiond",
label: "Session daemon",
available: true,
capabilities: [],
activeAgentProfile: profile,
}),
};
}
+36
View File
@@ -0,0 +1,36 @@
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
import {
getSessionDaemonActiveAgentProfile,
type SessionDaemonAgentProfileResult,
type SessionDaemonRequestClient,
} from "../sessiond/sessionDaemonClient.js";
export interface ActiveAgentProfileProvider {
getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult>;
}
/** Reads the daemon-owned profile on every call so a new sessiond epoch is observed. */
export class SessionDaemonActiveAgentProfileProvider implements ActiveAgentProfileProvider {
constructor(private readonly daemon: SessionDaemonRequestClient) {}
getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult> {
return getSessionDaemonActiveAgentProfile(this.daemon);
}
}
export class ActiveAgentProfileAccessError extends Error {
readonly profileStatus: "unavailable" | "invalid";
constructor(result: Exclude<SessionDaemonAgentProfileResult, { status: "available" }>) {
const label = result.status === "unavailable" ? "unavailable" : "invalid";
super(`Active agent profile is ${label}: ${result.error}`);
this.name = "ActiveAgentProfileAccessError";
this.profileStatus = result.status;
}
}
export async function requireActiveAgentProfile(provider: ActiveAgentProfileProvider): Promise<ActiveAgentProfileDescriptor> {
const result = await provider.getActiveAgentProfile();
if (result.status !== "available") throw new ActiveAgentProfileAccessError(result);
return result.profile;
}
+154
View File
@@ -0,0 +1,154 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebPluginInfo } from "../shared/apiTypes.js";
import type { SessionDaemonAgentProfileResult } from "../sessiond/sessionDaemonClient.js";
import type { ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
import { buildApp } from "./app.js";
import type { PiWebConfigService } from "./configRoutes.js";
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-active-profile-app-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("buildApp active profile composition", () => {
it("routes package and package-backed plugin reads through the same refreshable provider", async () => {
const firstAgentDir = join(tempDir, "first-agent");
const secondAgentDir = join(tempDir, "second-agent");
const firstPackageDir = join(tempDir, "first-package");
const secondPackageDir = join(tempDir, "second-package");
await Promise.all([
writePackagePlugin(firstPackageDir, "profile-first"),
writePackagePlugin(secondPackageDir, "profile-second"),
writePiPackageSettings(firstAgentDir, [firstPackageDir]),
writePiPackageSettings(secondAgentDir, [secondPackageDir]),
]);
let result: SessionDaemonAgentProfileResult = { status: "available", profile: activeProfile("a", "first-agent", firstAgentDir) };
const getActiveAgentProfile = vi.fn(() => Promise.resolve(result));
const app = await buildApp({
agentProfileProvider: { getActiveAgentProfile },
config: emptyConfigService(),
clientDist: false,
logger: false,
});
try {
const firstPackages = await app.inject({ method: "GET", url: "/api/pi-packages" });
const firstPlugins = await app.inject({ method: "GET", url: "/api/plugins" });
expect(firstPackages.statusCode).toBe(200);
expect(packageSources(firstPackages.json())).toContain(firstPackageDir);
expect(pluginIds(firstPlugins.json())).toContain("profile-first");
expect(pluginIds(firstPlugins.json())).not.toContain("profile-second");
result = { status: "available", profile: activeProfile("b", "second-agent", secondAgentDir) };
const secondPackages = await app.inject({ method: "GET", url: "/api/pi-packages" });
const secondPlugins = await app.inject({ method: "GET", url: "/api/plugins" });
expect(secondPackages.statusCode).toBe(200);
expect(packageSources(secondPackages.json())).toContain(secondPackageDir);
expect(packageSources(secondPackages.json())).not.toContain(firstPackageDir);
expect(pluginIds(secondPlugins.json())).toContain("profile-second");
expect(pluginIds(secondPlugins.json())).not.toContain("profile-first");
expect(getActiveAgentProfile).toHaveBeenCalledTimes(4);
} finally {
await app.close();
}
});
it.each(["unavailable", "invalid"] as const)("returns 503 instead of falling back when the active profile is %s", async (status) => {
const provider: ActiveAgentProfileProvider = {
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} daemon profile` }),
};
const app = await buildApp({
agentProfileProvider: provider,
config: emptyConfigService(),
clientDist: false,
logger: false,
});
try {
const packages = await app.inject({ method: "GET", url: "/api/pi-packages" });
const plugins = await app.inject({ method: "GET", url: "/api/plugins" });
const manifest = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(packages.statusCode).toBe(503);
expect(packages.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
expect(plugins.statusCode).toBe(503);
expect(plugins.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
expect(manifest.statusCode).toBe(503);
expect(manifest.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
} finally {
await app.close();
}
});
});
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
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`, "utf8");
}
async function writePackagePlugin(root: string, pluginId: string): Promise<void> {
await mkdir(root, { recursive: true });
await writeFile(join(root, "package.json"), `${JSON.stringify({
name: `@test/${pluginId}`,
version: "1.0.0",
piWeb: { plugins: [{ id: pluginId, module: "pi-web-plugin.js" }] },
}, null, 2)}\n`, "utf8");
await writeFile(join(root, "pi-web-plugin.js"), "export default {};\n", "utf8");
}
function emptyConfigService(): PiWebConfigService {
const response: PiWebConfigResponse = {
path: join(tempDir, "config.json"),
exists: false,
config: {},
effectiveConfig: {},
envOverrides: {
host: false,
port: false,
allowedHosts: false,
spawnSessions: false,
subsessions: false,
agentCommand: false,
agentDir: false,
agentSessionDir: false,
},
};
return {
read: () => Promise.resolve(response),
write: () => Promise.resolve(response),
};
}
function packageSources(value: unknown): string[] {
if (!isRecord(value) || !Array.isArray(value["packages"])) return [];
return value["packages"].flatMap((entry) => isRecord(entry) && typeof entry["source"] === "string" ? [entry["source"]] : []);
}
function pluginIds(value: unknown): PiWebPluginInfo["id"][] {
if (!isRecord(value) || !Array.isArray(value["plugins"])) return [];
return value["plugins"].flatMap((entry) => isRecord(entry) && typeof entry["id"] === "string" ? [entry["id"]] : []);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+33 -8
View File
@@ -1,13 +1,13 @@
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js"; import type { ActiveAgentProfileDescriptor, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js"; import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks(); registerAppTestHooks();
describe("buildApp agent config", () => { describe("buildApp active agent profile", () => {
it.each(["/api/config", "/api/machines/local/config"])("uses the latest configured agent dir for status after writes through %s", async (configRoute) => { it.each(["/api/config", "/api/machines/local/config"])("keeps desired writes separate from the active profile and observes a new daemon epoch through %s", async (configRoute) => {
const originalEnv = captureEnv([ const originalEnv = captureEnv([
"PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_SKIP_VERSION_CHECK",
"PI_WEB_DOCKER_RUNTIME", "PI_WEB_DOCKER_RUNTIME",
@@ -24,9 +24,11 @@ describe("buildApp agent config", () => {
try { try {
const initialAgentDir = join(appTestContext.tempDir, "initial-agent"); const initialAgentDir = join(appTestContext.tempDir, "initial-agent");
const updatedAgentDir = join(appTestContext.tempDir, "updated-agent"); const updatedAgentDir = join(appTestContext.tempDir, "updated-agent");
appTestContext.piWebConfig = { agent: { command: "pi", dir: initialAgentDir } }; appTestContext.piWebConfig = { agent: { command: "desired-agent", dir: initialAgentDir } };
appTestContext.agentProfileResult = { status: "available", profile: activeProfile("a", "active-agent", initialAgentDir) };
await mkdir(initialAgentDir, { recursive: true }); await mkdir(initialAgentDir, { recursive: true });
await installConfiguredPiWebPackage(updatedAgentDir); await installConfiguredPiWebPackage(updatedAgentDir);
process.env["PI_WEB_AGENT_DIR"] = updatedAgentDir;
const initialStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" }); const initialStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" });
expect(initialStatus.statusCode).toBe(200); expect(initialStatus.statusCode).toBe(200);
@@ -35,14 +37,27 @@ describe("buildApp agent config", () => {
const updateResponse = await appTestContext.app.inject({ const updateResponse = await appTestContext.app.inject({
method: "PUT", method: "PUT",
url: configRoute, url: configRoute,
payload: { config: { agent: { command: "pi", dir: updatedAgentDir } } }, payload: { config: { agent: { command: "next-agent", dir: updatedAgentDir } } },
}); });
expect(updateResponse.statusCode).toBe(200); expect(updateResponse.statusCode).toBe(200);
const refreshedStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" }); const desiredWriteStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" });
const desiredWriteVersion = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/version" });
expect(desiredWriteStatus.statusCode).toBe(200);
expect(desiredWriteStatus.json<PiWebStatusResponse>().components.web.installation?.kind).not.toBe("pi-package");
expect(desiredWriteVersion.json<PiWebVersionResponse>().components.web.installation?.kind).not.toBe("pi-package");
expect(refreshedStatus.statusCode).toBe(200); appTestContext.agentProfileResult = { status: "available", profile: activeProfile("b", "next-agent", updatedAgentDir) };
expect(refreshedStatus.json<PiWebStatusResponse>().components.web.installation).toMatchObject({ const restartedStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
const restartedVersion = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/version" });
expect(restartedStatus.statusCode).toBe(200);
expect(restartedStatus.json<PiWebStatusResponse>().components.web.installation).toMatchObject({
kind: "pi-package",
source: process.cwd(),
scope: "user",
});
expect(restartedVersion.json<PiWebVersionResponse>().components.web.installation).toMatchObject({
kind: "pi-package", kind: "pi-package",
source: process.cwd(), source: process.cwd(),
scope: "user", scope: "user",
@@ -53,6 +68,16 @@ describe("buildApp agent config", () => {
}); });
}); });
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
async function installConfiguredPiWebPackage(agentDir: string): Promise<void> { async function installConfiguredPiWebPackage(agentDir: string): Promise<void> {
await mkdir(agentDir, { recursive: true }); await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8"); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
+23 -1
View File
@@ -14,7 +14,8 @@ import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { PiPackageService } from "./piPackageService.js"; import type { PiPackageService } from "./piPackageService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import type { ActiveAgentProfileDescriptor, PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { SessionDaemonAgentProfileResult } from "../sessiond/sessionDaemonClient.js";
interface AppTestContext { interface AppTestContext {
readonly app: FastifyInstance; readonly app: FastifyInstance;
@@ -24,6 +25,7 @@ interface AppTestContext {
readonly sessionDaemonRequests: CapturedSessionDaemonRequest[]; readonly sessionDaemonRequests: CapturedSessionDaemonRequest[];
readonly piPackageRequests: CapturedPiPackageRequest[]; readonly piPackageRequests: CapturedPiPackageRequest[];
piWebConfig: PiWebConfigValues; piWebConfig: PiWebConfigValues;
agentProfileResult: SessionDaemonAgentProfileResult;
} }
let app: FastifyInstance | undefined; let app: FastifyInstance | undefined;
@@ -33,6 +35,7 @@ let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[] = []; let sessionDaemonRequests: CapturedSessionDaemonRequest[] = [];
let piPackageRequests: CapturedPiPackageRequest[] = []; let piPackageRequests: CapturedPiPackageRequest[] = [];
let piWebConfig: PiWebConfigValues = {}; let piWebConfig: PiWebConfigValues = {};
let agentProfileResult: SessionDaemonAgentProfileResult = { status: "invalid", error: "App test harness was not initialized" };
export const appTestContext: AppTestContext = { export const appTestContext: AppTestContext = {
get app() { get app() {
@@ -65,6 +68,12 @@ export const appTestContext: AppTestContext = {
set piWebConfig(config) { set piWebConfig(config) {
piWebConfig = config; piWebConfig = config;
}, },
get agentProfileResult() {
return agentProfileResult;
},
set agentProfileResult(result) {
agentProfileResult = result;
},
}; };
export function registerAppTestHooks(): void { export function registerAppTestHooks(): void {
@@ -75,6 +84,7 @@ export function registerAppTestHooks(): void {
sessionDaemonRequests = []; sessionDaemonRequests = [];
piPackageRequests = []; piPackageRequests = [];
piWebConfig = {}; piWebConfig = {};
agentProfileResult = { status: "available", profile: appTestAgentProfile(join(tempDir, "agent")) };
app = await buildApp({ app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(), workspaces: new WorkspaceService(),
@@ -95,6 +105,7 @@ export function registerAppTestHooks(): void {
}), }),
}), }),
sessionDaemon: fakeSessionDaemon(), sessionDaemon: fakeSessionDaemon(),
agentProfileProvider: { getActiveAgentProfile: () => Promise.resolve(agentProfileResult) },
config: fakeConfigService(), config: fakeConfigService(),
piPackages: fakePiPackageService(), piPackages: fakePiPackageService(),
piWebPlugins: { piWebPlugins: {
@@ -117,6 +128,7 @@ export function registerAppTestHooks(): void {
sessionDaemonRequests = []; sessionDaemonRequests = [];
piPackageRequests = []; piPackageRequests = [];
piWebConfig = {}; piWebConfig = {};
agentProfileResult = { status: "invalid", error: "App test harness was not initialized" };
if (appToClose !== undefined) await appToClose.close(); if (appToClose !== undefined) await appToClose.close();
if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true }); if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true });
@@ -152,6 +164,16 @@ function fakeConfigService() {
}; };
} }
function appTestAgentProfile(dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "pi",
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
};
}
export function fullPiWebConfig(): PiWebConfigValues { export function fullPiWebConfig(): PiWebConfigValues {
return { return {
host: "127.0.0.1", host: "127.0.0.1",
+31 -18
View File
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify"; import Fastify, { type FastifyInstance, type FastifyReply, type FastifyServerOptions } from "fastify";
import fastifyCompress from "@fastify/compress"; import fastifyCompress from "@fastify/compress";
import fastifyStatic from "@fastify/static"; import fastifyStatic from "@fastify/static";
import fastifyWebsocket from "@fastify/websocket"; import fastifyWebsocket from "@fastify/websocket";
@@ -21,11 +21,16 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js"; import { PiWebPluginService } from "./piWebPluginService.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js"; import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js"; import { registerPiPackageRoutes } from "./piPackageRoutes.js";
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js"; import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { effectiveAgentConfig, type EffectivePiWebAgentConfig } from "../config.js"; import {
ActiveAgentProfileAccessError,
requireActiveAgentProfile,
SessionDaemonActiveAgentProfileProvider,
type ActiveAgentProfileProvider,
} from "./activeAgentProfileProvider.js";
import { MachineService } from "./machines/machineService.js"; import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js";
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
@@ -37,6 +42,7 @@ export interface AppDependencies {
workspaces?: WorkspaceService; workspaces?: WorkspaceService;
machines?: MachineService; machines?: MachineService;
sessionDaemon?: SessionProxyDaemon; sessionDaemon?: SessionProxyDaemon;
agentProfileProvider?: ActiveAgentProfileProvider;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">; piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
piPackages?: PiPackageService; piPackages?: PiPackageService;
piWebStatusCache?: PiWebStatusCache; piWebStatusCache?: PiWebStatusCache;
@@ -125,10 +131,6 @@ async function readEffectiveConfig(config: Pick<PiWebConfigService, "read">) {
return (await config.read()).effectiveConfig; return (await config.read()).effectiveConfig;
} }
async function readEffectiveAgentConfig(config: Pick<PiWebConfigService, "read">): Promise<EffectivePiWebAgentConfig> {
return effectiveAgentConfig(process.env, await readEffectiveConfig(config));
}
function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: Pick<PiWebStatusCache, "invalidate">): PiWebConfigService { function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: Pick<PiWebStatusCache, "invalidate">): PiWebConfigService {
return { return {
read: () => config.read(), read: () => config.read(),
@@ -140,6 +142,15 @@ function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: P
}; };
} }
async function withProfileDependency<T>(reply: FastifyReply, operation: () => Promise<T>): Promise<T | FastifyReply> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ActiveAgentProfileAccessError)) throw error;
return reply.code(503).send({ error: error.message });
}
}
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> { export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) }); const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) });
// Vite proxies development API requests here, while production and machine-scoped // Vite proxies development API requests here, while production and machine-scoped
@@ -155,19 +166,19 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const workspaces = deps.workspaces ?? new WorkspaceService(); const workspaces = deps.workspaces ?? new WorkspaceService();
const configService = deps.config ?? createFilePiWebConfigService(); const configService = deps.config ?? createFilePiWebConfigService();
const readConfig = () => readEffectiveConfig(configService); const readConfig = () => readEffectiveConfig(configService);
const readAgentConfig = () => readEffectiveAgentConfig(configService); const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const agentProfileProvider = deps.agentProfileProvider ?? new SessionDaemonActiveAgentProfileProvider(sessionDaemon);
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({ const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({
configProvider: readConfig, configProvider: readConfig,
agentDirProvider: async () => (await requireActiveAgentProfile(agentProfileProvider)).dir,
}); });
const piPackages = deps.piPackages ?? createDefaultPiPackageService(process.cwd(), (await readAgentConfig()).dir); const piPackages = deps.piPackages ?? createActiveProfilePiPackageService(agentProfileProvider);
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache( const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
async ({ force }) => { async ({ force }) => {
const agent = await readAgentConfig(); const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile();
return getPiWebStatus(sessionDaemon, { return getPiWebStatus(sessionDaemon, {
forceReleaseCheck: force, forceReleaseCheck: force,
agentCommand: agent.command, ...(activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {}),
agentDir: agent.dir,
}); });
}, },
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } }, { onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
@@ -176,26 +187,28 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
localRuntime: () => getPiWebRuntime(sessionDaemon), localRuntime: () => getPiWebRuntime(sessionDaemon),
}); });
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest()); app.get("/pi-web-plugins/manifest.json", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.manifest()));
app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => { app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => {
if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return; if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return;
return withProfileDependency(reply, async () => {
const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]); const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]);
if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" }); if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" });
return reply.type(asset.contentType).send(asset.content); return reply.type(asset.contentType).send(asset.content);
}); });
});
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1" app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
? piWebStatusCache.refresh({ force: true }) ? piWebStatusCache.refresh({ force: true })
: piWebStatusCache.get()); : piWebStatusCache.get());
app.get("/api/pi-web/version", async () => { app.get("/api/pi-web/version", async () => {
const agent = await readAgentConfig(); const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile();
return getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }); return getPiWebVersionStatus(sessionDaemon, activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {});
}); });
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins()); app.get("/api/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins()));
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins()); app.get("/api/machines/local/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins()));
registerPiPackageRoutes(app, piPackages); registerPiPackageRoutes(app, piPackages);
registerPiPackageRoutes(app, piPackages, "/api/machines/local"); registerPiPackageRoutes(app, piPackages, "/api/machines/local");
const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache); const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache);
+10
View File
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js"; import type { PiPackageInfo } from "../shared/apiTypes.js";
import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
import type { PiPackageService } from "./piPackageService.js"; import type { PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js"; import { registerPiPackageRoutes } from "./piPackageRoutes.js";
@@ -95,6 +96,15 @@ describe("registerPiPackageRoutes", () => {
expect(serviceMocks.update).not.toHaveBeenCalled(); expect(serviceMocks.update).not.toHaveBeenCalled();
}); });
it("returns 503 when the daemon-owned active profile is unavailable", async () => {
serviceMocks.list.mockRejectedValueOnce(new ActiveAgentProfileAccessError({ status: "unavailable", error: "connect ECONNREFUSED" }));
const response = await app.inject({ method: "GET", url: "/api/pi-packages" });
expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({ error: "Active agent profile is unavailable: connect ECONNREFUSED" });
});
it("returns stable 500 errors for package-manager failures", async () => { it("returns stable 500 errors for package-manager failures", async () => {
serviceMocks.install.mockRejectedValueOnce(new Error("install failed")); serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
+8 -3
View File
@@ -1,10 +1,11 @@
import type { FastifyInstance, FastifyReply } from "fastify"; import type { FastifyInstance, FastifyReply } from "fastify";
import type { PiPackageScope } from "../shared/apiTypes.js"; import type { PiPackageScope } from "../shared/apiTypes.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js"; import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
import type { PiPackageService } from "./piPackageService.js";
class PiPackageRequestValidationError extends Error {} class PiPackageRequestValidationError extends Error {}
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void { export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService, prefix = "/api"): void {
const routePrefix = normalizeRoutePrefix(prefix); const routePrefix = normalizeRoutePrefix(prefix);
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => { app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
@@ -79,7 +80,11 @@ function requireRequestObject(value: unknown): Record<string, unknown> {
} }
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply { function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
const status = error instanceof PiPackageRequestValidationError ? 400 : 500; const status = error instanceof PiPackageRequestValidationError
? 400
: error instanceof ActiveAgentProfileAccessError
? 503
: 500;
return reply.code(status).send({ error: errorMessage(error) }); return reply.code(status).send({ error: errorMessage(error) });
} }
+63 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js"; import type { PiPackageInfo } from "../shared/apiTypes.js";
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js"; import { type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
import { ActiveProfilePiPackageService, DefaultPiPackageService, type PiPackageManagerPort, type PiPackageService } from "./piPackageService.js";
function fakeManager(packages: PiPackageInfo[] = []) { function fakeManager(packages: PiPackageInfo[] = []) {
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages); const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
@@ -21,6 +22,44 @@ function deferred<T = void>() {
return { promise, resolve, reject }; return { promise, resolve, reject };
} }
describe("ActiveProfilePiPackageService", () => {
it("uses the daemon profile active when each package operation begins", async () => {
const getActiveAgentProfile = vi.fn<ActiveAgentProfileProvider["getActiveAgentProfile"]>()
.mockResolvedValueOnce(availableProfile("a", "/state/first"))
.mockResolvedValueOnce(availableProfile("b", "/state/second"));
const firstService = fakePiPackageService("first");
const secondService = fakePiPackageService("second");
const serviceForAgentDir = vi.fn((agentDir: string): PiPackageService => agentDir === "/state/first" ? firstService : secondService);
const service = new ActiveProfilePiPackageService({ getActiveAgentProfile }, serviceForAgentDir);
await expect(service.list()).resolves.toEqual({ packages: [{ source: "first", scope: "user", filtered: false }] });
await expect(service.install("npm:@acme/tools")).resolves.toMatchObject({ action: "install", source: "npm:@acme/tools", packages: [{ source: "second" }] });
expect(serviceForAgentDir).toHaveBeenNthCalledWith(1, "/state/first");
expect(serviceForAgentDir).toHaveBeenNthCalledWith(2, "/state/second");
expect(firstService.list).toHaveBeenCalledOnce();
expect(secondService.install).toHaveBeenCalledWith("npm:@acme/tools");
});
it.each(["unavailable", "invalid"] as const)("fails closed without constructing a package manager when the profile is %s", async (status) => {
const activeAgentProfile: ActiveAgentProfileProvider = {
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} profile` }),
};
const serviceForAgentDir = vi.fn<(agentDir: string) => PiPackageService>();
const service = new ActiveProfilePiPackageService(activeAgentProfile, serviceForAgentDir);
await expect(service.list()).rejects.toMatchObject({
profileStatus: status,
message: `Active agent profile is ${status}: ${status} profile`,
});
await expect(service.install("npm:@acme/tools")).rejects.toMatchObject({
profileStatus: status,
message: `Active agent profile is ${status}: ${status} profile`,
});
expect(serviceForAgentDir).not.toHaveBeenCalled();
});
});
describe("DefaultPiPackageService", () => { describe("DefaultPiPackageService", () => {
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => { it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
const fake = fakeManager([ const fake = fakeManager([
@@ -201,3 +240,26 @@ describe("DefaultPiPackageService", () => {
]); ]);
}); });
}); });
function availableProfile(revisionCharacter: string, dir: string) {
return {
status: "available" as const,
profile: {
schemaVersion: 1 as const,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command: `${revisionCharacter}-agent`,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
},
};
}
function fakePiPackageService(source: string) {
const packages = [{ source, scope: "user" as const, filtered: false }];
return {
list: vi.fn(() => Promise.resolve({ packages })),
install: vi.fn((installedSource: string) => Promise.resolve({ action: "install" as const, source: installedSource, packages })),
remove: vi.fn((removedSource: string, scope: "user" | "project" = "user") => Promise.resolve({ action: "remove" as const, source: removedSource, scope, removed: true, packages })),
update: vi.fn((updatedSource?: string) => Promise.resolve({ action: "update" as const, ...(updatedSource === undefined ? {} : { source: updatedSource }), packages })),
} satisfies PiPackageService;
}
+48 -2
View File
@@ -1,5 +1,6 @@
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
import { requireActiveAgentProfile, type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
export interface PiPackageManagerPort { export interface PiPackageManagerPort {
listConfiguredPackages(): PiPackageInfo[]; listConfiguredPackages(): PiPackageInfo[];
@@ -16,6 +17,47 @@ export interface PiPackageService {
update(source?: string): Promise<PiPackageMutationResponse>; update(source?: string): Promise<PiPackageMutationResponse>;
} }
export type PiPackageServiceForAgentDir = (agentDir: string) => PiPackageService;
export class ActiveProfilePiPackageService implements PiPackageService {
private mutationQueue: Promise<void> = Promise.resolve();
constructor(
private readonly activeAgentProfile: ActiveAgentProfileProvider,
private readonly serviceForAgentDir: PiPackageServiceForAgentDir,
) {}
async list(): Promise<PiPackagesResponse> {
return await this.withActiveService((service) => service.list());
}
install(source: string): Promise<PiPackageMutationResponse> {
return this.enqueueMutation((service) => service.install(source));
}
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse> {
return this.enqueueMutation((service) => service.remove(source, scope));
}
update(source?: string): Promise<PiPackageMutationResponse> {
return this.enqueueMutation((service) => service.update(source));
}
private enqueueMutation(operation: (service: PiPackageService) => Promise<PiPackageMutationResponse>): Promise<PiPackageMutationResponse> {
const queuedMutation = this.mutationQueue.then(() => this.withActiveService(operation));
this.mutationQueue = queuedMutation.then(
() => undefined,
() => undefined,
);
return queuedMutation;
}
private async withActiveService<T>(operation: (service: PiPackageService) => Promise<T>): Promise<T> {
const profile = await requireActiveAgentProfile(this.activeAgentProfile);
return await operation(this.serviceForAgentDir(profile.dir));
}
}
export class DefaultPiPackageService implements PiPackageService { export class DefaultPiPackageService implements PiPackageService {
private mutationQueue: Promise<void> = Promise.resolve(); private mutationQueue: Promise<void> = Promise.resolve();
@@ -84,7 +126,11 @@ export class DefaultPiPackageService implements PiPackageService {
} }
} }
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService { export function createActiveProfilePiPackageService(activeAgentProfile: ActiveAgentProfileProvider, cwd = process.cwd()): PiPackageService {
return new ActiveProfilePiPackageService(activeAgentProfile, (agentDir) => createDefaultPiPackageService(cwd, agentDir));
}
export function createDefaultPiPackageService(cwd: string, agentDir: string): PiPackageService {
const settingsManager = SettingsManager.create(cwd, agentDir); const settingsManager = SettingsManager.create(cwd, agentDir);
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
return new DefaultPiPackageService({ return new DefaultPiPackageService({
+21 -4
View File
@@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile, mkdir, symlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService.js"; import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService.js";
let tempDir: string; let tempDir: string;
@@ -133,11 +134,11 @@ describe("PiWebPluginService", () => {
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u); expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
}); });
it("uses the current config provider for Pi package plugin discovery", async () => { it("uses the active agent directory on every Pi package plugin discovery", async () => {
const packageDir = join(tempDir, "pkg"); const packageDir = join(tempDir, "pkg");
const initialAgentDir = join(tempDir, "initial-agent"); const initialAgentDir = join(tempDir, "initial-agent");
const updatedAgentDir = join(tempDir, "updated-agent"); const updatedAgentDir = join(tempDir, "updated-agent");
let currentConfig = { agent: { dir: initialAgentDir } }; let activeAgentDir = initialAgentDir;
await writePlugin(packageDir, { await writePlugin(packageDir, {
packageJson: { piWeb: { plugins: [{ id: "agent-package", module: "dist/plugin.js" }] } }, packageJson: { piWeb: { plugins: [{ id: "agent-package", module: "dist/plugin.js" }] } },
files: { "dist/plugin.js": "export default {};" }, files: { "dist/plugin.js": "export default {};" },
@@ -145,15 +146,31 @@ describe("PiWebPluginService", () => {
await mkdir(initialAgentDir, { recursive: true }); await mkdir(initialAgentDir, { recursive: true });
await mkdir(updatedAgentDir, { recursive: true }); await mkdir(updatedAgentDir, { recursive: true });
await writeFile(join(updatedAgentDir, "settings.json"), `${JSON.stringify({ packages: [packageDir] }, null, 2)}\n`, "utf8"); await writeFile(join(updatedAgentDir, "settings.json"), `${JSON.stringify({ packages: [packageDir] }, null, 2)}\n`, "utf8");
const service = new PiWebPluginService({ roots: [], cwd: tempDir, configProvider: () => currentConfig }); const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDirProvider: () => activeAgentDir });
await expect(service.manifest()).resolves.toEqual({ plugins: [] }); await expect(service.manifest()).resolves.toEqual({ plugins: [] });
currentConfig = { agent: { dir: updatedAgentDir } }; activeAgentDir = updatedAgentDir;
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "agent-package", source: packageDir, scope: "user" }] }); await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "agent-package", source: packageDir, scope: "user" }] });
}); });
it("fails complete package-backed discovery closed while keeping known local assets independent", async () => {
const pluginDir = join(tempDir, "plugins", "local-only");
await writePlugin(pluginDir, {
packageJson: { piWeb: { plugins: [{ id: "local-only", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const profileError = new ActiveAgentProfileAccessError({ status: "invalid", error: "missing descriptor" });
const service = new PiWebPluginService({
roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }],
agentDirProvider: () => { throw profileError; },
});
await expect(service.manifest()).rejects.toBe(profileError);
await expect(service.readAsset("local-only", "pi-web-plugin.js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" });
});
it("refreshes Pi package plugin discovery after Pi package settings change", async () => { it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
const agentDir = join(tempDir, "agent"); const agentDir = join(tempDir, "agent");
const firstPackageDir = join(tempDir, "first-package"); const firstPackageDir = join(tempDir, "first-package");
+29 -26
View File
@@ -3,7 +3,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path"; import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent"; import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import { effectiveAgentConfig, loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js"; import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js";
import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js"; import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js"; import { isPiWebPluginId } from "../shared/pluginIds.js";
@@ -71,8 +71,8 @@ type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
export class DefaultPiPackageProvider implements PiPackageProvider { export class DefaultPiPackageProvider implements PiPackageProvider {
constructor( constructor(
private readonly cwd = process.cwd(), private readonly cwd: string,
private readonly agentDir = defaultAgentDirForCwd(cwd), private readonly agentDir: string,
) {} ) {}
listPackages(): ConfiguredPiPackage[] { listPackages(): ConfiguredPiPackage[] {
@@ -92,32 +92,24 @@ export class DefaultPiPackageProvider implements PiPackageProvider {
} }
} }
function defaultAgentDirForCwd(cwd: string): string {
return effectiveAgentConfig(process.env, loadPiWebConfig({ cwd }).config, cwd).dir;
}
export class PiWebPluginService { export class PiWebPluginService {
private readonly cwd: string;
private readonly roots: LocalPluginRoot[]; private readonly roots: LocalPluginRoot[];
private readonly agentDir: string | undefined; private readonly agentDir: string | undefined;
private readonly agentDirProvider: (() => string | Promise<string>) | undefined; private readonly agentDirProvider: (() => string | Promise<string>) | undefined;
private readonly staticPackageProvider: PiPackageProvider | undefined;
private readonly packageProviderForAgentDir: ((agentDir: string) => PiPackageProvider) | undefined; private readonly packageProviderForAgentDir: ((agentDir: string) => PiPackageProvider) | undefined;
private readonly configProvider: () => PiWebConfig | Promise<PiWebConfig>; private readonly configProvider: () => PiWebConfig | Promise<PiWebConfig>;
constructor(options: PiWebPluginServiceOptions = {}) { constructor(options: PiWebPluginServiceOptions = {}) {
const cwd = options.cwd ?? process.cwd(); const cwd = options.cwd ?? process.cwd();
this.cwd = cwd;
this.roots = options.roots ?? defaultPluginRoots(cwd); this.roots = options.roots ?? defaultPluginRoots(cwd);
this.agentDir = options.agentDir; this.agentDir = options.agentDir;
this.agentDirProvider = options.agentDirProvider; this.agentDirProvider = options.agentDirProvider;
const packageProvider = options.packageProvider; const packageProvider = options.packageProvider;
if (packageProvider === false) { this.staticPackageProvider = packageProvider === false || packageProvider === undefined ? undefined : packageProvider;
this.packageProviderForAgentDir = undefined; this.packageProviderForAgentDir = packageProvider === false || packageProvider !== undefined
} else if (packageProvider !== undefined) { ? undefined
this.packageProviderForAgentDir = () => packageProvider; : (agentDir) => new DefaultPiPackageProvider(cwd, agentDir);
} else {
this.packageProviderForAgentDir = (agentDir) => new DefaultPiPackageProvider(cwd, agentDir);
}
this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config); this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config);
} }
@@ -131,13 +123,13 @@ export class PiWebPluginService {
async plugins(): Promise<PiWebPluginsResponse> { async plugins(): Promise<PiWebPluginsResponse> {
const config = await this.configProvider(); const config = await this.configProvider();
const plugins = await this.discoverPlugins(config); const plugins = await this.discoverPlugins();
return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) }; return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) };
} }
async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> { async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
if (!isPiWebPluginId(pluginId)) return undefined; if (!isPiWebPluginId(pluginId)) return undefined;
const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId); const plugin = await this.findPlugin(pluginId);
if (plugin === undefined) return undefined; if (plugin === undefined) return undefined;
const resolved = resolve(plugin.root, assetPath); const resolved = resolve(plugin.root, assetPath);
@@ -164,26 +156,37 @@ export class PiWebPluginService {
}; };
} }
private async discoverPlugins(config?: PiWebConfig): Promise<PluginRecord[]> { private async discoverPlugins(): Promise<PluginRecord[]> {
const records = new Map<string, PluginRecord>(); const records = new Map<string, PluginRecord>();
for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin); for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
const packageProvider = await this.packageProvider(config); const packageProvider = await this.currentPackageProvider();
if (packageProvider !== undefined) { if (packageProvider !== undefined) {
for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin); for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin);
} }
return [...records.values()].sort((left, right) => left.id.localeCompare(right.id)); return [...records.values()].sort((left, right) => left.id.localeCompare(right.id));
} }
private async packageProvider(config?: PiWebConfig): Promise<PiPackageProvider | undefined> { private async findPlugin(pluginId: string): Promise<PluginRecord | undefined> {
if (this.packageProviderForAgentDir === undefined) return undefined; const localPlugin = (await this.discoverLocalPlugins()).find((candidate) => candidate.id === pluginId);
return this.packageProviderForAgentDir(await this.currentAgentDir(config)); if (localPlugin !== undefined) return localPlugin;
const packageProvider = await this.currentPackageProvider();
if (packageProvider === undefined) return undefined;
const records = new Map<string, PluginRecord>();
for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin);
return records.get(pluginId);
} }
private async currentAgentDir(config?: PiWebConfig): Promise<string> { private async currentPackageProvider(): Promise<PiPackageProvider | undefined> {
if (this.staticPackageProvider !== undefined) return this.staticPackageProvider;
if (this.packageProviderForAgentDir === undefined) return undefined;
return this.packageProviderForAgentDir(await this.currentAgentDir());
}
private async currentAgentDir(): Promise<string> {
if (this.agentDirProvider !== undefined) return await this.agentDirProvider(); if (this.agentDirProvider !== undefined) return await this.agentDirProvider();
if (this.agentDir !== undefined) return this.agentDir; if (this.agentDir !== undefined) return this.agentDir;
const currentConfig = config ?? await this.configProvider(); throw new Error("Pi package plugin discovery requires an explicit active agent directory");
return effectiveAgentConfig(process.env, currentConfig, this.cwd).dir;
} }
private async discoverLocalPlugins(): Promise<PluginRecord[]> { private async discoverLocalPlugins(): Promise<PluginRecord[]> {
+49 -1
View File
@@ -14,6 +14,7 @@ const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"]; const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"]; const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"]; const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
const originalAgentDir = process.env["PI_WEB_AGENT_DIR"];
afterEach(() => { afterEach(() => {
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck); restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
@@ -23,6 +24,7 @@ afterEach(() => {
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode); restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir); restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot); restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
restoreEnv("PI_WEB_AGENT_DIR", originalAgentDir);
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -64,7 +66,7 @@ describe("PI WEB status", () => {
capabilities: [], capabilities: [],
}); });
const status = await getPiWebVersionStatus(daemon, { agentCommand: "alt-agent", agentDir }); const status = await getPiWebVersionStatus(daemon, { activeAgentProfile: activeProfile("a", "alt-agent", agentDir) });
expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" }); expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" });
} finally { } finally {
@@ -72,6 +74,29 @@ describe("PI WEB status", () => {
} }
}); });
it("does not fall back to the web process environment when no active profile is available", async () => {
disableDockerRuntimeEnv();
const agentDir = await tempHome();
try {
await installConfiguredPiWebPackage(agentDir);
process.env["PI_WEB_AGENT_DIR"] = agentDir;
const daemon = daemonWithRuntime({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
available: true,
capabilities: [],
});
const status = await getPiWebVersionStatus(daemon);
expect(status.components.web.installation?.kind).not.toBe("pi-package");
expect(status.components.sessiond.installation?.kind).not.toBe("pi-package");
} finally {
await rm(agentDir, { recursive: true, force: true });
}
});
it("reports web-only capabilities from the web runtime", async () => { it("reports web-only capabilities from the web runtime", async () => {
const daemon = daemonWithComponent({ const daemon = daemonWithComponent({
component: "sessiond", component: "sessiond",
@@ -161,6 +186,19 @@ describe("PI WEB status", () => {
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale"); expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
}); });
it("suppresses Pi package update planning without an active companion command", async () => {
const hasCommand = vi.fn(() => Promise.resolve(true));
const updateCommand = await updateCommandFor(
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
"pi-web restart",
{ agentCommand: undefined, hasCommand },
);
expect(updateCommand).toBeUndefined();
expect(hasCommand).not.toHaveBeenCalled();
});
it("shell-quotes pi-package agent update commands", async () => { it("shell-quotes pi-package agent update commands", async () => {
const updateCommand = await updateCommandFor( const updateCommand = await updateCommandFor(
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
@@ -276,6 +314,16 @@ describe("PI WEB status", () => {
}); });
}); });
function activeProfile(revisionCharacter: string, command: string, dir: string) {
return {
schemaVersion: 1 as const,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
function npmVersionResponse(version: string): Response { function npmVersionResponse(version: string): Response {
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
} }
+11 -23
View File
@@ -6,12 +6,11 @@ import { homedir } from "node:os";
import { dirname, join, relative, resolve, sep } from "node:path"; import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent"; import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; import type { ActiveAgentProfileDescriptor, PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { effectiveAgentConfig } from "../config.js";
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js"; import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -77,21 +76,10 @@ interface PiWebStatusDaemon {
export interface PiWebStatusOptions { export interface PiWebStatusOptions {
forceReleaseCheck?: boolean; forceReleaseCheck?: boolean;
agentCommand?: string; activeAgentProfile?: ActiveAgentProfileDescriptor;
agentDir?: string;
hasCommand?: (command: string) => Promise<boolean>; hasCommand?: (command: string) => Promise<boolean>;
} }
function effectiveStatusAgentConfig(options: PiWebStatusOptions): { command: string; dir: string } {
const agent = effectiveAgentConfig(process.env, {
agent: {
...(options.agentCommand === undefined ? {} : { command: options.agentCommand }),
...(options.agentDir === undefined ? {} : { dir: options.agentDir }),
},
});
return { command: agent.command, dir: agent.dir };
}
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion); const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
const runtimePackageInfo = readPackageInfoSync(); const runtimePackageInfo = readPackageInfoSync();
@@ -119,7 +107,7 @@ export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDae
export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise<PiWebComponentStatus> { export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise<PiWebComponentStatus> {
const [installed, installation] = await Promise.all([ const [installed, installation] = await Promise.all([
readInstalledPackageInfo(), readInstalledPackageInfo(),
detectPiWebInstallation(options.agentDir), detectPiWebInstallation(options.activeAgentProfile?.dir),
]); ]);
const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION; const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION;
const installedVersion = installed?.version; const installedVersion = installed?.version;
@@ -147,12 +135,11 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
} }
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> { export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
const agent = effectiveStatusAgentConfig(options); const versionStatus = await getPiWebVersionStatus(daemon, options);
const versionStatus = await getPiWebVersionStatus(daemon, { ...options, agentDir: agent.dir });
const { web, sessiond } = versionStatus.components; const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond }; const components = { web, sessiond };
const commands = await commandsFor(components, { agentCommand: agent.command, hasCommand: options.hasCommand ?? hasCommand }); const commands = await commandsFor(components, { agentCommand: options.activeAgentProfile?.command, hasCommand: options.hasCommand ?? hasCommand });
const messages = buildMessages(components, release, commands); const messages = buildMessages(components, release, commands);
return { return {
...versionStatus, ...versionStatus,
@@ -209,11 +196,12 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined
async function detectPiWebInstallation(agentDir?: string): Promise<PiWebInstallationInfo> { async function detectPiWebInstallation(agentDir?: string): Promise<PiWebInstallationInfo> {
const docker = detectDockerInstallation(); const docker = detectDockerInstallation();
if (docker !== undefined) return docker; if (docker !== undefined) return docker;
const resolvedAgentDir = agentDir ?? effectiveAgentConfig().dir;
const root = packageRootPath(); const root = packageRootPath();
const realRoot = await realPathOrSelf(root); const realRoot = await realPathOrSelf(root);
const piPackage = await detectPiPackageInstallation(realRoot, root, resolvedAgentDir); if (agentDir !== undefined) {
const piPackage = await detectPiPackageInstallation(realRoot, root, agentDir);
if (piPackage !== undefined) return piPackage; if (piPackage !== undefined) return piPackage;
}
const npmGlobal = await detectNpmGlobalInstallation(realRoot, root); const npmGlobal = await detectNpmGlobalInstallation(realRoot, root);
if (npmGlobal !== undefined) return npmGlobal; if (npmGlobal !== undefined) return npmGlobal;
return { kind: "local", path: root }; return { kind: "local", path: root };
@@ -427,7 +415,7 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version; return version;
} }
async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string; hasCommand: (command: string) => Promise<boolean> }): Promise<PiWebStatusResponse["commands"]> { async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<PiWebStatusResponse["commands"]> {
const installation = preferredInstallation(components); const installation = preferredInstallation(components);
if (installation?.kind === "docker") return dockerCommands(installation); if (installation?.kind === "docker") return dockerCommands(installation);
@@ -478,10 +466,10 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
return cliCommands.restart ?? serviceCommands.restart; return cliCommands.restart ?? serviceCommands.restart;
} }
export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string; hasCommand: (command: string) => Promise<boolean> }): Promise<string | undefined> { export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<string | undefined> {
if (restartCommand === undefined) return undefined; if (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") { if (installation?.kind === "pi-package") {
if (!(await options.hasCommand(options.agentCommand))) return undefined; if (options.agentCommand === undefined || !(await options.hasCommand(options.agentCommand))) return undefined;
return `${shellQuote(options.agentCommand)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`; return `${shellQuote(options.agentCommand)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`;
} }
if (installation?.kind === "local" && installation.path !== undefined) { if (installation?.kind === "local" && installation.path !== undefined) {
+35 -27
View File
@@ -9,6 +9,10 @@ export type SessionDaemonAgentProfileResult =
| { status: "unavailable"; error: string } | { status: "unavailable"; error: string }
| { status: "invalid"; error: string }; | { status: "invalid"; error: string };
export interface SessionDaemonRequestClient {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
}
export class SessionDaemonClient { export class SessionDaemonClient {
private readonly baseUrl = sessiondHttpUrl(); private readonly baseUrl = sessiondHttpUrl();
private readonly socketPath = sessiondSocketPath(); private readonly socketPath = sessiondSocketPath();
@@ -19,33 +23,8 @@ export class SessionDaemonClient {
return this.requestSocket(method, path, payload); return this.requestSocket(method, path, payload);
} }
async getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult> { getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult> {
let response: Awaited<ReturnType<SessionDaemonClient["request"]>>; return getSessionDaemonActiveAgentProfile(this);
try {
response = await this.request("GET", "/runtime");
} catch (error) {
return { status: "unavailable", error: errorMessage(error) };
}
if (response.statusCode < 200 || response.statusCode >= 300) {
return { status: "unavailable", error: `session daemon runtime request returned HTTP ${String(response.statusCode)}` };
}
let value: unknown;
try {
value = response.body === "" ? undefined : JSON.parse(response.body);
} catch {
return { status: "invalid", error: "session daemon runtime response was not valid JSON" };
}
const runtime = parsePiWebRuntimeComponent(value);
if (runtime?.component !== "sessiond") {
return { status: "invalid", error: "session daemon runtime response was invalid" };
}
if (runtime.activeAgentProfile === undefined) {
return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" };
}
return { status: "available", profile: runtime.activeAgentProfile };
} }
connectWebSocket(path: string): WebSocket { connectWebSocket(path: string): WebSocket {
@@ -103,6 +82,35 @@ export class SessionDaemonClient {
} }
} }
export async function getSessionDaemonActiveAgentProfile(client: SessionDaemonRequestClient): Promise<SessionDaemonAgentProfileResult> {
let response: Awaited<ReturnType<SessionDaemonRequestClient["request"]>>;
try {
response = await client.request("GET", "/runtime");
} catch (error) {
return { status: "unavailable", error: errorMessage(error) };
}
if (response.statusCode < 200 || response.statusCode >= 300) {
return { status: "unavailable", error: `session daemon runtime request returned HTTP ${String(response.statusCode)}` };
}
let value: unknown;
try {
value = response.body === "" ? undefined : JSON.parse(response.body);
} catch {
return { status: "invalid", error: "session daemon runtime response was not valid JSON" };
}
const runtime = parsePiWebRuntimeComponent(value);
if (runtime?.component !== "sessiond") {
return { status: "invalid", error: "session daemon runtime response was invalid" };
}
if (runtime.activeAgentProfile === undefined) {
return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" };
}
return { status: "available", profile: runtime.activeAgentProfile };
}
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }