feat: add local machine registry foundation

This commit is contained in:
Marc Kassubeck
2026-05-26 13:04:00 +02:00
parent 712456f953
commit 0405b384b1
18 changed files with 673 additions and 28 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add the first machine registry API and show the synthesized Local machine in the web UI as the foundation for machine federation.
+120 -19
View File
@@ -98,8 +98,24 @@ Initial storage file:
$PI_WEB_DATA_DIR/machines.json
```
Allow tests and advanced deployments to override it with:
```text
PI_WEB_MACHINES_FILE=/path/to/machines.json
```
The `local` machine is synthesized by the service, not persisted. The stored file contains remote machines only. This keeps the default local endpoint stable, prevents accidental deletion/corruption of the built-in machine, and allows a fresh install with no `machines.json` to behave exactly like current Pi Web.
Default behavior when no file exists:
```json
{
"machines": []
}
```
API responses still include the synthesized local machine first:
```json
{
"machines": [
@@ -139,10 +155,12 @@ Example create request:
Rules:
- `local` machine cannot be deleted.
- `local` machine cannot be created, patched, or deleted through the registry because it is synthesized.
- Remote `baseUrl` must be `http:` or `https:`.
- Remote `baseUrl` must not include username/password, query, or hash components.
- Normalize `baseUrl` by trimming trailing slash.
- Do not return `token` in normal responses.
- Treat machine registry credentials as gateway-to-remote Pi Web credentials, not model-provider credentials.
### Machine-scoped project/workspace/file/git routes
@@ -195,6 +213,14 @@ Compatibility aliases keep using local machine:
/api/sessions...
```
Remote auth policy for first remote implementation:
- Machine registry `token`/`headers` authenticate the gateway to the remote Pi Web instance.
- Model-provider API keys and OAuth state remain owned by each target machine/session daemon.
- API-key provider configuration may be proxied once the normal remote HTTP proxy is working.
- OAuth flows should not be fully proxied in the first remote phase. The UI should offer to open the selected remote Pi Web directly for OAuth login/logout until callback origin behavior is explicitly designed and tested.
- If a remote auth endpoint is unavailable or intentionally unsupported, return a clear error telling the user to configure auth on the remote machine.
### Machine-scoped WebSockets
Canonical new routes:
@@ -231,8 +257,9 @@ src/server/machines/machineProxyRoutes.ts
Responsibilities:
- Read/write `$PI_WEB_DATA_DIR/machines.json`.
- Return default local machine if file is missing.
- Read/write `$PI_WEB_DATA_DIR/machines.json`, or `PI_WEB_MACHINES_FILE` when configured.
- Store remote machine records only. Do not persist the synthesized `local` machine.
- Return an empty remote list if the file is missing.
- Validate JSON shape.
- Generate stable IDs for new remote machines.
@@ -240,8 +267,9 @@ Responsibilities:
Responsibilities:
- CRUD machine records.
- Prevent deleting `local`.
- CRUD remote machine records.
- Synthesize the built-in `local` machine in list/get responses.
- Prevent creating, patching, or deleting `local`.
- Resolve a machine by ID.
- Create an appropriate gateway target:
- local target: existing services and local session daemon client;
@@ -259,16 +287,20 @@ Responsibilities:
Pseudo-interface:
```ts
interface MachineHttpResponse {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string | Buffer | NodeJS.ReadableStream;
}
interface MachineClient {
request(method: string, path: string, body?: unknown): Promise<{
statusCode: number;
headers: Record<string, string>;
body: string;
}>;
request(method: string, path: string, body?: unknown): Promise<MachineHttpResponse>;
connectWebSocket(path: string): WebSocket;
}
```
The interface must support streaming/binary responses because file previews and future downloads cannot safely be represented as JSON strings.
For `local`, this can be backed by direct local services where practical or by existing local route handlers/session daemon clients. For first implementation, keep local code paths mostly unchanged and add route wrappers.
### Route implementation strategy
@@ -279,7 +311,15 @@ For `local`, this can be backed by direct local services where practical or by e
- If `machineId === "local"`, call current local services.
- Else proxy equivalent path to remote machine without the `/api/machines/:machineId` prefix.
Example remote mapping:
Path translation must be explicit and tested:
```text
/api/machines/:machineId/<compat-path>
-> /api/<compat-path> for remote Pi Web HTTP/WebSocket routes
-> /<compat-path> for local sessiond routes where sessiond expects non-/api paths
```
Examples:
```text
GET /api/machines/devbox/projects
@@ -287,10 +327,33 @@ GET /api/machines/devbox/projects
WS /api/machines/devbox/sessions/abc/events
-> WS wss://devbox.example.ts.net/api/sessions/abc/events
GET /api/machines/local/sessions/abc/status
-> local sessiond GET /sessions/abc/status
```
This lets remote machines run unmodified Pi Web at first. Later, when remote Pi Web also supports machine-scoped APIs, the gateway can still target the compatibility aliases on that remote.
Proxy response handling rules:
- Preserve query strings exactly after the machine prefix is stripped.
- Pass through successful JSON responses using normal API parsers.
- Pass through binary/streaming responses such as file previews without buffering into strings.
- Forward only safe response headers such as `content-type`, `content-length`, `cache-control`, `last-modified`, and `etag`.
- Strip hop-by-hop headers such as `connection`, `transfer-encoding`, `upgrade`, `keep-alive`, and `proxy-authenticate`.
- Apply short request timeouts for health checks and bounded timeouts for normal HTTP proxy requests.
- Normalize remote unreachable/timeouts to gateway errors (`502`/`504`) with clear messages.
Proxy security rules:
- Never ignore TLS certificate errors by default.
- Do not follow redirects for proxied API requests unless there is a specific, reviewed need.
- Do not forward browser credentials/cookies to remote machines by default.
- Only attach credentials configured on the machine record, and block configured headers that would override transport semantics such as `host`, `connection`, `upgrade`, `transfer-encoding`, `content-length`, or `authorization` unless the field is the explicit token/auth mechanism.
- Use request body size limits consistent with the existing local API.
- Use response size limits for JSON endpoints where practical; streaming/binary endpoints should stream with timeout/backpressure rather than unbounded buffering.
- Private network URLs are allowed because Tailscale/WireGuard/SSH tunnels are a primary use case, but the UI and docs should warn that registering a machine gives the local Pi Web server permission to contact that endpoint.
## Client architecture
### State changes
@@ -314,6 +377,31 @@ workspaces // workspaces for selectedProject on selectedMachine
sessions // sessions for selectedWorkspace on selectedMachine
```
### Cross-machine identity and cache keys
Server APIs should keep returning the target machine's native IDs. The client must namespace any state, cache, route restoration, or lookup table that can contain entities from more than one machine.
Use helper functions rather than ad hoc string concatenation:
```ts
const machineProjectKey = (machineId: string, projectId: string) => `${machineId}:${projectId}`;
const machineWorkspaceKey = (machineId: string, projectId: string, workspaceId: string) => `${machineId}:${projectId}:${workspaceId}`;
const machineSessionKey = (machineId: string, sessionId: string) => `${machineId}:${sessionId}`;
```
At minimum, namespace:
- `workspacesByProjectId` or its replacement;
- `sessionStatuses`;
- `sessionActivities`;
- `workspaceActivities`;
- chat transcript caches;
- prompt draft storage;
- any cached new-session or session-restoration state;
- terminal socket state if more than one machine can be active at a time.
Flat selected-machine views are still fine for rendering, but persisted and long-lived maps should never assume project, workspace, session, or terminal IDs are globally unique.
### API client changes
In `src/client/src/api/clients.ts`, add:
@@ -473,13 +561,16 @@ src/client/src/route.test.ts
Cover:
- default local machine when no machines file exists;
- default local machine is synthesized when no machines file exists;
- `machines.json` stores remote machines only and does not persist `local`;
- `PI_WEB_MACHINES_FILE` overrides the default store path;
- add remote machine;
- reject invalid base URLs;
- reject invalid base URLs, including username/password, query, and hash components;
- do not expose token in response;
- cannot delete local machine;
- cannot create, patch, or delete local machine;
- route read/write with and without `machine`;
- switching machine clears project/workspace/session state;
- machine-scoped cache key helpers avoid collisions;
- missing route machine falls back to local.
### Integration tests
@@ -487,8 +578,12 @@ Cover:
Add server route tests with mocked remote machine client:
- `GET /api/machines/remote/projects` proxies to `/api/projects` on remote.
- local sessiond path mapping strips `/api/machines/local` and forwards `/sessions...`, `/auth...`, and `/activity` correctly.
- Remote non-2xx status passes through reasonably.
- Remote unreachable returns 502 with useful error.
- Remote timeout returns 504 with useful error.
- Binary/streaming responses such as file previews are not coerced into strings.
- Hop-by-hop headers are stripped and safe response headers are preserved.
- WebSocket path mapping uses `ws:`/`wss:` correctly.
### Manual test matrix
@@ -533,12 +628,17 @@ npm test
### Phase 1: Local machine registry only
Deliverable: Pi Web has a Machines list, but only `local` exists and all existing behavior works.
Deliverable: Pi Web has a Machines list, but only synthesized `local` exists and all existing behavior works.
This can be split into two PRs if review size matters:
- Phase 1a: shared `Machine` types, remote-only `MachineStore`, `MachineService`, `/api/machines` routes, and tests.
- Phase 1b: client `machinesApi`, `MachineController`, selected-machine state, route support, and Local-only UI.
Tasks:
- Add `Machine` shared types.
- Add `MachineStore`, `MachineService`, and `/api/machines` routes.
- Add remote-only `MachineStore`, `MachineService`, and `/api/machines` routes that synthesize `local`.
- Add client `machinesApi`.
- Add `MachineController`.
- Add `selectedMachine` to app state.
@@ -548,7 +648,7 @@ Tasks:
Acceptance:
- Fresh UI shows `Local` under Machines.
- Fresh UI shows synthesized `Local` under Machines.
- Current project/workspace/session workflows unchanged.
- Current URLs continue to work.
@@ -578,7 +678,8 @@ Tasks:
- Add remote `MachineClient`.
- Add `GET /api/machines/:id/health`.
- Proxy machine-scoped HTTP routes for remote machines to remote compatibility routes.
- Add token/header support.
- Add token/header support for gateway-to-remote authentication.
- Keep OAuth provider login/logout flows remote-direct unless callback origin behavior is explicitly implemented.
- Add UI for add/remove remote machines.
Acceptance:
@@ -673,7 +774,7 @@ docs/machines.md or docs/federation.md
## Open questions
1. Should remote machine auth be a bearer token, arbitrary headers, or both?
2. Should `machines.json` store secrets directly, or should it use a separate secret store later?
2. Should remote machine secrets in `machines.json` stay inline for v1, or should they use a separate secret store later?
3. Should central Pi Web allow adding projects to remote machines, or only list existing remote projects at first?
4. Should activity be subscribed only for selected machine, or for all machines with active health polling?
5. Should machine IDs be user-chosen slugs or generated UUIDs with editable names?
+2 -2
View File
@@ -1,3 +1,3 @@
export { activityApi, api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { activityApi, api, filesApi, gitApi, machinesApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+9
View File
@@ -14,6 +14,8 @@ import {
parseFileTreeResponse,
parseGitDiffResponse,
parseGitStatusResponse,
parseMachine,
parseMachinesResponse,
parseMessagePage,
parseModelSelectionResponse,
parseOAuthFlowState,
@@ -36,6 +38,12 @@ export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
};
export const machinesApi = {
machines: () => request("/api/machines", parseMachinesResponse),
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
};
export const activityApi = {
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
};
@@ -144,6 +152,7 @@ export const gitApi = {
export const api = {
...piWebApi,
...machinesApi,
...activityApi,
...projectsApi,
...workspacesApi,
+37 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -57,6 +57,42 @@ export function parseMessagePage(value: unknown): MessagePage {
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
}
export function parseMachinesResponse(value: unknown): Machine[] {
const record = requireRecord(value);
return arrayOf(parseMachine)(record["machines"]);
}
export function parseMachine(value: unknown): Machine {
const record = requireRecord(value);
const kind = requireMachineKind(record, "kind");
const baseUrl = optionalString(record, "baseUrl");
const status = optionalMachineStatus(record, "status");
const statusMessage = optionalString(record, "statusMessage");
return {
id: requireString(record, "id"),
name: requireString(record, "name"),
kind,
...(baseUrl === undefined ? {} : { baseUrl }),
createdAt: requireString(record, "createdAt"),
updatedAt: requireString(record, "updatedAt"),
...(status === undefined ? {} : { status }),
...(statusMessage === undefined ? {} : { statusMessage }),
};
}
function requireMachineKind(record: Record<string, unknown>, key: string): MachineKind {
const value = requireString(record, key);
if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`);
return value;
}
function optionalMachineStatus(record: Record<string, unknown>, key: string): MachineStatus | undefined {
const value = optionalString(record, key);
if (value === undefined) return undefined;
if (value !== "unknown" && value !== "online" && value !== "offline" && value !== "error") throw new Error(`Expected machine status field: ${key}`);
return value;
}
export function parseProject(value: unknown): Project {
const record = requireRecord(value);
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
+9 -1
View File
@@ -1,8 +1,12 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids";
export interface AppState {
machines: Machine[];
selectedMachine: Machine | undefined;
isLoadingMachines: boolean;
machineStatuses: Record<string, MachineHealth>;
projects: Project[];
workspaces: Workspace[];
sessions: SessionInfo[];
@@ -91,6 +95,10 @@ export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
export function initialAppState(): AppState {
return {
machines: [],
selectedMachine: undefined,
isLoadingMachines: false,
machineStatuses: {},
projects: [],
workspaces: [],
sessions: [],
+45
View File
@@ -0,0 +1,45 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { Machine } from "../api";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@customElement("machine-list")
export class MachineList extends LitElement {
@property({ attribute: false }) machines: Machine[] = [];
@property({ attribute: false }) selected?: Machine;
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
@property({ attribute: false }) onToggleCollapsed?: () => void;
override render() {
return html`
<section>
<h2>${this.renderHeading()}</h2>
${this.collapsed ? null : this.machines.map((machine) => html`
<div
class=${`action-row ${this.selected?.id === machine.id ? "selected" : ""}`}
tabindex="0"
title=${machine.baseUrl ?? machine.name}
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }}
>
<div class="action-main">
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl}</small>
</div>
</div>
`)}
</section>
`;
}
private renderHeading() {
if (!this.collapsible) return "Machines";
const selectedSummary = this.selected?.name ?? "No machine selected";
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
}
static override styles = listStyles;
}
+24 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import { piWebApi, terminalsApi, type Machine, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity";
@@ -8,6 +8,7 @@ import { ActivityController } from "../controllers/activityController";
import { AuthController } from "../controllers/authController";
import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { MachineController } from "../controllers/machineController";
import { ProjectController } from "../controllers/projectController";
import { SessionController } from "../controllers/sessionController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
@@ -25,6 +26,7 @@ import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMo
import { readRoute, writeRoute, type AppRoute } from "../route";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
import "./MachineList";
import "./ProjectList";
import "./WorkspaceList";
import "./SessionList";
@@ -42,7 +44,7 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import { actionMenuPanelStyle } from "./actionMenu";
import { appStyles } from "./shared";
type NavigationSection = "projects" | "workspaces" | "sessions";
type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
@@ -86,6 +88,12 @@ export class PiWebApp extends LitElement {
(patch) => { this.setState(patch); },
this.workspaces,
);
private readonly machines = new MachineController(
() => this.state,
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
this.projects,
);
private readonly files = new FileExplorerController(
() => this.state,
(patch) => { this.setState(patch); },
@@ -252,6 +260,8 @@ export class PiWebApp extends LitElement {
}
private async loadProjectsAndRestoreRoute() {
const route = readRoute();
await this.machines.loadMachines(route.machineId);
await this.projects.loadProjects();
await this.withChatScrollTransition(() => this.restoreRoute(false));
await this.refreshWorkspaceDeletionRuns();
@@ -368,6 +378,7 @@ export class PiWebApp extends LitElement {
private updateUrl(options?: { replace?: boolean | undefined }) {
writeRoute({
machineId: this.state.selectedMachine?.id,
projectId: this.state.selectedProject?.id,
workspaceId: this.state.selectedWorkspace?.id,
sessionId: this.state.selectedSession?.id,
@@ -528,6 +539,17 @@ export class PiWebApp extends LitElement {
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
</div>
</header>
<machine-list
.machines=${this.state.machines}
.selected=${this.state.selectedMachine}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("machines")}
.onToggleCollapsed=${() => { this.toggleNavigationSection("machines"); }}
.onSelect=${(machine: Machine) => this.withChatScrollTransition(async () => {
this.expandNavigationSection("projects");
await this.machines.selectMachine(machine);
})}
></machine-list>
<project-list
.projects=${this.state.projects}
.selected=${this.state.selectedProject}
@@ -0,0 +1,41 @@
import { api, type Machine } from "../api";
import { resetWorkspaceScopedState } from "../appState";
import type { GetState, SetState, UpdateUrl } from "./types";
import type { ProjectController } from "./projectController";
export class MachineController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: ProjectController) {}
async loadMachines(routeMachineId?: string): Promise<void> {
this.setState({ error: "", isLoadingMachines: true });
try {
const machines = await api.machines();
const selectedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local")) ?? machines.find((machine) => machine.id === "local") ?? machines[0];
this.setState({ machines, selectedMachine });
} catch (error) {
this.setState({ error: String(error) });
} finally {
this.setState({ isLoadingMachines: false });
}
}
async selectMachine(machine: Machine): Promise<void> {
if (this.getState().selectedMachine?.id === machine.id) return;
this.setState({
selectedMachine: machine,
projects: [],
workspaces: [],
selectedProject: undefined,
selectedWorkspace: undefined,
selectedSession: undefined,
messages: [],
messagePageStart: 0,
messagePageTotal: 0,
status: undefined,
activity: undefined,
...resetWorkspaceScopedState(),
});
this.updateUrl();
await this.projects.loadProjects();
}
}
+5 -3
View File
@@ -33,9 +33,10 @@ function installWindow(href: string): { pushed: string[] } {
describe("route helpers", () => {
it("reads only supported route fields from the current URL", () => {
installWindow("http://localhost/app?project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
installWindow("http://localhost/app?machine=remote&project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
expect(readRoute()).toEqual({
machineId: "remote",
projectId: "p1",
workspaceId: "w1",
sessionId: "s1",
@@ -53,6 +54,7 @@ describe("route helpers", () => {
it("writes compact URLs and preserves path/hash", () => {
const { pushed } = installWindow("http://localhost/app?old=1#section");
const route: AppRoute = {
machineId: "remote",
projectId: "project/id",
workspaceId: "workspace id",
sessionId: "",
@@ -62,13 +64,13 @@ describe("route helpers", () => {
writeRoute(route);
expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
});
it("does not push history when the route is unchanged", () => {
const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
expect(pushed).toEqual([]);
});
+4
View File
@@ -1,6 +1,7 @@
import type { QualifiedContributionId } from "./plugins/types";
export interface AppRoute {
machineId: string | undefined;
projectId: string | undefined;
workspaceId: string | undefined;
sessionId: string | undefined;
@@ -11,6 +12,7 @@ export interface AppRoute {
export function readRoute(): AppRoute {
const params = new URLSearchParams(window.location.search);
return {
machineId: params.get("machine") ?? undefined,
projectId: params.get("project") ?? undefined,
workspaceId: params.get("workspace") ?? undefined,
sessionId: params.get("session") ?? undefined,
@@ -21,11 +23,13 @@ export function readRoute(): AppRoute {
export function writeRoute(route: AppRoute, options?: { replace?: boolean | undefined }): void {
const url = new URL(window.location.href);
url.searchParams.delete("machine");
url.searchParams.delete("project");
url.searchParams.delete("workspace");
url.searchParams.delete("session");
url.searchParams.delete("tool");
url.searchParams.delete("view");
if (route.machineId !== undefined && route.machineId !== "" && route.machineId !== "local") url.searchParams.set("machine", route.machineId);
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
+18
View File
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildApp } from "./app.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js";
@@ -20,6 +22,7 @@ beforeEach(async () => {
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(),
machines: new MachineService(new MachineStore(join(tempDir, "machines.json"))),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
@@ -35,6 +38,21 @@ afterEach(async () => {
});
describe("buildApp", () => {
it("lists synthesized local machine through the HTTP contract", async () => {
const response = await app.inject({ method: "GET", url: "/api/machines" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] });
});
it("adds remote machines without exposing tokens", async () => {
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } });
expect(addResponse.statusCode).toBe(200);
expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" });
expect(addResponse.json()).not.toHaveProperty("token");
});
it("adds, lists, and closes projects through the HTTP contract", async () => {
const addResponse = await app.inject({
method: "POST",
+6
View File
@@ -15,10 +15,13 @@ import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { getPiWebStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
export interface AppDependencies {
projects?: ProjectService;
workspaces?: WorkspaceService;
machines?: MachineService;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "readAsset">;
clientDist?: string | false;
logger?: FastifyServerOptions["logger"];
@@ -31,6 +34,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const machines = deps.machines ?? new MachineService();
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
@@ -42,6 +46,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/api/pi-web/status", async () => getPiWebStatus());
registerMachineRoutes(app, machines);
app.get("/api/projects", async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyInstance } from "fastify";
import { MachineService, type CreateMachineInput, type UpdateMachineInput } from "./machineService.js";
export function registerMachineRoutes(app: FastifyInstance, machines = new MachineService()): void {
app.get("/api/machines", async () => ({ machines: await machines.list() }));
app.post<{ Body: CreateMachineInput }>("/api/machines", async (request, reply) => {
try {
return await machines.add(request.body);
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
const machine = await machines.get(request.params.machineId);
if (machine === undefined) return reply.code(404).send({ error: "Machine not found" });
return machine;
});
app.patch<{ Params: { machineId: string }; Body: UpdateMachineInput }>("/api/machines/:machineId", async (request, reply) => {
try {
const machine = await machines.update(request.params.machineId, request.body);
if (machine === undefined) return await reply.code(404).send({ error: "Machine not found" });
return machine;
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.delete<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
try {
const removed = await machines.remove(request.params.machineId);
if (!removed) return await reply.code(404).send({ error: "Machine not found" });
return { deleted: true };
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,55 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { MachineService } from "./machineService.js";
import { MachineStore, machineStorePath } from "./machineStore.js";
let tempDir: string;
let storePath: string;
let service: MachineService;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-machines-test-"));
storePath = join(tempDir, "machines.json");
service = new MachineService(new MachineStore(storePath));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("MachineService", () => {
it("synthesizes local machine without persisting it", async () => {
expect(await service.list()).toEqual([
{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" },
]);
});
it("adds remote machines and omits secrets from public responses", async () => {
const machine = await service.add({ name: " Dev Box ", baseUrl: "https://devbox.example.test/", token: "secret" });
expect(machine).toMatchObject({ name: "Dev Box", kind: "remote", baseUrl: "https://devbox.example.test" });
expect(machine).not.toHaveProperty("token");
expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" }), machine]);
const raw: unknown = JSON.parse(await readFile(storePath, "utf8"));
expect(raw).toMatchObject({ machines: [expect.objectContaining({ kind: "remote", token: "secret" })] });
});
it("rejects invalid remote base URLs", async () => {
await expect(service.add({ name: "Bad", baseUrl: "ftp://example.test" })).rejects.toThrow("http or https");
await expect(service.add({ name: "Bad", baseUrl: "https://[email protected]" })).rejects.toThrow("credentials");
await expect(service.add({ name: "Bad", baseUrl: "https://example.test/path?q=1" })).rejects.toThrow("query or hash");
});
it("does not allow local machine mutation", async () => {
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
});
it("supports PI_WEB_MACHINES_FILE path overrides", () => {
const env: NodeJS.ProcessEnv = { PI_WEB_MACHINES_FILE: "data/machines.json" };
expect(machineStorePath(env, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "data/machines.json"));
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { Machine } from "../../shared/apiTypes.js";
import { MachineStore, type StoredMachine } from "./machineStore.js";
export interface CreateMachineInput {
name?: string;
baseUrl?: string;
token?: string;
headers?: Record<string, string>;
}
export type UpdateMachineInput = Partial<CreateMachineInput>;
const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z";
export class MachineService {
constructor(private readonly store = new MachineStore()) {}
async list(): Promise<Machine[]> {
return [localMachine(), ...(await this.store.list()).map(publicMachine)];
}
async get(id: string): Promise<Machine | undefined> {
if (id === "local") return localMachine();
const machine = (await this.store.list()).find((stored) => stored.id === id);
return machine === undefined ? undefined : publicMachine(machine);
}
async add(input: CreateMachineInput): Promise<Machine> {
const name = validateName(input.name);
const baseUrl = validateBaseUrl(input.baseUrl);
const stored = await this.store.add({ name, baseUrl, ...optionalSecrets(input) });
return publicMachine(stored);
}
async update(id: string, input: UpdateMachineInput): Promise<Machine | undefined> {
if (id === "local") throw new Error("Local machine cannot be changed");
const patch: Partial<Pick<StoredMachine, "name" | "baseUrl" | "token" | "headers">> = {};
if (input.name !== undefined) patch.name = validateName(input.name);
if (input.baseUrl !== undefined) patch.baseUrl = validateBaseUrl(input.baseUrl);
if (input.token !== undefined) patch.token = input.token;
if (input.headers !== undefined) patch.headers = validateHeaders(input.headers);
const stored = await this.store.update(id, patch);
return stored === undefined ? undefined : publicMachine(stored);
}
async remove(id: string): Promise<boolean> {
if (id === "local") throw new Error("Local machine cannot be deleted");
return await this.store.remove(id);
}
}
export function localMachine(): Machine {
return { id: "local", name: "Local", kind: "local", createdAt: LOCAL_MACHINE_TIMESTAMP, updatedAt: LOCAL_MACHINE_TIMESTAMP };
}
function publicMachine(machine: StoredMachine): Machine {
return { id: machine.id, name: machine.name, kind: "remote", baseUrl: machine.baseUrl, createdAt: machine.createdAt, updatedAt: machine.updatedAt };
}
function validateName(value: string | undefined): string {
const name = value?.trim();
if (name === undefined || name === "") throw new Error("Machine name is required");
return name;
}
function validateBaseUrl(value: string | undefined): string {
const raw = value?.trim();
if (raw === undefined || raw === "") throw new Error("Machine baseUrl is required");
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error("Machine baseUrl must be a valid URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Machine baseUrl must use http or https");
if (url.username !== "" || url.password !== "") throw new Error("Machine baseUrl must not include credentials");
if (url.search !== "" || url.hash !== "") throw new Error("Machine baseUrl must not include query or hash");
return url.href.replace(/\/$/u, "");
}
function optionalSecrets(input: CreateMachineInput): { token?: string; headers?: Record<string, string> } {
return {
...(input.token === undefined ? {} : { token: input.token }),
...(input.headers === undefined ? {} : { headers: validateHeaders(input.headers) }),
};
}
function validateHeaders(value: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(value).map(([key, headerValue]) => {
if (typeof headerValue !== "string") throw new Error("Machine headers must be strings");
return [key, headerValue];
}));
}
+132
View File
@@ -0,0 +1,132 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { piWebDataDir } from "../../config.js";
export interface StoredMachine {
id: string;
name: string;
kind: "remote";
baseUrl: string;
token?: string;
headers?: Record<string, string>;
createdAt: string;
updatedAt: string;
}
interface MachineFile {
machines: StoredMachine[];
}
export function defaultMachineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
return join(piWebDataDir(env, cwd), "machines.json");
}
export function machineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
const configured = env["PI_WEB_MACHINES_FILE"];
if (configured === undefined || configured === "") return defaultMachineStorePath(env, cwd);
return resolve(cwd, configured);
}
export class MachineStore {
constructor(private readonly filePath = machineStorePath()) {}
async list(): Promise<StoredMachine[]> {
return (await this.read()).machines;
}
async add(input: { name: string; baseUrl: string; token?: string; headers?: Record<string, string> }): Promise<StoredMachine> {
const data = await this.read();
const now = new Date().toISOString();
const machine: StoredMachine = {
id: randomUUID(),
name: input.name,
kind: "remote",
baseUrl: input.baseUrl,
...(input.token === undefined ? {} : { token: input.token }),
...(input.headers === undefined ? {} : { headers: input.headers }),
createdAt: now,
updatedAt: now,
};
data.machines.push(machine);
await this.write(data);
return machine;
}
async update(id: string, patch: Partial<Pick<StoredMachine, "name" | "baseUrl" | "token" | "headers">>): Promise<StoredMachine | undefined> {
const data = await this.read();
const index = data.machines.findIndex((machine) => machine.id === id);
if (index < 0) return undefined;
const current = data.machines[index];
if (current === undefined) return undefined;
const next: StoredMachine = { ...current, ...patch, updatedAt: new Date().toISOString() };
data.machines[index] = next;
await this.write(data);
return next;
}
async remove(id: string): Promise<boolean> {
const data = await this.read();
const machines = data.machines.filter((machine) => machine.id !== id);
if (machines.length === data.machines.length) return false;
await this.write({ machines });
return true;
}
private async read(): Promise<MachineFile> {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
return parseMachineFile(value);
} catch (error) {
if (isNodeErrorWithCode(error, "ENOENT")) return { machines: [] };
throw error;
}
}
private async write(data: MachineFile): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
}
function parseMachineFile(value: unknown): MachineFile {
if (!isRecord(value) || !Array.isArray(value["machines"])) throw new Error("Invalid machine file");
return { machines: value["machines"].map(parseStoredMachine) };
}
function parseStoredMachine(value: unknown): StoredMachine {
if (!isRecord(value)) throw new Error("Invalid machine");
const id = value["id"];
const name = value["name"];
const kind = value["kind"];
const baseUrl = value["baseUrl"];
const createdAt = value["createdAt"];
const updatedAt = value["updatedAt"];
if (typeof id !== "string" || typeof name !== "string" || kind !== "remote" || typeof baseUrl !== "string" || typeof createdAt !== "string" || typeof updatedAt !== "string") throw new Error("Invalid machine");
const token = optionalString(value["token"], "token");
const headers = optionalStringRecord(value["headers"], "headers");
return { id, name, kind, baseUrl, createdAt, updatedAt, ...(token === undefined ? {} : { token }), ...(headers === undefined ? {} : { headers }) };
}
function optionalString(value: unknown, key: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") throw new Error(`Invalid machine ${key}`);
return value;
}
function optionalStringRecord(value: unknown, key: string): Record<string, string> | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) throw new Error(`Invalid machine ${key}`);
return Object.fromEntries(Object.entries(value).map(([header, headerValue]) => {
if (typeof headerValue !== "string") throw new Error(`Invalid machine ${key}`);
return [header, headerValue];
}));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+24
View File
@@ -1,3 +1,27 @@
export type MachineKind = "local" | "remote";
export type MachineStatus = "unknown" | "online" | "offline" | "error";
export interface Machine {
id: string;
name: string;
kind: MachineKind;
baseUrl?: string;
createdAt: string;
updatedAt: string;
status?: MachineStatus;
statusMessage?: string;
}
export interface MachineHealth {
machineId: string;
ok: boolean;
checkedAt: string;
status?: MachineStatus;
web?: PiWebComponentStatus;
sessiond?: PiWebComponentStatus;
error?: string;
}
export interface Project {
id: string;
name: string;