feat: support machine-specific plugins

This commit is contained in:
Federico Jaramillo Martinez
2026-06-09 15:10:32 +02:00
parent 0118e6ebe9
commit c57f24dfa5
22 changed files with 286 additions and 47 deletions
+20 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { machinesApi, terminalsApi, workspacesApi } from "./clients";
import { machinesApi, piWebApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = {
id: "w/1",
@@ -31,6 +31,25 @@ afterEach(() => {
});
describe("machine-scoped runtime API", () => {
it("reads machine PI WEB status through the gateway route", async () => {
const fetchMock = stubJsonFetch({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "PI WEB", available: true, stale: false },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
});
await piWebApi.piWebStatus("remote a");
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
});
it("reads machine runtime through the gateway route", async () => {
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
+1 -1
View File
@@ -43,7 +43,7 @@ import { machineGitDiffUrl, messageUrl } from "./urls";
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
};
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Workspace } from "../../../shared/apiTypes";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
import { workspaceImagePreviewUrl } from "./urls";
@@ -26,6 +26,7 @@ describe("federated route contract", () => {
vi.stubGlobal("fetch", fetchMock);
await Promise.all([
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
ignoreParseFailure(projectsApi.projects(machineId)),
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
+2 -2
View File
@@ -33,9 +33,9 @@ describe("API parsers", () => {
it("parses PI WEB plugin status responses", () => {
expect(parsePiWebPluginsResponse({
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
})).toEqual({
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
});
});
+7
View File
@@ -492,6 +492,7 @@ function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo {
module: requireString(record, "module"),
source: requireString(record, "source"),
scope: parsePiWebPluginScope(record["scope"]),
machineSpecific: parseOptionalBoolean(record["machineSpecific"], "machineSpecific") ?? false,
enabled: requireBoolean(record, "enabled"),
};
}
@@ -501,6 +502,12 @@ function parsePiWebPluginScope(value: unknown): PiWebPluginScope {
return value;
}
function parseOptionalBoolean(value: unknown, key: string): boolean | undefined {
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error(`Expected optional boolean field: ${key}`);
return value;
}
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
const record = requireRecord(value);
return {
+11 -3
View File
@@ -266,10 +266,13 @@ export class PiWebApp extends LitElement {
}
private async refreshPiWebStatus(): Promise<void> {
const machineId = selectedMachineId(this.state);
try {
this.setState({ piWebStatus: await piWebApi.piWebStatus() });
const piWebStatus = await piWebApi.piWebStatus(machineId);
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
} catch (error) {
console.warn("Failed to refresh PI WEB status", error);
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
}
}
@@ -723,7 +726,9 @@ export class PiWebApp extends LitElement {
this.realtime.close();
this.connectRealtime();
this.activeTerminalIds.clear();
this.setState({ piWebStatus: undefined });
this.git.updatePolling();
void this.refreshPiWebStatus();
void this.loadPluginsForSelectedMachine();
}
@@ -1186,7 +1191,10 @@ export class PiWebApp extends LitElement {
const existing = this.machinePluginLoadPromises.get(machine.id);
if (existing !== undefined) return existing;
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id }))
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
machineId: machine.id,
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
}))
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
this.machinePluginLoadPromises.set(machine.id, load);
@@ -46,7 +46,7 @@ export class SettingsPluginsPanel extends LitElement {
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
<div class="plugin-main">
<strong>${plugin.id}</strong>
<small>${plugin.source} · ${plugin.scope}</small>
<small>${plugin.source} · ${plugin.scope}${plugin.machineSpecific ? " · machine-specific" : ""}</small>
<small>${configuredState}</small>
</div>
<label class="toggle">
+12 -2
View File
@@ -1,9 +1,10 @@
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
interface PluginManifestEntry {
export interface PluginManifestEntry {
id: string;
module: string;
machineSpecific: boolean;
}
interface PluginManifest {
@@ -12,6 +13,7 @@ interface PluginManifest {
export interface LoadExternalPluginsOptions {
machineId?: string;
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
@@ -20,6 +22,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
const registrations: PiWebPluginRegistration[] = [];
for (const entry of manifest.plugins) {
if (options.shouldLoadPlugin?.(entry) === false) continue;
try {
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
@@ -27,6 +30,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
registrations.push({
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
plugin,
machineSpecific: entry.machineSpecific,
...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }),
});
} catch (error) {
@@ -48,11 +52,17 @@ function parseManifest(value: unknown): PluginManifest {
return {
plugins: value["plugins"].map((entry) => {
if (!isRecord(entry) || typeof entry["id"] !== "string" || entry["id"] === "" || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
return { id: entry["id"], module: entry["module"] };
return { id: entry["id"], module: entry["module"], machineSpecific: parseMachineSpecific(entry["machineSpecific"]) };
}),
};
}
function parseMachineSpecific(value: unknown): boolean {
if (value === undefined) return false;
if (typeof value !== "boolean") throw new Error("Invalid plugin manifest entry");
return value;
}
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin {
if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`);
const plugin = module["default"];
+85
View File
@@ -406,6 +406,91 @@ describe("PluginRegistry", () => {
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
expect(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "gateway" }]);
expect(registry.shouldLoadRemotePlugin("shared-tools")).toBe(false);
expect(registry.shouldLoadRemotePlugin("shared-tools", true)).toBe(true);
});
it("uses machine-specific remote duplicates instead of the gateway plugin for that machine", () => {
const registry = new PluginRegistry();
const workspace = testWorkspace();
const remotePluginId = machineScopedPluginId("remote-1", "updates");
registry.register({
id: "updates",
machineSpecific: true,
plugin: {
apiVersion: 1,
name: "Gateway Updates",
activate: () => ({
contributions: {
actions: [{ id: "open", title: "Open Gateway Updates", run: () => undefined }],
workspacePanels: [{ id: "workspace.updates", title: "Gateway Updates", render: () => html`<p>Gateway</p>` }],
workspaceLabels: [{ id: "label", items: () => [{ type: "text", text: "gateway" }] }],
},
}),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).toContain("updates:open");
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).not.toContain("updates:open");
expect(registry.shouldLoadRemotePlugin("updates")).toBe(true);
registry.register({
id: remotePluginId,
machineId: "remote-1",
sourcePluginId: "updates",
plugin: {
apiVersion: 1,
name: "Remote Updates",
activate: () => ({
contributions: {
actions: [{ id: "open", title: "Open Remote Updates", run: () => undefined }],
workspacePanels: [{ id: "workspace.updates", title: "Remote Updates", render: () => html`<p>Remote</p>` }],
workspaceLabels: [{ id: "label", items: () => [{ type: "text", text: "remote" }] }],
},
}),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).toContain("updates:open");
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toEqual([`${remotePluginId}:open`]);
const panels = registry.getWorkspacePanels();
expect(panels.find((panel) => panel.id === "updates:workspace.updates")?.visible?.(createWorkspacePanelContext("local"))).toBe(true);
expect(panels.find((panel) => panel.id === "updates:workspace.updates")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.updates`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("local", workspace))).toEqual([{ type: "text", text: "gateway" }]);
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "remote" }]);
});
it("allows a machine-specific remote duplicate to override a portable gateway plugin for that machine", () => {
const registry = new PluginRegistry();
const remotePluginId = machineScopedPluginId("remote-1", "status-tools");
registry.register({
id: "status-tools",
plugin: {
apiVersion: 1,
name: "Gateway Status Tools",
activate: () => ({ contributions: { actions: [{ id: "open", title: "Open Gateway Status", run: () => undefined }] } }),
},
});
expect(registry.shouldLoadRemotePlugin("status-tools")).toBe(false);
expect(registry.shouldLoadRemotePlugin("status-tools", true)).toBe(true);
registry.register({
id: remotePluginId,
machineId: "remote-1",
sourcePluginId: "status-tools",
machineSpecific: true,
plugin: {
apiVersion: 1,
name: "Remote Status Tools",
activate: () => ({ contributions: { actions: [{ id: "open", title: "Open Remote Status", run: () => undefined }] } }),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).toEqual(["status-tools:open"]);
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toEqual([`${remotePluginId}:open`]);
});
it("does not activate remote duplicates when the gateway plugin is already registered", () => {
+62 -14
View File
@@ -22,13 +22,16 @@ export class PluginRegistry {
private readonly themePairs: QualifiedThemePairContribution[] = [];
private readonly pluginIds = new Set<string>();
private readonly gatewayPluginIds = new Set<string>();
private readonly gatewayMachineSpecificPluginIds = new Set<string>();
private readonly remoteMachineSpecificPluginIds = new Map<string, Set<string>>();
private readonly contributionIds = new Set<QualifiedContributionId>();
register(registration: PiWebPluginRegistration): void {
const { id, plugin } = registration;
this.validatePluginId(id);
const machineSpecific = this.parseMachineSpecific(id, registration.machineSpecific);
if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
if (isDuplicateOfGatewayPlugin(registration, this.gatewayPluginIds)) return;
if (this.isRemoteDuplicateHiddenByGateway(registration.sourcePluginId, registration.machineId, machineSpecific)) return;
this.pluginIds.add(id);
const apiVersion: unknown = plugin.apiVersion;
@@ -42,11 +45,19 @@ export class PluginRegistry {
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
this.gatewayPluginIds.add(id);
if (machineSpecific) this.gatewayMachineSpecificPluginIds.add(id);
} else if (registration.sourcePluginId !== undefined && machineSpecific) {
addMappedSetValue(this.remoteMachineSpecificPluginIds, registration.sourcePluginId, registration.machineId);
}
}
shouldLoadRemotePlugin(sourcePluginId: string, machineSpecific = false): boolean {
return !this.gatewayPluginIds.has(sourcePluginId) || this.gatewayMachineSpecificPluginIds.has(sourcePluginId) || machineSpecific;
}
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context), action.sourcePluginId, this.gatewayPluginIds)).map((action) => {
const selectedMachineId = runtimeContextMachineId(context);
return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => {
const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
const enabled = action.enabled?.(scopedContext);
const qualified: QualifiedPluginAction = {
@@ -101,8 +112,8 @@ export class PluginRegistry {
pluginId,
localId: panel.id,
...(machineId === undefined ? {} : { machineId }),
visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
visible: (context: WorkspacePanelContext) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
};
}
@@ -117,8 +128,8 @@ export class PluginRegistry {
pluginId,
localId: contribution.id,
...(machineId === undefined ? {} : { machineId }),
visible: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) && (visible?.(context) ?? true),
items: (context) => isActiveForMachine(machineId, context.machine.id, sourcePluginId, this.gatewayPluginIds) ? items(context) : [],
visible: (context) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) && (visible?.(context) ?? true),
items: (context) => this.isContributionActive(pluginId, machineId, context.machine.id, sourcePluginId) ? items(context) : [],
};
}
@@ -152,6 +163,33 @@ export class PluginRegistry {
return `${pluginId}:${localId}`;
}
private isContributionActive(pluginId: string, machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined): boolean {
if (machineId === undefined) return !this.isGatewayPluginHiddenForMachine(pluginId, selectedMachineId);
return machineId === selectedMachineId && !this.isRemotePluginHiddenByGateway(sourcePluginId, machineId);
}
private isRemoteDuplicateHiddenByGateway(sourcePluginId: string | undefined, machineId: string | undefined, machineSpecific: boolean): boolean {
return sourcePluginId !== undefined
&& machineId !== undefined
&& this.gatewayPluginIds.has(sourcePluginId)
&& !this.gatewayMachineSpecificPluginIds.has(sourcePluginId)
&& !machineSpecific;
}
private isRemotePluginHiddenByGateway(sourcePluginId: string | undefined, machineId: string): boolean {
if (sourcePluginId === undefined) return false;
if (this.gatewayMachineSpecificPluginIds.has(sourcePluginId)) return false;
if (this.remoteMachineSpecificPluginIds.get(sourcePluginId)?.has(machineId) === true) return false;
return this.gatewayPluginIds.has(sourcePluginId);
}
private isGatewayPluginHiddenForMachine(pluginId: string, machineId: string): boolean {
return machineId !== "local" && (
this.gatewayMachineSpecificPluginIds.has(pluginId)
|| this.remoteMachineSpecificPluginIds.get(pluginId)?.has(machineId) === true
);
}
private validatePluginId(pluginId: string): void {
if (!idPattern.test(pluginId)) throw new Error(`Invalid plugin id: ${pluginId}`);
}
@@ -159,6 +197,12 @@ export class PluginRegistry {
private validateLocalId(localId: string): void {
if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`);
}
private parseMachineSpecific(pluginId: string, value: unknown): boolean {
if (value === undefined) return false;
if (typeof value !== "boolean") throw new Error(`Invalid plugin machineSpecific value for ${pluginId}: ${formatUnknownValue(value)}`);
return value;
}
}
function pluginRuntimeContextFor(context: PluginRuntimeContext, pluginId: string): PluginRuntimeContext {
@@ -179,16 +223,20 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
return context;
}
function isDuplicateOfGatewayPlugin(registration: PiWebPluginRegistration, gatewayPluginIds: ReadonlySet<string>): boolean {
return registration.machineId !== undefined && registration.sourcePluginId !== undefined && gatewayPluginIds.has(registration.sourcePluginId);
function addMappedSetValue(map: Map<string, Set<string>>, key: string, value: string): void {
const existing = map.get(key);
if (existing === undefined) map.set(key, new Set([value]));
else existing.add(value);
}
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string, sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean {
return machineId === undefined || (machineId === selectedMachineId && !isHiddenByGatewayPlugin(sourcePluginId, gatewayPluginIds));
}
function isHiddenByGatewayPlugin(sourcePluginId: string | undefined, gatewayPluginIds: ReadonlySet<string>): boolean {
return sourcePluginId !== undefined && gatewayPluginIds.has(sourcePluginId);
function formatUnknownValue(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
try {
return JSON.stringify(value);
} catch {
return Object.prototype.toString.call(value);
}
}
function runtimeContextMachineId(context: PluginRuntimeContext): string {
+1
View File
@@ -14,6 +14,7 @@ export interface PiWebPluginRegistration {
plugin: PiWebPlugin;
machineId?: string;
sourcePluginId?: PluginId;
machineSpecific?: boolean;
}
export interface PiWebPlugin {