Archived
feat: route profile consumers through sessiond
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<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);
|
||||
expect(refreshedStatus.json<PiWebStatusResponse>().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<PiWebStatusResponse>().components.web.installation).toMatchObject({
|
||||
kind: "pi-package",
|
||||
source: process.cwd(),
|
||||
scope: "user",
|
||||
});
|
||||
expect(restartedVersion.json<PiWebVersionResponse>().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<void> {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
|
||||
|
||||
@@ -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",
|
||||
|
||||
+31
-18
@@ -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<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||
piPackages?: PiPackageService;
|
||||
piWebStatusCache?: PiWebStatusCache;
|
||||
@@ -125,10 +131,6 @@ async function readEffectiveConfig(config: Pick<PiWebConfigService, "read">) {
|
||||
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 {
|
||||
return {
|
||||
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> {
|
||||
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<FastifyInsta
|
||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
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({
|
||||
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<FastifyInsta
|
||||
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) => {
|
||||
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["*"]);
|
||||
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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
}
|
||||
|
||||
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) });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
|
||||
@@ -21,6 +22,44 @@ function deferred<T = void>() {
|
||||
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", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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 manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
return new DefaultPiPackageService({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<PluginRecord, "source" | "scope">;
|
||||
|
||||
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<string>) | undefined;
|
||||
private readonly staticPackageProvider: PiPackageProvider | undefined;
|
||||
private readonly packageProviderForAgentDir: ((agentDir: string) => PiPackageProvider) | undefined;
|
||||
private readonly configProvider: () => PiWebConfig | Promise<PiWebConfig>;
|
||||
|
||||
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<PiWebPluginsResponse> {
|
||||
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<PluginRecord[]> {
|
||||
private async discoverPlugins(): Promise<PluginRecord[]> {
|
||||
const records = new Map<string, PluginRecord>();
|
||||
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<PiPackageProvider | undefined> {
|
||||
if (this.packageProviderForAgentDir === undefined) return undefined;
|
||||
return this.packageProviderForAgentDir(await this.currentAgentDir(config));
|
||||
private async findPlugin(pluginId: string): Promise<PluginRecord | undefined> {
|
||||
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<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.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<PluginRecord[]> {
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
|
||||
+11
-23
@@ -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<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 runtimePackageInfo = readPackageInfoSync();
|
||||
|
||||
@@ -119,7 +107,7 @@ export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDae
|
||||
export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise<PiWebComponentStatus> {
|
||||
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<PiWebStatusResponse> {
|
||||
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<PiWebInstallationInfo> {
|
||||
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 (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<string> {
|
||||
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);
|
||||
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<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 (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) {
|
||||
|
||||
@@ -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<string, string>; 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<SessionDaemonAgentProfileResult> {
|
||||
let response: Awaited<ReturnType<SessionDaemonClient["request"]>>;
|
||||
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<SessionDaemonAgentProfileResult> {
|
||||
return getSessionDaemonActiveAgentProfile(this);
|
||||
}
|
||||
|
||||
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 {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user