diff --git a/src/server/activeAgentProfileProvider.test.ts b/src/server/activeAgentProfileProvider.test.ts new file mode 100644 index 0000000..7878e14 --- /dev/null +++ b/src/server/activeAgentProfileProvider.test.ts @@ -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() + .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().mockResolvedValue({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: "not-json", + }); + const unavailableRequest = vi.fn().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, + }), + }; +} diff --git a/src/server/activeAgentProfileProvider.ts b/src/server/activeAgentProfileProvider.ts new file mode 100644 index 0000000..75dfccf --- /dev/null +++ b/src/server/activeAgentProfileProvider.ts @@ -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; +} + +/** 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 { + return getSessionDaemonActiveAgentProfile(this.daemon); + } +} + +export class ActiveAgentProfileAccessError extends Error { + readonly profileStatus: "unavailable" | "invalid"; + + constructor(result: Exclude) { + 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 { + const result = await provider.getActiveAgentProfile(); + if (result.status !== "available") throw new ActiveAgentProfileAccessError(result); + return result.profile; +} diff --git a/src/server/app.activeAgentProfile.test.ts b/src/server/app.activeAgentProfile.test.ts new file mode 100644 index 0000000..c80b3fd --- /dev/null +++ b/src/server/app.activeAgentProfile.test.ts @@ -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 { + 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/server/app.agentConfig.test.ts b/src/server/app.agentConfig.test.ts index b129343..21622ff 100644 --- a/src/server/app.agentConfig.test.ts +++ b/src/server/app.agentConfig.test.ts @@ -1,13 +1,13 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; 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"; registerAppTestHooks(); -describe("buildApp agent config", () => { - it.each(["/api/config", "/api/machines/local/config"])("uses the latest configured agent dir for status after writes through %s", async (configRoute) => { +describe("buildApp active agent profile", () => { + 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([ "PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_DOCKER_RUNTIME", @@ -24,9 +24,11 @@ describe("buildApp agent config", () => { try { const initialAgentDir = join(appTestContext.tempDir, "initial-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 installConfiguredPiWebPackage(updatedAgentDir); + process.env["PI_WEB_AGENT_DIR"] = updatedAgentDir; const initialStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" }); expect(initialStatus.statusCode).toBe(200); @@ -35,14 +37,27 @@ describe("buildApp agent config", () => { const updateResponse = await appTestContext.app.inject({ method: "PUT", url: configRoute, - payload: { config: { agent: { command: "pi", dir: updatedAgentDir } } }, + payload: { config: { agent: { command: "next-agent", dir: updatedAgentDir } } }, }); 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().components.web.installation?.kind).not.toBe("pi-package"); + expect(desiredWriteVersion.json().components.web.installation?.kind).not.toBe("pi-package"); - expect(refreshedStatus.statusCode).toBe(200); - expect(refreshedStatus.json().components.web.installation).toMatchObject({ + appTestContext.agentProfileResult = { status: "available", profile: activeProfile("b", "next-agent", updatedAgentDir) }; + 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().components.web.installation).toMatchObject({ + kind: "pi-package", + source: process.cwd(), + scope: "user", + }); + expect(restartedVersion.json().components.web.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), 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 { await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8"); diff --git a/src/server/app.testSupport.ts b/src/server/app.testSupport.ts index 7478ddd..3d689e1 100644 --- a/src/server/app.testSupport.ts +++ b/src/server/app.testSupport.ts @@ -14,7 +14,8 @@ import { WorkspaceService } from "./workspaces/workspaceService.js"; import type { PiPackageService } from "./piPackageService.js"; import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; -import 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 { readonly app: FastifyInstance; @@ -24,6 +25,7 @@ interface AppTestContext { readonly sessionDaemonRequests: CapturedSessionDaemonRequest[]; readonly piPackageRequests: CapturedPiPackageRequest[]; piWebConfig: PiWebConfigValues; + agentProfileResult: SessionDaemonAgentProfileResult; } let app: FastifyInstance | undefined; @@ -33,6 +35,7 @@ let remoteClient: MachineClient | undefined; let sessionDaemonRequests: CapturedSessionDaemonRequest[] = []; let piPackageRequests: CapturedPiPackageRequest[] = []; let piWebConfig: PiWebConfigValues = {}; +let agentProfileResult: SessionDaemonAgentProfileResult = { status: "invalid", error: "App test harness was not initialized" }; export const appTestContext: AppTestContext = { get app() { @@ -65,6 +68,12 @@ export const appTestContext: AppTestContext = { set piWebConfig(config) { piWebConfig = config; }, + get agentProfileResult() { + return agentProfileResult; + }, + set agentProfileResult(result) { + agentProfileResult = result; + }, }; export function registerAppTestHooks(): void { @@ -75,6 +84,7 @@ export function registerAppTestHooks(): void { sessionDaemonRequests = []; piPackageRequests = []; piWebConfig = {}; + agentProfileResult = { status: "available", profile: appTestAgentProfile(join(tempDir, "agent")) }; app = await buildApp({ projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))), workspaces: new WorkspaceService(), @@ -95,6 +105,7 @@ export function registerAppTestHooks(): void { }), }), sessionDaemon: fakeSessionDaemon(), + agentProfileProvider: { getActiveAgentProfile: () => Promise.resolve(agentProfileResult) }, config: fakeConfigService(), piPackages: fakePiPackageService(), piWebPlugins: { @@ -117,6 +128,7 @@ export function registerAppTestHooks(): void { sessionDaemonRequests = []; piPackageRequests = []; piWebConfig = {}; + agentProfileResult = { status: "invalid", error: "App test harness was not initialized" }; if (appToClose !== undefined) await appToClose.close(); 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 { return { host: "127.0.0.1", diff --git a/src/server/app.ts b/src/server/app.ts index a7c0035..6bd2eee 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; 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 fastifyStatic from "@fastify/static"; import fastifyWebsocket from "@fastify/websocket"; @@ -21,11 +21,16 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.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 { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.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 { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; @@ -37,6 +42,7 @@ export interface AppDependencies { workspaces?: WorkspaceService; machines?: MachineService; sessionDaemon?: SessionProxyDaemon; + agentProfileProvider?: ActiveAgentProfileProvider; piWebPlugins?: Pick; piPackages?: PiPackageService; piWebStatusCache?: PiWebStatusCache; @@ -125,10 +131,6 @@ async function readEffectiveConfig(config: Pick) { return (await config.read()).effectiveConfig; } -async function readEffectiveAgentConfig(config: Pick): Promise { - return effectiveAgentConfig(process.env, await readEffectiveConfig(config)); -} - function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: Pick): PiWebConfigService { return { read: () => config.read(), @@ -140,6 +142,15 @@ function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: P }; } +async function withProfileDependency(reply: FastifyReply, operation: () => Promise): Promise { + 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 { const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) }); // Vite proxies development API requests here, while production and machine-scoped @@ -155,19 +166,19 @@ export async function buildApp(deps: AppDependencies = {}): Promise 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({ configProvider: readConfig, + agentDirProvider: async () => (await requireActiveAgentProfile(agentProfileProvider)).dir, }); - const piPackages = deps.piPackages ?? createDefaultPiPackageService(process.cwd(), (await readAgentConfig()).dir); - const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient(); + const piPackages = deps.piPackages ?? createActiveProfilePiPackageService(agentProfileProvider); const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache( async ({ force }) => { - const agent = await readAgentConfig(); + const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile(); return getPiWebStatus(sessionDaemon, { forceReleaseCheck: force, - agentCommand: agent.command, - agentDir: agent.dir, + ...(activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {}), }); }, { 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 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) => { if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return; - const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]); - if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" }); - return reply.type(asset.contentType).send(asset.content); + return withProfileDependency(reply, async () => { + const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]); + if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" }); + return reply.type(asset.contentType).send(asset.content); + }); }); app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1" ? piWebStatusCache.refresh({ force: true }) : piWebStatusCache.get()); app.get("/api/pi-web/version", async () => { - const agent = await readAgentConfig(); - return getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }); + const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile(); + return getPiWebVersionStatus(sessionDaemon, activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {}); }); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); - app.get("/api/plugins", async () => piWebPlugins.plugins()); - app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins()); + app.get("/api/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins())); + app.get("/api/machines/local/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins())); registerPiPackageRoutes(app, piPackages); registerPiPackageRoutes(app, piPackages, "/api/machines/local"); const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache); diff --git a/src/server/piPackageRoutes.test.ts b/src/server/piPackageRoutes.test.ts index 18bbb20..ed603d4 100644 --- a/src/server/piPackageRoutes.test.ts +++ b/src/server/piPackageRoutes.test.ts @@ -1,6 +1,7 @@ import Fastify, { type FastifyInstance } from "fastify"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PiPackageInfo } from "../shared/apiTypes.js"; +import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js"; import type { PiPackageService } from "./piPackageService.js"; import { registerPiPackageRoutes } from "./piPackageRoutes.js"; @@ -95,6 +96,15 @@ describe("registerPiPackageRoutes", () => { 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 () => { serviceMocks.install.mockRejectedValueOnce(new Error("install failed")); diff --git a/src/server/piPackageRoutes.ts b/src/server/piPackageRoutes.ts index f6c01b4..444833f 100644 --- a/src/server/piPackageRoutes.ts +++ b/src/server/piPackageRoutes.ts @@ -1,10 +1,11 @@ import type { FastifyInstance, FastifyReply } from "fastify"; 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 {} -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); app.get(`${routePrefix}/pi-packages`, async (_request, reply) => { @@ -79,7 +80,11 @@ function requireRequestObject(value: unknown): Record { } 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) }); } diff --git a/src/server/piPackageService.test.ts b/src/server/piPackageService.test.ts index 2bcd536..c6a0600 100644 --- a/src/server/piPackageService.test.ts +++ b/src/server/piPackageService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; 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[] = []) { const listConfiguredPackages = vi.fn(() => packages); @@ -21,6 +22,44 @@ function deferred() { return { promise, resolve, reject }; } +describe("ActiveProfilePiPackageService", () => { + it("uses the daemon profile active when each package operation begins", async () => { + const getActiveAgentProfile = vi.fn() + .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", () => { it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => { 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; +} diff --git a/src/server/piPackageService.ts b/src/server/piPackageService.ts index 98472cc..5e93bb5 100644 --- a/src/server/piPackageService.ts +++ b/src/server/piPackageService.ts @@ -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 { requireActiveAgentProfile, type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js"; export interface PiPackageManagerPort { listConfiguredPackages(): PiPackageInfo[]; @@ -16,6 +17,47 @@ export interface PiPackageService { update(source?: string): Promise; } +export type PiPackageServiceForAgentDir = (agentDir: string) => PiPackageService; + +export class ActiveProfilePiPackageService implements PiPackageService { + private mutationQueue: Promise = Promise.resolve(); + + constructor( + private readonly activeAgentProfile: ActiveAgentProfileProvider, + private readonly serviceForAgentDir: PiPackageServiceForAgentDir, + ) {} + + async list(): Promise { + return await this.withActiveService((service) => service.list()); + } + + install(source: string): Promise { + return this.enqueueMutation((service) => service.install(source)); + } + + remove(source: string, scope?: PiPackageScope): Promise { + return this.enqueueMutation((service) => service.remove(source, scope)); + } + + update(source?: string): Promise { + return this.enqueueMutation((service) => service.update(source)); + } + + private enqueueMutation(operation: (service: PiPackageService) => Promise): Promise { + const queuedMutation = this.mutationQueue.then(() => this.withActiveService(operation)); + this.mutationQueue = queuedMutation.then( + () => undefined, + () => undefined, + ); + return queuedMutation; + } + + private async withActiveService(operation: (service: PiPackageService) => Promise): Promise { + const profile = await requireActiveAgentProfile(this.activeAgentProfile); + return await operation(this.serviceForAgentDir(profile.dir)); + } +} + export class DefaultPiPackageService implements PiPackageService { private mutationQueue: Promise = 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 manager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); return new DefaultPiPackageService({ diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 5a0967c..0576a24 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile, mkdir, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js"; import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService.js"; 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); }); - 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 initialAgentDir = join(tempDir, "initial-agent"); const updatedAgentDir = join(tempDir, "updated-agent"); - let currentConfig = { agent: { dir: initialAgentDir } }; + let activeAgentDir = initialAgentDir; await writePlugin(packageDir, { packageJson: { piWeb: { plugins: [{ id: "agent-package", module: "dist/plugin.js" }] } }, files: { "dist/plugin.js": "export default {};" }, @@ -145,15 +146,31 @@ describe("PiWebPluginService", () => { await mkdir(initialAgentDir, { recursive: true }); await mkdir(updatedAgentDir, { recursive: true }); 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: [] }); - currentConfig = { agent: { dir: updatedAgentDir } }; + activeAgentDir = updatedAgentDir; 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 () => { const agentDir = join(tempDir, "agent"); const firstPackageDir = join(tempDir, "first-package"); diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 8271d44..fe3ea53 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -3,7 +3,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; 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 { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -71,8 +71,8 @@ type ArraylessPluginRecord = Omit; export class DefaultPiPackageProvider implements PiPackageProvider { constructor( - private readonly cwd = process.cwd(), - private readonly agentDir = defaultAgentDirForCwd(cwd), + private readonly cwd: string, + private readonly agentDir: string, ) {} 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 { - private readonly cwd: string; private readonly roots: LocalPluginRoot[]; private readonly agentDir: string | undefined; private readonly agentDirProvider: (() => string | Promise) | undefined; + private readonly staticPackageProvider: PiPackageProvider | undefined; private readonly packageProviderForAgentDir: ((agentDir: string) => PiPackageProvider) | undefined; private readonly configProvider: () => PiWebConfig | Promise; constructor(options: PiWebPluginServiceOptions = {}) { const cwd = options.cwd ?? process.cwd(); - this.cwd = cwd; this.roots = options.roots ?? defaultPluginRoots(cwd); this.agentDir = options.agentDir; this.agentDirProvider = options.agentDirProvider; const packageProvider = options.packageProvider; - if (packageProvider === false) { - this.packageProviderForAgentDir = undefined; - } else if (packageProvider !== undefined) { - this.packageProviderForAgentDir = () => packageProvider; - } else { - this.packageProviderForAgentDir = (agentDir) => new DefaultPiPackageProvider(cwd, agentDir); - } + this.staticPackageProvider = packageProvider === false || packageProvider === undefined ? undefined : packageProvider; + this.packageProviderForAgentDir = packageProvider === false || packageProvider !== undefined + ? undefined + : (agentDir) => new DefaultPiPackageProvider(cwd, agentDir); this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config); } @@ -131,13 +123,13 @@ export class PiWebPluginService { async plugins(): Promise { 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)) }; } async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | 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; const resolved = resolve(plugin.root, assetPath); @@ -164,26 +156,37 @@ export class PiWebPluginService { }; } - private async discoverPlugins(config?: PiWebConfig): Promise { + private async discoverPlugins(): Promise { const records = new Map(); for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin); - const packageProvider = await this.packageProvider(config); + const packageProvider = await this.currentPackageProvider(); if (packageProvider !== undefined) { for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin); } return [...records.values()].sort((left, right) => left.id.localeCompare(right.id)); } - private async packageProvider(config?: PiWebConfig): Promise { - if (this.packageProviderForAgentDir === undefined) return undefined; - return this.packageProviderForAgentDir(await this.currentAgentDir(config)); + private async findPlugin(pluginId: string): Promise { + const localPlugin = (await this.discoverLocalPlugins()).find((candidate) => candidate.id === pluginId); + if (localPlugin !== undefined) return localPlugin; + + const packageProvider = await this.currentPackageProvider(); + if (packageProvider === undefined) return undefined; + const records = new Map(); + for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin); + return records.get(pluginId); } - private async currentAgentDir(config?: PiWebConfig): Promise { + private async currentPackageProvider(): Promise { + if (this.staticPackageProvider !== undefined) return this.staticPackageProvider; + if (this.packageProviderForAgentDir === undefined) return undefined; + return this.packageProviderForAgentDir(await this.currentAgentDir()); + } + + private async currentAgentDir(): Promise { if (this.agentDirProvider !== undefined) return await this.agentDirProvider(); if (this.agentDir !== undefined) return this.agentDir; - const currentConfig = config ?? await this.configProvider(); - return effectiveAgentConfig(process.env, currentConfig, this.cwd).dir; + throw new Error("Pi package plugin discovery requires an explicit active agent directory"); } private async discoverLocalPlugins(): Promise { diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 8c9f685..7b81e0a 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -14,6 +14,7 @@ const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"]; const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"]; const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"]; const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"]; +const originalAgentDir = process.env["PI_WEB_AGENT_DIR"]; afterEach(() => { restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck); @@ -23,6 +24,7 @@ afterEach(() => { restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode); restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir); restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot); + restoreEnv("PI_WEB_AGENT_DIR", originalAgentDir); vi.restoreAllMocks(); }); @@ -64,7 +66,7 @@ describe("PI WEB status", () => { 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" }); } 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 () => { const daemon = daemonWithComponent({ component: "sessiond", @@ -161,6 +186,19 @@ describe("PI WEB status", () => { 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 () => { const updateCommand = await updateCommandFor( { 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 { return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } }); } diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index dbe7ece..56c2a92 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -6,12 +6,11 @@ import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; 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 { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; -import { effectiveAgentConfig } from "../config.js"; import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; @@ -77,21 +76,10 @@ interface PiWebStatusDaemon { export interface PiWebStatusOptions { forceReleaseCheck?: boolean; - agentCommand?: string; - agentDir?: string; + activeAgentProfile?: ActiveAgentProfileDescriptor; hasCommand?: (command: string) => Promise; } -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 runtimePackageInfo = readPackageInfoSync(); @@ -119,7 +107,7 @@ export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDae export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise { const [installed, installation] = await Promise.all([ readInstalledPackageInfo(), - detectPiWebInstallation(options.agentDir), + detectPiWebInstallation(options.activeAgentProfile?.dir), ]); const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_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 { - const agent = effectiveStatusAgentConfig(options); - const versionStatus = await getPiWebVersionStatus(daemon, { ...options, agentDir: agent.dir }); + const versionStatus = await getPiWebVersionStatus(daemon, options); const { web, sessiond } = versionStatus.components; const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); 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); return { ...versionStatus, @@ -209,11 +196,12 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined async function detectPiWebInstallation(agentDir?: string): Promise { const docker = detectDockerInstallation(); if (docker !== undefined) return docker; - const resolvedAgentDir = agentDir ?? effectiveAgentConfig().dir; const root = packageRootPath(); const realRoot = await realPathOrSelf(root); - const piPackage = await detectPiPackageInstallation(realRoot, root, resolvedAgentDir); - if (piPackage !== undefined) return piPackage; + if (agentDir !== undefined) { + const piPackage = await detectPiPackageInstallation(realRoot, root, agentDir); + if (piPackage !== undefined) return piPackage; + } const npmGlobal = await detectNpmGlobalInstallation(realRoot, root); if (npmGlobal !== undefined) return npmGlobal; return { kind: "local", path: root }; @@ -427,7 +415,7 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise { return version; } -async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string; hasCommand: (command: string) => Promise }): Promise { +async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise }): Promise { const installation = preferredInstallation(components); if (installation?.kind === "docker") return dockerCommands(installation); @@ -478,10 +466,10 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv return cliCommands.restart ?? serviceCommands.restart; } -export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string; hasCommand: (command: string) => Promise }): Promise { +export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise }): Promise { if (restartCommand === undefined) return undefined; 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}`; } if (installation?.kind === "local" && installation.path !== undefined) { diff --git a/src/sessiond/sessionDaemonClient.ts b/src/sessiond/sessionDaemonClient.ts index 06b69d3..ca097b2 100644 --- a/src/sessiond/sessionDaemonClient.ts +++ b/src/sessiond/sessionDaemonClient.ts @@ -9,6 +9,10 @@ export type SessionDaemonAgentProfileResult = | { status: "unavailable"; error: string } | { status: "invalid"; error: string }; +export interface SessionDaemonRequestClient { + request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; +} + export class SessionDaemonClient { private readonly baseUrl = sessiondHttpUrl(); private readonly socketPath = sessiondSocketPath(); @@ -19,33 +23,8 @@ export class SessionDaemonClient { return this.requestSocket(method, path, payload); } - async getActiveAgentProfile(): Promise { - let response: Awaited>; - 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 }; + getActiveAgentProfile(): Promise { + return getSessionDaemonActiveAgentProfile(this); } connectWebSocket(path: string): WebSocket { @@ -103,6 +82,35 @@ export class SessionDaemonClient { } } +export async function getSessionDaemonActiveAgentProfile(client: SessionDaemonRequestClient): Promise { + let response: Awaited>; + 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 { return error instanceof Error ? error.message : String(error); }