From 332db5faebc6ce0a491572963004ca8829be606d Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 1 Jul 2026 22:13:02 +0200 Subject: [PATCH] feat: expose package management capability --- .changeset/responsive-package-settings.md | 2 +- src/client/src/api/parsers.test.ts | 6 ++-- src/client/src/api/parsers.ts | 7 +++-- src/server/app.test.ts | 6 ++-- src/server/machines/machineService.ts | 28 ++---------------- src/server/piWebStatus.test.ts | 20 ++++++++++++- src/shared/apiTypes.ts | 1 + src/shared/capabilities.test.ts | 19 ++++++++++++ src/shared/capabilities.ts | 25 ++++++++++++++-- src/shared/piWebStatusParsing.test.ts | 35 +++++++++++++++++++++++ src/shared/piWebStatusParsing.ts | 24 ++++++++++------ 11 files changed, 127 insertions(+), 46 deletions(-) create mode 100644 src/shared/capabilities.test.ts create mode 100644 src/shared/piWebStatusParsing.test.ts diff --git a/.changeset/responsive-package-settings.md b/.changeset/responsive-package-settings.md index 141c148..83d445e 100644 --- a/.changeset/responsive-package-settings.md +++ b/.changeset/responsive-package-settings.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately. +Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately, and report Pi package-management support through runtime capabilities. diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index b351ff1..fadfd5e 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -24,11 +24,11 @@ describe("API parsers", () => { packageName: "@jmfederico/pi-web", generatedAt: "now", components: { - web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, + web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] }, sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, }, - capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], - })).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"], + })).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] }); }); it("parses Pi package list and mutation responses", () => { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index a81ffa0..299ed4c 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,6 +1,6 @@ import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes"; -import { isPiWebCapability } from "../../../shared/capabilities"; +import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -810,8 +810,9 @@ function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent { } function parsePiWebCapabilities(value: unknown): PiWebCapability[] { - if (!Array.isArray(value) || !value.every(isPiWebCapability)) throw new Error("Invalid PI WEB capabilities"); - return value; + const capabilities = parseKnownPiWebCapabilities(value); + if (capabilities === undefined) throw new Error("Invalid PI WEB capabilities"); + return capabilities; } function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity { diff --git a/src/server/app.test.ts b/src/server/app.test.ts index a2dc8e1..4123237 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -127,10 +127,10 @@ describe("buildApp", () => { packageName: "@jmfederico/pi-web", generatedAt: "2026-05-25T00:00:00.000Z", components: { - web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, + web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] }, sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, }, - capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"], }, })); remoteClient = fakeRemoteClient({ requestJson }); @@ -138,7 +138,7 @@ describe("buildApp", () => { const runtime = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` }); expect(runtime.statusCode).toBe(200); - expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); + expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] }); expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 }); }); diff --git a/src/server/machines/machineService.ts b/src/server/machines/machineService.ts index 8044cee..2e8b6a3 100644 --- a/src/server/machines/machineService.ts +++ b/src/server/machines/machineService.ts @@ -1,5 +1,5 @@ import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js"; -import { isPiWebCapability } from "../../shared/capabilities.js"; +import { parsePiWebRuntimeResponse } from "../../shared/piWebStatusParsing.js"; import { getPiWebRuntime } from "../piWebStatus.js"; import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js"; import { MachineStore, type StoredMachine } from "./machineStore.js"; @@ -151,7 +151,8 @@ export class MachineService { const checkedAt = this.now().toISOString(); try { const response = await this.clientFor(machine).requestJson("GET", "/api/pi-web/runtime", undefined, { timeoutMs: DEFAULT_REMOTE_HEALTH_TIMEOUT_MS }); - if (response.statusCode >= 200 && response.statusCode < 300 && isPiWebRuntimeResponse(response.body)) return machineRuntime(id, checkedAt, response.body); + const runtime = parsePiWebRuntimeResponse(response.body); + if (response.statusCode >= 200 && response.statusCode < 300 && runtime !== undefined) return machineRuntime(id, checkedAt, runtime); return { machineId: id, ok: false, checkedAt, error: `Remote runtime returned HTTP ${String(response.statusCode)}` }; } catch (error) { return { machineId: id, ok: false, checkedAt, error: errorMessage(error) }; @@ -241,16 +242,6 @@ function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse { return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]); } -function isPiWebRuntimeResponse(value: unknown): value is PiWebRuntimeResponse { - if (!isRecord(value)) return false; - const packageName = value["packageName"]; - const generatedAt = value["generatedAt"]; - const components = value["components"]; - const capabilities = value["capabilities"]; - if (typeof packageName !== "string" || typeof generatedAt !== "string" || !isRecord(components) || !isPiWebCapabilityArray(capabilities)) return false; - return isPiWebRuntimeComponent(components["web"]) && isPiWebRuntimeComponent(components["sessiond"]); -} - function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus { if (!isRecord(value)) return false; const component = value["component"]; @@ -260,19 +251,6 @@ function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus { && typeof value["available"] === "boolean"; } -function isPiWebRuntimeComponent(value: unknown): boolean { - if (!isRecord(value)) return false; - const component = value["component"]; - return (component === "web" || component === "sessiond") - && typeof value["label"] === "string" - && typeof value["available"] === "boolean" - && isPiWebCapabilityArray(value["capabilities"]); -} - -function isPiWebCapabilityArray(value: unknown): boolean { - return Array.isArray(value) && value.every(isPiWebCapability); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 344395f..95f12ce 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -2,9 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; +import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; import type { PiWebComponentStatus } from "../shared/apiTypes.js"; +import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js"; const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"]; const originalHome = process.env["HOME"]; @@ -40,6 +41,23 @@ describe("PI WEB status", () => { expect(status).not.toHaveProperty("release"); }); + it("reports Pi package management as a web runtime capability", async () => { + const daemon = daemonWithComponent({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + installedVersion: "1.202605.8", + stale: true, + available: true, + }); + + const runtime = await getPiWebRuntime(daemon); + + expect(runtime.components.web.capabilities).toContain(PI_WEB_CAPABILITIES.piPackagesManage); + expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage); + expect(runtime.capabilities).toContain(PI_WEB_CAPABILITIES.piPackagesManage); + }); + it("reports stale session daemon versions as messages", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; const daemon = daemonWithComponent({ diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 4975cf4..4874145 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -8,6 +8,7 @@ export const PI_WEB_CAPABILITIES = { sessionsReload: "sessions.reload", promptAttachments: "prompt.attachments", workspaceFileSuggestions: "workspace.fileSuggestions", + piPackagesManage: "piPackages.manage", } as const; export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES]; diff --git a/src/shared/capabilities.test.ts b/src/shared/capabilities.test.ts new file mode 100644 index 0000000..98895f2 --- /dev/null +++ b/src/shared/capabilities.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { effectivePiWebCapabilities, PI_WEB_CAPABILITIES, SESSIOND_RUNTIME_CAPABILITIES, WEB_RUNTIME_CAPABILITIES, parseKnownPiWebCapabilities } from "./capabilities"; + +describe("PI WEB capabilities", () => { + it("advertises Pi package management from the web runtime only", () => { + expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage); + expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage); + + expect(effectivePiWebCapabilities({ + web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] }, + sessiond: { available: false, capabilities: [] }, + })).toEqual([PI_WEB_CAPABILITIES.piPackagesManage]); + }); + + it("keeps only known string capabilities when parsing runtime data", () => { + expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage]); + expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined(); + }); +}); diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 11b83d6..b53e5ce 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -6,8 +6,23 @@ export type { PiWebCapability }; export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES); const knownPiWebCapabilities: ReadonlySet = new Set(KNOWN_PI_WEB_CAPABILITIES); -export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[]; -export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsBulkMutations, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; +export const WEB_RUNTIME_CAPABILITIES = [ + PI_WEB_CAPABILITIES.sessionsDeleteArchived, + PI_WEB_CAPABILITIES.sessionsBulkMutations, + PI_WEB_CAPABILITIES.sessionsCleanup, + PI_WEB_CAPABILITIES.sessionsReload, + PI_WEB_CAPABILITIES.promptAttachments, + PI_WEB_CAPABILITIES.workspaceFileSuggestions, + PI_WEB_CAPABILITIES.piPackagesManage, +] as const satisfies readonly PiWebCapability[]; + +export const SESSIOND_RUNTIME_CAPABILITIES = [ + PI_WEB_CAPABILITIES.sessionsDeleteArchived, + PI_WEB_CAPABILITIES.sessionsBulkMutations, + PI_WEB_CAPABILITIES.sessionsCleanup, + PI_WEB_CAPABILITIES.sessionsReload, + PI_WEB_CAPABILITIES.promptAttachments, +] as const satisfies readonly PiWebCapability[]; const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"], @@ -16,6 +31,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], + [PI_WEB_CAPABILITIES.piPackagesManage]: ["web"], } as const satisfies Record; export function isPiWebCapability(value: unknown): value is PiWebCapability { @@ -26,6 +42,11 @@ export function supportsPiWebCapability(source: { capabilities?: readonly PiWebC return source?.capabilities?.includes(capability) === true; } +export function parseKnownPiWebCapabilities(value: unknown): PiWebCapability[] | undefined { + if (!Array.isArray(value) || !value.every((capability) => typeof capability === "string")) return undefined; + return value.filter(isPiWebCapability); +} + export function effectivePiWebCapabilities(components: Partial>>): PiWebCapability[] { return KNOWN_PI_WEB_CAPABILITIES.filter((capability) => { const requiredComponents = EFFECTIVE_CAPABILITY_REQUIREMENTS[capability]; diff --git a/src/shared/piWebStatusParsing.test.ts b/src/shared/piWebStatusParsing.test.ts new file mode 100644 index 0000000..b284fa4 --- /dev/null +++ b/src/shared/piWebStatusParsing.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { PI_WEB_CAPABILITIES } from "./capabilities"; +import { parsePiWebRuntimeResponse } from "./piWebStatusParsing"; + +describe("PI WEB status parsing", () => { + it("parses package-management runtime capabilities and ignores unknown string capabilities", () => { + expect(parsePiWebRuntimeResponse({ + packageName: "@jmfederico/pi-web", + generatedAt: "now", + components: { + web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] }, + sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] }, + }, + capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"], + })).toMatchObject({ + components: { + web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] }, + sessiond: { capabilities: [] }, + }, + capabilities: [PI_WEB_CAPABILITIES.piPackagesManage], + }); + }); + + it("rejects malformed capability arrays", () => { + expect(parsePiWebRuntimeResponse({ + packageName: "@jmfederico/pi-web", + generatedAt: "now", + components: { + web: { component: "web", label: "Web/UI", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, 1] }, + sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [] }, + }, + capabilities: [PI_WEB_CAPABILITIES.piPackagesManage], + })).toBeUndefined(); + }); +}); diff --git a/src/shared/piWebStatusParsing.ts b/src/shared/piWebStatusParsing.ts index 736c9c7..19c5492 100644 --- a/src/shared/piWebStatusParsing.ts +++ b/src/shared/piWebStatusParsing.ts @@ -1,5 +1,5 @@ -import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebVersionResponse } from "./apiTypes.js"; -import { isPiWebCapability } from "./capabilities.js"; +import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js"; +import { parseKnownPiWebCapabilities } from "./capabilities.js"; export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined { if (!isRecord(value)) return undefined; @@ -13,13 +13,26 @@ export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse return { packageName, generatedAt, components: { web, sessiond } }; } +export function parsePiWebRuntimeResponse(value: unknown): PiWebRuntimeResponse | undefined { + if (!isRecord(value)) return undefined; + const packageName = value["packageName"]; + const generatedAt = value["generatedAt"]; + const components = value["components"]; + const capabilities = parseKnownPiWebCapabilities(value["capabilities"]); + if (typeof packageName !== "string" || packageName === "" || typeof generatedAt !== "string" || generatedAt === "" || !isRecord(components) || capabilities === undefined) return undefined; + const web = parsePiWebRuntimeComponent(components["web"]); + const sessiond = parsePiWebRuntimeComponent(components["sessiond"]); + if (web === undefined || sessiond === undefined) return undefined; + return { packageName, generatedAt, components: { web, sessiond }, capabilities }; +} + export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent | undefined { if (!isRecord(value)) return undefined; const component = value["component"]; const label = value["label"]; const runtimeVersion = value["runtimeVersion"]; const available = value["available"]; - const capabilities = parsePiWebCapabilities(value["capabilities"]); + const capabilities = parseKnownPiWebCapabilities(value["capabilities"]); const error = value["error"]; if (component !== "web" && component !== "sessiond") return undefined; if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined; @@ -57,11 +70,6 @@ export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus }; } -function parsePiWebCapabilities(value: unknown): PiWebCapability[] | undefined { - if (!Array.isArray(value) || !value.every(isPiWebCapability)) return undefined; - return value; -} - export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined { if (!isRecord(value)) return undefined; const kind = value["kind"];