From 712456f953b1ab49955bc27c9147e252d60f64da Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Mon, 25 May 2026 09:27:38 +0200 Subject: [PATCH 01/10] docs: plan machine federation --- MACHINE_FEDERATION_PLAN.md | 689 +++++++++++++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 MACHINE_FEDERATION_PLAN.md diff --git a/MACHINE_FEDERATION_PLAN.md b/MACHINE_FEDERATION_PLAN.md new file mode 100644 index 0000000..e7ef8cd --- /dev/null +++ b/MACHINE_FEDERATION_PLAN.md @@ -0,0 +1,689 @@ +# Machine Federation Plan + +Goal: extend Pi Web from the current hierarchy: + +```text +Project -> Workspace -> Session +``` + +to: + +```text +Machine -> Project -> Workspace -> Session +``` + +A machine is a Pi Web runtime endpoint. The local machine is the current Pi Web install. Remote machines are other Pi Web installs reachable over HTTP/WebSocket, ideally through a trusted network/tunnel such as Tailscale, WireGuard, SSH forwarding, or a reverse proxy with auth. + +## Design principles + +1. **Upstreamable, not permanent-fork-only** + - Keep existing behavior working by auto-providing a default `local` machine. + - Keep current non-machine-scoped API routes as compatibility aliases for the local machine. + - Implement in small, reviewable phases. + +2. **Machine is a server-side concept first** + - Do not implement federation only in browser plugins. + - The browser should keep a single origin: the currently opened Pi Web server. + - The local Pi Web server acts as a gateway/proxy to remote Pi Web servers. + +3. **No direct remote browser calls by default** + - Avoid CORS problems and scattered credentials in browser code. + - Proxy HTTP and WebSocket traffic through the local Pi Web server. + +4. **Security explicitness** + - Pi Web is currently documented as trusted-user/trusted-path tooling, not a secure multi-tenant platform. + - Remote machines must be opt-in and should support token/header configuration before being exposed beyond private networks. + +5. **Minimal domain disruption** + - Projects, workspaces, sessions, files, git, terminals, activity, and auth remain owned by each target machine. + - Federation initially aggregates/proxies; it does not replicate remote state locally beyond machine registry and optional health cache. + +## Non-goals for first implementation + +- Multi-user RBAC. +- Public internet exposure guidance beyond warnings and token/private-network support. +- Cross-machine project import/sync. +- Cross-machine worktree management. +- Shared session IDs across machines. Session IDs are unique only within a machine unless namespaced client-side. +- Running remote session daemons directly from the central server. Remote machines should run their own Pi Web. + +## Data model + +Add shared API types in `src/shared/apiTypes.ts`: + +```ts +export type MachineKind = "local" | "remote"; +export type MachineStatus = "unknown" | "online" | "offline" | "error"; + +export interface Machine { + id: string; + name: string; + kind: MachineKind; + baseUrl?: string; // absent for local + createdAt: string; + updatedAt: string; + status?: MachineStatus; // optional summary from health checks + statusMessage?: string; +} + +export interface MachineHealth { + machineId: string; + ok: boolean; + checkedAt: string; + status?: MachineStatus; + web?: PiWebComponentStatus; + sessiond?: PiWebComponentStatus; + error?: string; +} +``` + +Server-only stored record can include fields that should not be echoed casually: + +```ts +interface StoredMachine { + id: string; + name: string; + kind: "local" | "remote"; + baseUrl?: string; + token?: string; + headers?: Record; + createdAt: string; + updatedAt: string; +} +``` + +Initial storage file: + +```text +$PI_WEB_DATA_DIR/machines.json +``` + +Default behavior when no file exists: + +```json +{ + "machines": [ + { + "id": "local", + "name": "Local", + "kind": "local" + } + ] +} +``` + +## API shape + +### Machine registry + +New canonical routes: + +```text +GET /api/machines +POST /api/machines +GET /api/machines/:machineId +PATCH /api/machines/:machineId +DELETE /api/machines/:machineId +GET /api/machines/:machineId/health +``` + +Example create request: + +```json +{ + "name": "Dev Box", + "baseUrl": "https://devbox.example.ts.net", + "token": "optional-token" +} +``` + +Rules: + +- `local` machine cannot be deleted. +- Remote `baseUrl` must be `http:` or `https:`. +- Normalize `baseUrl` by trimming trailing slash. +- Do not return `token` in normal responses. + +### Machine-scoped project/workspace/file/git routes + +Canonical new routes: + +```text +GET /api/machines/:machineId/projects +POST /api/machines/:machineId/projects +DELETE /api/machines/:machineId/projects/:projectId +GET /api/machines/:machineId/project-directories?q=... +GET /api/machines/:machineId/projects/:projectId/workspaces +GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/tree?path=... +GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/file?path=... +GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/file/preview?path=... +GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/git/status +GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/git/diff?path=...&staged=true +GET /api/machines/:machineId/files?cwd=...&q=...&kind=...&mode=... +``` + +Compatibility aliases keep using local machine: + +```text +/api/projects... +/api/project-directories... +/api/files... +``` + +### Machine-scoped sessions/auth/activity + +Canonical new routes: + +```text +GET /api/machines/:machineId/activity +GET /api/machines/:machineId/auth... +GET /api/machines/:machineId/sessions?cwd=... +POST /api/machines/:machineId/sessions +GET /api/machines/:machineId/sessions/:sessionId/messages +GET /api/machines/:machineId/sessions/:sessionId/status +POST /api/machines/:machineId/sessions/:sessionId/prompt +POST /api/machines/:machineId/sessions/:sessionId/shell +POST /api/machines/:machineId/sessions/:sessionId/archive +... +``` + +Compatibility aliases keep using local machine: + +```text +/api/activity +/api/auth... +/api/sessions... +``` + +### Machine-scoped WebSockets + +Canonical new routes: + +```text +WS /api/machines/:machineId/events +WS /api/machines/:machineId/sessions/events +WS /api/machines/:machineId/sessions/:sessionId/events +WS /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket +``` + +Compatibility aliases keep using local machine: + +```text +WS /api/events +WS /api/sessions/events +WS /api/sessions/:sessionId/events +WS /api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket +``` + +## Server architecture + +Add these server modules: + +```text +src/server/machines/machineStore.ts +src/server/machines/machineService.ts +src/server/machines/machineClient.ts +src/server/machines/machineRoutes.ts +src/server/machines/machineProxyRoutes.ts +``` + +### `MachineStore` + +Responsibilities: + +- Read/write `$PI_WEB_DATA_DIR/machines.json`. +- Return default local machine if file is missing. +- Validate JSON shape. +- Generate stable IDs for new remote machines. + +### `MachineService` + +Responsibilities: + +- CRUD machine records. +- Prevent deleting `local`. +- Resolve a machine by ID. +- Create an appropriate gateway target: + - local target: existing services and local session daemon client; + - remote target: `RemoteMachineClient`. + +### `RemoteMachineClient` + +Responsibilities: + +- HTTP proxy requests to remote Pi Web base URL. +- WebSocket proxy requests to remote Pi Web base URL. +- Attach auth headers/token when configured. +- Normalize remote failures into useful gateway errors. + +Pseudo-interface: + +```ts +interface MachineClient { + request(method: string, path: string, body?: unknown): Promise<{ + statusCode: number; + headers: Record; + body: string; + }>; + connectWebSocket(path: string): WebSocket; +} +``` + +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 + +1. Extract current route registration to support a path prefix and a target selector where possible. +2. Keep existing local routes untouched initially. +3. Add machine-scoped wrappers: + - If `machineId === "local"`, call current local services. + - Else proxy equivalent path to remote machine without the `/api/machines/:machineId` prefix. + +Example remote mapping: + +```text +GET /api/machines/devbox/projects + -> GET https://devbox.example.ts.net/api/projects + +WS /api/machines/devbox/sessions/abc/events + -> WS wss://devbox.example.ts.net/api/sessions/abc/events +``` + +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. + +## Client architecture + +### State changes + +In `src/client/src/appState.ts`, add: + +```ts +machines: Machine[]; +selectedMachine: Machine | undefined; +isLoadingMachines: boolean; +machineStatuses: Record; +projectsByMachineId: Record; +workspacesByMachineProjectId: Record; +``` + +Consider eventually replacing current flat `projects`, `workspaces`, `sessions` with selected-machine views. For the first pass, keep flat selected lists and reload them when machine changes: + +```ts +projects // projects for selectedMachine +workspaces // workspaces for selectedProject on selectedMachine +sessions // sessions for selectedWorkspace on selectedMachine +``` + +### API client changes + +In `src/client/src/api/clients.ts`, add: + +```ts +machinesApi.machines() +machinesApi.addMachine(...) +machinesApi.deleteMachine(...) +machinesApi.health(machineId) +``` + +Then add machine-scoped variants or a helper: + +```ts +const machinePrefix = (machineId: string) => `/api/machines/${encodeURIComponent(machineId)}`; + +projects(machineId) +addProject(machineId, path, name, create) +workspaces(machineId, projectId) +sessions(machineId, cwd) +... +``` + +Initial compatibility choice: + +- Update controllers to require `selectedMachine?.id ?? "local"`. +- Keep API function names but add `machineId` as the first arg where needed. + +### Controllers + +Add: + +```text +src/client/src/controllers/machineController.ts +``` + +Responsibilities: + +- load machines; +- select machine; +- add/edit/delete machine; +- refresh machine health; +- clear project/workspace/session state on machine switch; +- select default local machine on startup if route has none. + +Modify existing controllers: + +- `ProjectController`: load/add/close projects for selected machine. +- `WorkspaceController`: select project within selected machine. +- `SessionController`: all session operations use selected machine; session sockets become machine-scoped. +- `ActivityController`: activity socket/API becomes machine-scoped or subscribes per selected machine first. +- `FileExplorerController`, `GitController`, terminal calls: use selected machine. + +### Routing + +Extend `src/client/src/route.ts`: + +```ts +interface AppRoute { + machineId: string | undefined; + projectId: string | undefined; + workspaceId: string | undefined; + sessionId: string | undefined; + tool: QualifiedContributionId | undefined; + view: "chat" | QualifiedContributionId | undefined; +} +``` + +Query param: + +```text +?machine=local&project=...&workspace=...&session=... +``` + +Compatibility: + +- Missing `machine` means `local`. +- Current URLs keep working. + +### UI + +Add a machine list above projects in navigation: + +```text +Machines + Local + Dev Box +Projects + ... +Workspaces + ... +Sessions + ... +``` + +New component: + +```text +src/client/src/components/MachineList.ts +``` + +New/updated dialogs: + +- `MachineDialog` or reuse action palette flow: + - Add Machine + - Edit Machine + - Remove Machine + - Refresh Machine Health + +Action palette additions: + +- `Add Machine` +- `Refresh Machine` +- `Open Selected Machine Pi Web` for remote base URL + +Status/labels: + +- Show online/offline marker next to machines. +- Show selected machine in `StatusBar` so users know which host they are controlling. + +## Plugin API impact + +Current plugin stable context has selected workspace/session. Add selected machine once the client model is stable: + +```ts +interface PluginRuntimeState { + selectedMachine?: Machine; + selectedWorkspace?: Workspace; + selectedSession?: unknown; + ... +} +``` + +Potential future contribution type: + +```ts +machineLabels?: MachineLabelContribution[]; +machinePanels?: MachinePanelContribution[]; +``` + +Do **not** add this in phase 1 unless needed. Keep plugin changes minimal: expose `selectedMachine` in state after core UI works. + +## Testing plan + +### Unit tests + +Add tests for: + +```text +src/server/machines/machineStore.test.ts +src/server/machines/machineService.test.ts +src/server/machines/machineClient.test.ts +src/server/machines/machineRoutes.test.ts +src/client/src/controllers/machineController.test.ts +src/client/src/route.test.ts +``` + +Cover: + +- default local machine when no machines file exists; +- add remote machine; +- reject invalid base URLs; +- do not expose token in response; +- cannot delete local machine; +- route read/write with and without `machine`; +- switching machine clears project/workspace/session state; +- missing route machine falls back to local. + +### Integration tests + +Add server route tests with mocked remote machine client: + +- `GET /api/machines/remote/projects` proxies to `/api/projects` on remote. +- Remote non-2xx status passes through reasonably. +- Remote unreachable returns 502 with useful error. +- WebSocket path mapping uses `ws:`/`wss:` correctly. + +### Manual test matrix + +1. Fresh install, no `machines.json`: + - UI loads Local machine. + - Existing project/workspace/session behavior works. + - Existing URLs without `machine` work. + +2. Add local project and start session: + - No regressions in chat, files, git, terminal. + +3. Register remote Pi Web over Tailscale/localhost tunnel: + - Machine appears online. + - Remote projects list loads. + - Remote workspaces list loads. + - Remote sessions list loads. + - Start/select session works. + - WebSocket events stream. + - Terminal socket works. + +4. Remote machine offline: + - UI shows offline/error. + - Selecting machine does not crash app. + - Error messages are clear. + +## Implementation phases + +### Phase 0: Planning and baseline + +- Keep this plan updated. +- Run baseline tests/typecheck before code changes. +- Identify current failures, if any. + +Commands: + +```bash +npm install +npm run typecheck +npm test +``` + +### Phase 1: Local machine registry only + +Deliverable: Pi Web has a Machines list, but only `local` exists and all existing behavior works. + +Tasks: + +- Add `Machine` shared types. +- Add `MachineStore`, `MachineService`, and `/api/machines` routes. +- Add client `machinesApi`. +- Add `MachineController`. +- Add `selectedMachine` to app state. +- Add `MachineList` above `ProjectList`. +- Route supports `?machine=local` but does not require it. +- Existing `/api/projects` routes remain unchanged. + +Acceptance: + +- Fresh UI shows `Local` under Machines. +- Current project/workspace/session workflows unchanged. +- Current URLs continue to work. + +### Phase 2: Machine-scoped local aliases + +Deliverable: machine-scoped APIs work for `local`. + +Tasks: + +- Add `/api/machines/local/projects` etc. wrappers for local services. +- Add `/api/machines/local/sessions...` proxy wrappers to local sessiond. +- Add `/api/machines/local/events` WebSocket wrappers. +- Update client API/controllers to use machine-scoped endpoints. +- Keep compatibility aliases. + +Acceptance: + +- Browser uses `/api/machines/local/...` for normal operation. +- Compatibility aliases still pass tests. + +### Phase 3: Remote HTTP proxy + +Deliverable: remote machines can list projects/workspaces/sessions and perform non-WebSocket actions. + +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 UI for add/remove remote machines. + +Acceptance: + +- Register another running Pi Web by URL. +- List remote projects/workspaces/sessions. +- Start session and send prompt via proxied HTTP. + +### Phase 4: Remote WebSocket proxy + +Deliverable: remote live sessions and terminals work. + +Tasks: + +- Proxy session event WebSockets to remote Pi Web. +- Proxy global events/activity WebSocket for selected machine. +- Proxy terminal socket WebSockets. +- Make `SessionSocket`, `RealtimeSocket`, and `terminalSocket` machine-scoped. + +Acceptance: + +- Remote assistant streaming appears live. +- Remote status/activity updates appear. +- Remote terminals work. + +### Phase 5: UX polish and docs + +Deliverable: feature is usable and explainable. + +Tasks: + +- Machine health indicators. +- Selected machine in status bar. +- Empty states updated from “Add project” to “Select/add machine, then add project”. +- Docs for Tailscale/SSH/reverse-proxy setup. +- Security warnings. +- Plugin state includes `selectedMachine`. + +Acceptance: + +- New users understand local vs remote control. +- Remote errors are actionable. +- Docs explain safe setup. + +## Key files likely touched + +Server: + +```text +src/shared/apiTypes.ts +src/server/app.ts +src/server/machines/* +src/server/sessiond/sessionProxyRoutes.ts +src/server/terminalProxyRoutes.ts +src/server/workspaceExplorerRoutes.ts +src/server/gitRoutes.ts +src/server/storage/projectStore.ts // probably not changed in phase 1 +src/server/projects/projectService.ts // probably not changed in phase 1 +``` + +Client: + +```text +src/client/src/appState.ts +src/client/src/api/clients.ts +src/client/src/api/parsers.ts +src/client/src/api/sockets.ts +src/client/src/api/urls.ts +src/client/src/components/PiWebApp.ts +src/client/src/components/MachineList.ts +src/client/src/components/ProjectDialog.ts // maybe later for machine-aware copy +src/client/src/components/StatusBar.ts +src/client/src/controllers/machineController.ts +src/client/src/controllers/projectController.ts +src/client/src/controllers/workspaceController.ts +src/client/src/controllers/sessionController.ts +src/client/src/controllers/activityController.ts +src/client/src/controllers/fileExplorerController.ts +src/client/src/controllers/gitController.ts +src/client/src/route.ts +src/client/src/sessionSocket.ts +src/client/src/plugins/types.ts // later +``` + +Docs: + +```text +README.md +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? +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? +6. Should machine-scoped remote routes target the remote compatibility aliases forever, or require remote Pi Web to also be machine-aware? +7. How much of this should be proposed upstream in one PR vs several PRs? + +## Suggested first PR scope + +The safest first PR is Phase 1 only: + +> Introduce a first-class `Machine` model with a default local machine and a machine selector UI, without changing remote behavior yet. + +That PR should be easy to review because it preserves all existing runtime behavior and creates the seam for federation. From 0405b384b17d3cce1092c497774f5efc8eae1489 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Mon, 25 May 2026 10:06:29 +0200 Subject: [PATCH 02/10] feat: add local machine registry foundation --- .changeset/synthesized-local-machines.md | 5 + MACHINE_FEDERATION_PLAN.md | 139 +++++++++++++++--- src/client/src/api.ts | 4 +- src/client/src/api/clients.ts | 9 ++ src/client/src/api/parsers.ts | 38 ++++- src/client/src/appState.ts | 10 +- src/client/src/components/MachineList.ts | 45 ++++++ src/client/src/components/PiWebApp.ts | 26 +++- .../src/controllers/machineController.ts | 41 ++++++ src/client/src/route.test.ts | 8 +- src/client/src/route.ts | 4 + src/server/app.test.ts | 18 +++ src/server/app.ts | 6 + src/server/machines/machineRoutes.ts | 44 ++++++ src/server/machines/machineService.test.ts | 55 +++++++ src/server/machines/machineService.ts | 93 ++++++++++++ src/server/machines/machineStore.ts | 132 +++++++++++++++++ src/shared/apiTypes.ts | 24 +++ 18 files changed, 673 insertions(+), 28 deletions(-) create mode 100644 .changeset/synthesized-local-machines.md create mode 100644 src/client/src/components/MachineList.ts create mode 100644 src/client/src/controllers/machineController.ts create mode 100644 src/server/machines/machineRoutes.ts create mode 100644 src/server/machines/machineService.test.ts create mode 100644 src/server/machines/machineService.ts create mode 100644 src/server/machines/machineStore.ts diff --git a/.changeset/synthesized-local-machines.md b/.changeset/synthesized-local-machines.md new file mode 100644 index 0000000..22ac957 --- /dev/null +++ b/.changeset/synthesized-local-machines.md @@ -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. diff --git a/MACHINE_FEDERATION_PLAN.md b/MACHINE_FEDERATION_PLAN.md index e7ef8cd..89989b4 100644 --- a/MACHINE_FEDERATION_PLAN.md +++ b/MACHINE_FEDERATION_PLAN.md @@ -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; + body: string | Buffer | NodeJS.ReadableStream; +} + interface MachineClient { - request(method: string, path: string, body?: unknown): Promise<{ - statusCode: number; - headers: Record; - body: string; - }>; + request(method: string, path: string, body?: unknown): Promise; 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/ + -> /api/ for remote Pi Web HTTP/WebSocket routes + -> / 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? diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 701108a..a6b88e1 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 0026218..275128f 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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, diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 28538be..c2f477d 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { 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, 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, 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") }; diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 409bf73..b37d702 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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; 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: [], diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts new file mode 100644 index 0000000..94b5bc7 --- /dev/null +++ b/src/client/src/components/MachineList.ts @@ -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` +
+

${this.renderHeading()}

+ ${this.collapsed ? null : this.machines.map((machine) => html` +
{ activateSelectableRow(event, () => this.onSelect?.(machine)); }} + @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }} + > +
+ ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl} +
+
+ `)} +
+ `; + } + + private renderHeading() { + if (!this.collapsible) return "Machines"; + const selectedSummary = this.selected?.name ?? "No machine selected"; + const selectedTitle = this.selected?.baseUrl ?? selectedSummary; + return html``; + } + + static override styles = listStyles; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 564f86c..36e48e6 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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 { + { this.toggleNavigationSection("machines"); }} + .onSelect=${(machine: Machine) => this.withChatScrollTransition(async () => { + this.expandNavigationSection("projects"); + await this.machines.selectMachine(machine); + })} + > { + 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 { + 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(); + } +} diff --git a/src/client/src/route.test.ts b/src/client/src/route.test.ts index 24ffd7e..c270bda 100644 --- a/src/client/src/route.test.ts +++ b/src/client/src/route.test.ts @@ -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([]); }); diff --git a/src/client/src/route.ts b/src/client/src/route.ts index bacb35e..0a7f90a 100644 --- a/src/client/src/route.ts +++ b/src/client/src/route.ts @@ -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); diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 57a4375..865659c 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -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", diff --git a/src/server/app.ts b/src/server/app.ts index e35d503..fd516b6 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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; clientDist?: string | false; logger?: FastifyServerOptions["logger"]; @@ -31,6 +34,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebPlugins.manifest()); @@ -42,6 +46,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise 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) => { diff --git a/src/server/machines/machineRoutes.ts b/src/server/machines/machineRoutes.ts new file mode 100644 index 0000000..58db3ef --- /dev/null +++ b/src/server/machines/machineRoutes.ts @@ -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); +} diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts new file mode 100644 index 0000000..22cb9df --- /dev/null +++ b/src/server/machines/machineService.test.ts @@ -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://user@example.test" })).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")); + }); +}); diff --git a/src/server/machines/machineService.ts b/src/server/machines/machineService.ts new file mode 100644 index 0000000..91cc4f4 --- /dev/null +++ b/src/server/machines/machineService.ts @@ -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; +} + +export type UpdateMachineInput = Partial; + +const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z"; + +export class MachineService { + constructor(private readonly store = new MachineStore()) {} + + async list(): Promise { + return [localMachine(), ...(await this.store.list()).map(publicMachine)]; + } + + async get(id: string): Promise { + 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 { + 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 { + if (id === "local") throw new Error("Local machine cannot be changed"); + const patch: Partial> = {}; + 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 { + 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 } { + return { + ...(input.token === undefined ? {} : { token: input.token }), + ...(input.headers === undefined ? {} : { headers: validateHeaders(input.headers) }), + }; +} + +function validateHeaders(value: Record): Record { + return Object.fromEntries(Object.entries(value).map(([key, headerValue]) => { + if (typeof headerValue !== "string") throw new Error("Machine headers must be strings"); + return [key, headerValue]; + })); +} diff --git a/src/server/machines/machineStore.ts b/src/server/machines/machineStore.ts new file mode 100644 index 0000000..2da52be --- /dev/null +++ b/src/server/machines/machineStore.ts @@ -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; + 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 { + return (await this.read()).machines; + } + + async add(input: { name: string; baseUrl: string; token?: string; headers?: Record }): Promise { + 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>): Promise { + 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 { + 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 { + 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 { + 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 | 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 { + 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; +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 3d2ae87..800fe74 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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; From 3270619f245e2c67346fb1ba45ac8019a8a91193 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Mon, 25 May 2026 10:06:54 +0200 Subject: [PATCH 03/10] test: make server tests pass on Windows --- src/server/piWebPluginService.test.ts | 2 +- src/server/storage/projectStore.test.ts | 6 +++--- src/server/workspaces/fileTreeService.test.ts | 18 ++++++++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 42d7d9e..b709be9 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -78,7 +78,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Dev', activate: () => ({ contributions: {} }) };" }, }); await mkdir(join(tempDir, "plugins"), { recursive: true }); - await symlink(pluginDir, join(tempDir, "plugins", "dev"), "dir"); + await symlink(pluginDir, join(tempDir, "plugins", "dev"), process.platform === "win32" ? "junction" : "dir"); const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); diff --git a/src/server/storage/projectStore.test.ts b/src/server/storage/projectStore.test.ts index 9a7bbbb..50e73fc 100644 --- a/src/server/storage/projectStore.test.ts +++ b/src/server/storage/projectStore.test.ts @@ -1,13 +1,13 @@ -import { join } from "node:path"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { projectStorePath } from "./projectStore.js"; describe("projectStorePath", () => { it("uses PI_WEB_DATA_DIR by default", () => { - expect(projectStorePath({ PI_WEB_DATA_DIR: "demo-data" }, "/tmp/pi-web")).toBe(join("/tmp/pi-web", "demo-data", "projects.json")); + expect(projectStorePath({ PI_WEB_DATA_DIR: "demo-data" }, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "demo-data", "projects.json")); }); it("uses PI_WEB_PROJECTS_FILE when configured", () => { - expect(projectStorePath({ PI_WEB_PROJECTS_FILE: "demo/projects.json" }, "/tmp/pi-web")).toBe(join("/tmp/pi-web", "demo/projects.json")); + expect(projectStorePath({ PI_WEB_PROJECTS_FILE: "demo/projects.json" }, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "demo/projects.json")); }); }); diff --git a/src/server/workspaces/fileTreeService.test.ts b/src/server/workspaces/fileTreeService.test.ts index f5d9f18..1e213c8 100644 --- a/src/server/workspaces/fileTreeService.test.ts +++ b/src/server/workspaces/fileTreeService.test.ts @@ -25,7 +25,7 @@ describe("listWorkspaceTree", () => { await mkdir(join(root, "node_modules")); await writeFile(join(root, "b.txt"), "b"); await writeFile(join(root, "a.txt"), "a"); - await symlink(join(root, "a.txt"), join(root, "link.txt")); + const createdSymlink = await trySymlink(join(root, "a.txt"), join(root, "link.txt")); const tree = await listWorkspaceTree(root, undefined); @@ -36,7 +36,7 @@ describe("listWorkspaceTree", () => { ["z-dir", "directory"], ["a.txt", "file"], ["b.txt", "file"], - ["link.txt", "symlink"], + ...(createdSymlink ? [["link.txt", "symlink"]] : []), ]); expect(Date.parse(tree.scannedAt)).not.toBeNaN(); }); @@ -73,3 +73,17 @@ describe("listWorkspaceTree", () => { expect(tree.truncated).toBe(true); }); }); + +async function trySymlink(target: string, path: string): Promise { + try { + await symlink(target, path); + return true; + } catch (error) { + if (isNodeErrorWithCode(error, "EPERM")) return false; + throw error; + } +} + +function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} From 418216b9b7b661a8c4c844f5d3f2c450b9e9db8f Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Mon, 25 May 2026 13:27:45 +0200 Subject: [PATCH 04/10] fix: avoid showing local projects for remote machines --- src/client/src/components/MachineList.ts | 4 ++-- src/client/src/components/PiWebApp.ts | 12 +++++++++++- src/client/src/controllers/machineController.ts | 4 ++-- src/client/src/controllers/projectController.ts | 9 +++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index 94b5bc7..afc33c7 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -21,12 +21,12 @@ export class MachineList extends LitElement {
{ activateSelectableRow(event, () => this.onSelect?.(machine)); }} @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }} >
- ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl} + ${machine.name}${machine.kind === "local" ? "Local Pi Web" : `${machine.baseUrl ?? "Remote Pi Web"} · projects coming soon`}
`)} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 36e48e6..dfdbb78 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -315,6 +315,7 @@ export class PiWebApp extends LitElement { private async restoreRoute(updateUrl: boolean) { const route = readRoute(); + await this.restoreRouteMachine(route, updateUrl); const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file"); const selectedDiffPath = readNamespacedString(queryNamespace("core:workspace.git"), "diff"); const selectedTerminalId = readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal"); @@ -342,8 +343,17 @@ export class PiWebApp extends LitElement { } } + private async restoreRouteMachine(route: AppRoute, updateUrl: boolean): Promise { + const routeMachineId = route.machineId ?? "local"; + if (this.state.selectedMachine?.id === routeMachineId) return; + const machine = this.state.machines.find((candidate) => candidate.id === routeMachineId); + if (machine === undefined) return; + await this.machines.selectMachine(machine, { updateUrl }); + } + private routeMatchesCurrentSelection(route: AppRoute): boolean { - return route.workspaceId !== undefined + return (route.machineId ?? "local") === (this.state.selectedMachine?.id ?? "local") + && route.workspaceId !== undefined && route.workspaceId !== "" && this.state.selectedProject?.id === route.projectId && this.state.selectedWorkspace?.id === route.workspaceId diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index ef0f25a..8cd9466 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -19,7 +19,7 @@ export class MachineController { } } - async selectMachine(machine: Machine): Promise { + async selectMachine(machine: Machine, options: { updateUrl?: boolean | undefined } = {}): Promise { if (this.getState().selectedMachine?.id === machine.id) return; this.setState({ selectedMachine: machine, @@ -35,7 +35,7 @@ export class MachineController { activity: undefined, ...resetWorkspaceScopedState(), }); - this.updateUrl(); + if (options.updateUrl !== false) this.updateUrl(); await this.projects.loadProjects(); } } diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index 21528a6..3b751f0 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -6,6 +6,11 @@ export class ProjectController { constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly workspaces: WorkspaceController) {} async loadProjects() { + const machine = this.getState().selectedMachine; + if (machine?.kind === "remote") { + this.setState({ projects: [], workspacesByProjectId: {}, error: "Remote project browsing is not available yet." }); + return; + } this.setState({ error: "", isLoadingProjects: true }); try { const projects = await api.projects(); @@ -20,6 +25,10 @@ export class ProjectController { } async addProject(path: string, create?: boolean) { + if (this.getState().selectedMachine?.kind === "remote") { + this.setState({ error: "Adding projects to remote machines is not available yet." }); + return; + } if (path.trim() === "") return; try { const project = await api.addProject(path.trim(), undefined, create); From b5f8810eda9616b3e2dfd7111d20a4e75f594b61 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Mon, 25 May 2026 17:01:04 +0200 Subject: [PATCH 05/10] feat: add machine-scoped local API aliases --- .changeset/machine-scoped-local-apis.md | 5 + MACHINE_FEDERATION_PLAN.md | 312 ++++++++++++++++-- src/client/src/api/clients.ts | 92 +++--- src/client/src/api/sockets.test.ts | 40 +++ src/client/src/api/sockets.ts | 20 +- src/client/src/api/urls.ts | 17 +- src/client/src/components/PiWebApp.ts | 8 +- src/client/src/components/ProjectDialog.ts | 3 +- src/client/src/components/PromptEditor.ts | 5 +- src/client/src/components/TerminalPanel.ts | 9 +- .../src/controllers/activityController.ts | 4 +- src/client/src/controllers/authController.ts | 22 +- .../src/controllers/fileExplorerController.ts | 11 +- src/client/src/controllers/gitController.ts | 8 +- .../src/controllers/projectController.ts | 8 +- .../src/controllers/sessionController.ts | 51 +-- src/client/src/controllers/types.ts | 4 + .../src/controllers/workspaceController.ts | 6 +- src/client/src/plugins/core/panels.ts | 4 +- src/client/src/sessionSocket.ts | 21 +- src/server/app.test.ts | 25 ++ src/server/app.ts | 102 +++--- src/server/gitRoutes.ts | 6 +- .../sessiond/sessionProxyRoutes.test.ts | 49 +++ src/server/sessiond/sessionProxyRoutes.ts | 33 +- src/server/terminalProxyRoutes.ts | 20 +- src/server/workspaceExplorerRoutes.ts | 8 +- 27 files changed, 664 insertions(+), 229 deletions(-) create mode 100644 .changeset/machine-scoped-local-apis.md create mode 100644 src/client/src/api/sockets.test.ts create mode 100644 src/server/sessiond/sessionProxyRoutes.test.ts diff --git a/.changeset/machine-scoped-local-apis.md b/.changeset/machine-scoped-local-apis.md new file mode 100644 index 0000000..ef8f8bb --- /dev/null +++ b/.changeset/machine-scoped-local-apis.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add machine-scoped local project, workspace, file, and git API aliases as the next step toward machine federation. diff --git a/MACHINE_FEDERATION_PLAN.md b/MACHINE_FEDERATION_PLAN.md index 89989b4..9345684 100644 --- a/MACHINE_FEDERATION_PLAN.md +++ b/MACHINE_FEDERATION_PLAN.md @@ -654,56 +654,316 @@ Acceptance: ### Phase 2: Machine-scoped local aliases -Deliverable: machine-scoped APIs work for `local`. +Deliverable: machine-scoped APIs work for the synthesized `local` machine, and the browser uses those endpoints for normal local operation. Remote machine rows may still be listed, but remote project/session control remains unavailable until Phase 3/4. -Tasks: +Implementation strategy: -- Add `/api/machines/local/projects` etc. wrappers for local services. -- Add `/api/machines/local/sessions...` proxy wrappers to local sessiond. -- Add `/api/machines/local/events` WebSocket wrappers. -- Update client API/controllers to use machine-scoped endpoints. -- Keep compatibility aliases. +- Prefer extracting existing route registration functions to accept a path prefix when this is low-risk. +- If extraction would be broad, add small wrapper route modules first and refactor later. +- Keep every existing non-machine-scoped route as a compatibility alias for `local`. +- Migrate client calls in route-family slices so regressions are easy to isolate: + 1. projects, project directories, workspaces; + 2. files, file previews, git; + 3. sessions, auth providers, activity HTTP; + 4. local WebSockets and terminals if they are not deferred to Phase 4. + +Local service route mapping: + +```text +GET /api/machines/local/projects + -> ProjectService.list() +POST /api/machines/local/projects + -> ProjectService.add() +DELETE /api/machines/local/projects/:projectId + -> ProjectService.close() + +GET /api/machines/local/project-directories?q=... + -> listDirectorySuggestions() + +GET /api/machines/local/projects/:projectId/workspaces + -> WorkspaceService.list(project) + +GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/tree?path=... + -> listWorkspaceTree() +GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/file?path=... + -> readWorkspaceFile() +GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/file/preview?path=... + -> readWorkspaceImagePreview() streaming response + +GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/git/status + -> current git status route behavior +GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/git/diff?path=...&staged=true + -> current git diff route behavior + +GET /api/machines/local/files?cwd=...&q=...&kind=...&mode=... + -> listFileSuggestions() / listPathSuggestions() +``` + +Local session daemon route mapping: + +```text +GET/POST/etc /api/machines/local/activity + -> local sessiond /activity +GET/POST/etc /api/machines/local/auth + -> local sessiond /auth +GET/POST/etc /api/machines/local/auth/* + -> local sessiond /auth/* +GET/POST/etc /api/machines/local/sessions + -> local sessiond /sessions +GET/POST/etc /api/machines/local/sessions/* + -> local sessiond /sessions/* +``` + +Local WebSocket mapping: + +```text +WS /api/machines/local/events + -> local sessiond /events +WS /api/machines/local/sessions/events + -> local sessiond /sessions/events +WS /api/machines/local/sessions/:sessionId/events + -> local sessiond /sessions/:sessionId/events +WS /api/machines/local/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=... + -> existing local terminal socket behavior with query preserved +``` + +Client API changes: + +```ts +const machinePrefix = (machineId: string) => `/api/machines/${encodeURIComponent(machineId)}`; + +projects(machineId) +addProject(machineId, path, name, create) +closeProject(machineId, projectId) +projectDirectories(machineId, query) +workspaces(machineId, projectId) +workspaceTree(machineId, projectId, workspaceId, path) +workspaceFile(machineId, projectId, workspaceId, path) +workspaceFilePreview(machineId, projectId, workspaceId, path) +gitStatus(machineId, projectId, workspaceId) +gitDiff(machineId, projectId, workspaceId, options) +files(machineId, cwd, query, kind, mode) +sessions(machineId, cwd) +... +``` + +Controller rules: + +- Controllers must derive `machineId` from `selectedMachine?.id ?? "local"`. +- While only local aliases are implemented, remote machines should remain non-operational in the project/session controllers and show clear “remote control coming soon” copy. +- Route restoration must restore machine selection before project/workspace/session selection. +- Cache keys introduced in this phase should be machine-scoped if they can outlive the selected-machine view. Acceptance: -- Browser uses `/api/machines/local/...` for normal operation. -- Compatibility aliases still pass tests. +- Browser network panel shows `/api/machines/local/...` for local project/workspace/session activity. +- Current compatibility routes such as `/api/projects` and `/api/sessions` still pass tests. +- Existing URLs without `machine` continue to restore local projects/workspaces/sessions. +- `?machine=local` is accepted but normal URL writes omit it. +- Selecting any remote row does not show local projects or local sessions under the remote machine. ### Phase 3: Remote HTTP proxy -Deliverable: remote machines can list projects/workspaces/sessions and perform non-WebSocket actions. +Deliverable: remote machines can list projects/workspaces/sessions and perform non-WebSocket actions through the local Pi Web gateway. -Tasks: +Remote proxy route allowlist: -- 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 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. +```text +GET /api/machines/:id/projects +POST /api/machines/:id/projects +DELETE /api/machines/:id/projects/:projectId +GET /api/machines/:id/project-directories?q=... +GET /api/machines/:id/projects/:projectId/workspaces +GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/tree?path=... +GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/file?path=... +GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/file/preview?path=... +GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/git/status +GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/git/diff?path=...&staged=true +GET /api/machines/:id/files?cwd=...&q=...&kind=...&mode=... +GET /api/machines/:id/activity +GET /api/machines/:id/sessions?cwd=... +POST /api/machines/:id/sessions +GET /api/machines/:id/sessions/:sessionId/messages +GET /api/machines/:id/sessions/:sessionId/status +GET /api/machines/:id/sessions/:sessionId/models +POST /api/machines/:id/sessions/:sessionId/model +POST /api/machines/:id/sessions/:sessionId/model/cycle +GET /api/machines/:id/sessions/:sessionId/thinking-levels +POST /api/machines/:id/sessions/:sessionId/thinking-level +POST /api/machines/:id/sessions/:sessionId/thinking-level/cycle +GET /api/machines/:id/sessions/:sessionId/commands +POST /api/machines/:id/sessions/:sessionId/prompt +POST /api/machines/:id/sessions/:sessionId/shell +POST /api/machines/:id/sessions/:sessionId/commands/run +POST /api/machines/:id/sessions/:sessionId/commands/respond +POST /api/machines/:id/sessions/:sessionId/abort +POST /api/machines/:id/sessions/:sessionId/stop +POST /api/machines/:id/sessions/:sessionId/archive +POST /api/machines/:id/sessions/:sessionId/archive-tree +POST /api/machines/:id/sessions/:sessionId/restore +POST /api/machines/:id/sessions/:sessionId/detach-parent +GET /api/machines/:id/auth/providers +POST /api/machines/:id/auth/api-key +POST /api/machines/:id/auth/logout // API-key/logout only if safe for selected provider +``` + +Do not add a catch-all remote proxy in the first remote phase. Any route not explicitly allowlisted should return `404` or `501` with a clear message. + +Remote path mapping: + +```text +/api/machines/:id/?query + -> /api/?query +``` + +Rules: + +- Preserve query strings exactly after the machine prefix is stripped. +- Forward JSON request bodies with `content-type: application/json` unless the original route needs a different explicit content type. +- Stream file preview responses; do not coerce previews into JSON strings. +- Use the same request body limits as the local Fastify API. +- Use bounded timeouts for normal HTTP proxy requests, and shorter timeouts for health checks. + +Remote auth/header policy: + +- `token` means `Authorization: Bearer ` by default. +- `headers` are additional gateway-to-remote headers. +- Never forward browser cookies, browser `Authorization`, or other browser credentials to a remote machine by default. +- Reject or ignore configured header names that affect transport/proxy semantics: + - `host` + - `connection` + - `upgrade` + - `transfer-encoding` + - `content-length` + - `keep-alive` + - `proxy-authenticate` + - `proxy-authorization` + - `te` + - `trailer` +- If both `token` and `headers.authorization` are provided, reject the machine config or require one explicit winner. Prefer rejecting ambiguity. +- Never disable TLS verification by default. +- Do not follow redirects for proxied API requests in v1. + +Remote response header policy: + +- Preserve safe response headers where useful: + - `content-type` + - `content-length` + - `cache-control` + - `last-modified` + - `etag` +- Strip hop-by-hop and credential-bearing headers: + - `connection` + - `transfer-encoding` + - `upgrade` + - `keep-alive` + - `proxy-authenticate` + - `proxy-authorization` + - `set-cookie` +- Normalize remote failures to JSON gateway errors for JSON endpoints. + +Error response contract: + +```json +{ + "error": "Remote machine unavailable", + "machineId": "devbox", + "statusCode": 502, + "detail": "connect ECONNREFUSED 100.64.0.2:8504" +} +``` + +Guidance: + +- Remote DNS/connect/TLS failure: `502`. +- Remote timeout: `504`. +- Unknown machine: `404`. +- Route known but not implemented remotely/gateway-side: preserve remote `404` when it came from remote, use gateway `501` when the gateway intentionally does not support it. +- Avoid leaking configured tokens/headers in errors or logs. + +Health endpoint contract: + +```text +GET /api/machines/:id/health +``` + +- `local` health combines existing Pi Web status and sessiond health where practical. +- Remote health calls `/api/pi-web/status` with a short timeout. +- If the remote is old and lacks `/api/pi-web/status`, fall back to a lightweight `GET /api/projects` or root request only if that fallback is deliberate and tested. +- Responses should include `checkedAt`, `ok`, `status`, and optional component statuses. +- Cache health in memory for a short TTL so selecting machines does not block on repeated offline checks. + +Remote auth provider policy: + +- Gateway-to-remote machine credentials are separate from model-provider credentials. +- API-key provider flows may be proxied after basic remote HTTP proxying works. +- OAuth login/logout remains remote-direct in Phase 3. UI should show “Open remote Pi Web to configure OAuth” rather than proxying callback-sensitive flows. Acceptance: - Register another running Pi Web by URL. -- List remote projects/workspaces/sessions. -- Start session and send prompt via proxied HTTP. +- Health shows online/offline without blocking the UI. +- List remote projects/workspaces/sessions through machine-scoped endpoints. +- Start a remote session and send a prompt via proxied HTTP. +- Remote file tree/file content/git status work. +- Remote file previews stream with correct content type. +- Remote unreachable returns `502`; timeout returns `504`; token/header values never appear in responses or logs. +- Existing local and compatibility routes still pass tests. ### Phase 4: Remote WebSocket proxy -Deliverable: remote live sessions and terminals work. +Deliverable: remote live sessions and terminals work through the local Pi Web gateway. -Tasks: +Remote WebSocket route mapping: -- Proxy session event WebSockets to remote Pi Web. -- Proxy global events/activity WebSocket for selected machine. -- Proxy terminal socket WebSockets. -- Make `SessionSocket`, `RealtimeSocket`, and `terminalSocket` machine-scoped. +```text +WS /api/machines/:id/events + -> /api/events +WS /api/machines/:id/sessions/events + -> /api/sessions/events +WS /api/machines/:id/sessions/:sessionId/events + -> /api/sessions/:sessionId/events +WS /api/machines/:id/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=... + -> /api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=... +``` + +Rules: + +- Convert remote `http:` base URLs to `ws:` and `https:` base URLs to `wss:`. +- Preserve query strings exactly, especially terminal `cols` and `rows`. +- Attach the same gateway-to-remote credentials as HTTP proxying where WebSocket libraries support headers. +- Do not forward browser cookies or browser credentials. +- If upstream connect fails, close the browser WebSocket with a clear close code/reason where possible and log a sanitized gateway error. +- Forward upstream close codes/reasons to the browser when safe. +- Forward browser close to upstream and upstream close to browser. +- Buffer browser messages only while the upstream socket is connecting, with a small max buffer. Drop/close on overflow rather than unbounded buffering. +- Reuse or extend `src/server/webSocketBridge.ts` so buffering/close/error behavior is consistent. +- Add heartbeat/ping behavior only if tests or real remote tunnels show idle sockets are dropped; do not introduce timers without cleanup. + +Client socket API changes: + +```ts +sessionEvents(machineId: string, sessionId: string): WebSocket +globalSessionEvents(machineId: string): WebSocket +realtimeEvents(machineId: string): WebSocket +terminalSocket(machineId: string, projectId: string, workspaceId: string, terminalId: string, initialSize?: TerminalSize): WebSocket +``` + +Controller/socket ownership rules: + +- `SessionSocket` reconnects using the selected machine ID. +- `RealtimeSocket` reconnects when selected machine changes. +- Terminal sockets are scoped to the selected machine and are closed when switching machines/workspaces. +- Activity/status maps use machine-scoped cache keys if data from multiple machines can coexist. Acceptance: - Remote assistant streaming appears live. - Remote status/activity updates appear. -- Remote terminals work. +- Remote terminals work, including initial size query parameters. +- Closing the browser tab/session closes upstream WebSockets. +- Offline remote WebSocket attempts fail visibly without crashing the app. +- Local WebSockets and compatibility aliases still pass tests. ### Phase 5: UX polish and docs diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 275128f..6d86290 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -32,7 +32,9 @@ import { parseWorkspace, parseWorkspaceActivityResponse, } from "./parsers"; -import { gitDiffUrl, messageUrl } from "./urls"; +import { machineGitDiffUrl, messageUrl } from "./urls"; + +const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`; export const piWebApi = { piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse), @@ -45,64 +47,64 @@ export const machinesApi = { }; export const activityApi = { - workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse), + workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse), }; export const projectsApi = { - projects: () => request("/api/projects", arrayOf(parseProject)), - addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }), - closeProject: (projectId: string) => request(`/api/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }), - projectDirectories: (query: string) => request(`/api/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)), + projects: (machineId = "local") => request(`${machinePrefix(machineId)}/projects`, arrayOf(parseProject)), + addProject: (path: string, name?: string, create?: boolean, machineId = "local") => request(`${machinePrefix(machineId)}/projects`, parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }), + closeProject: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }), + projectDirectories: (query: string, machineId = "local") => request(`${machinePrefix(machineId)}/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)), }; export const workspacesApi = { - workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)), - workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse), - workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse), + workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)), + workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse), + workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse), }; export const sessionsApi = { - sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)), - startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }), - messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage), - status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus), - models: (sessionId: string) => request(`/api/sessions/${sessionId}/models`, parseModelSelectionResponse), - setModel: (sessionId: string, provider: string, modelId: string) => request(`/api/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }), - cycleModel: (sessionId: string, direction: "forward" | "backward") => request(`/api/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }), - thinkingLevels: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse), - setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh") => request(`/api/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }), - cycleThinkingLevel: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }), - commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)), - prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }), - shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }), - runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }), - respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }), - abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }), - stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }), - archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }), - archiveWithDescendants: (sessionId: string) => request(`/api/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }), - restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }), - detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }), - authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => { + sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)), + startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }), + messages: (sessionId: string, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(sessionId, options, machineId), parseMessagePage), + status: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/status`, parseSessionStatus), + models: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/models`, parseModelSelectionResponse), + setModel: (sessionId: string, provider: string, modelId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }), + cycleModel: (sessionId: string, direction: "forward" | "backward", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }), + thinkingLevels: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse), + setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }), + cycleThinkingLevel: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }), + commands: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)), + prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }), + shell: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }), + runCommand: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }), + respondToCommand: (sessionId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }), + abort: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }), + stop: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }), + archive: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }), + archiveWithDescendants: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }), + restore: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }), + detachParent: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }), + authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => { const params = new URLSearchParams(); if (options?.mode !== undefined) params.set("mode", options.mode); if (options?.authType !== undefined) params.set("authType", options.authType); const query = params.toString(); - return request(`/api/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse); + return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse); }, - saveApiKey: (providerId: string, key: string) => request("/api/auth/api-key", parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }), - logoutProvider: (providerId: string) => request("/api/auth/logout", parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }), - startOAuthLogin: (providerId: string) => request("/api/auth/oauth", parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }), - oauthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState), - respondOAuthFlow: (flowId: string, requestId: string, value: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }), - cancelOAuthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }), + saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }), + logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }), + startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }), + oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState), + respondOAuthFlow: (flowId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }), + cancelOAuthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }), }; export const terminalsApi = { - terminals: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)), - startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }), - closeTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }), - continueTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }), + terminals: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)), + startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }), + closeTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }), + continueTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }), runTerminalCommand: (origin: string, input: RunTerminalCommandInput) => request(`/api/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }), listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)), getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId), @@ -142,12 +144,12 @@ function isRecord(value: unknown): value is Record { } export const filesApi = { - files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)), + files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path", machineId = "local") => request(`${machinePrefix(machineId)}/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)), }; export const gitApi = { - gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse), - gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse), + gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse), + gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffUrl(machineId, projectId, workspaceId, options), parseGitDiffResponse), }; export const api = { diff --git a/src/client/src/api/sockets.test.ts b/src/client/src/api/sockets.test.ts new file mode 100644 index 0000000..93b47ec --- /dev/null +++ b/src/client/src/api/sockets.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets"; + +const webSocketUrls: string[] = []; + +function FakeWebSocket(url: string): void { + webSocketUrls.push(url); +} + +beforeEach(() => { + webSocketUrls.length = 0; + vi.stubGlobal("WebSocket", FakeWebSocket); + vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("machine-scoped socket urls", () => { + it("defaults session sockets to the local machine scope", () => { + sessionEvents("s1"); + globalSessionEvents(); + realtimeEvents(); + + expect(webSocketUrls).toEqual([ + "wss://pi.example.test/api/machines/local/sessions/s1/events", + "wss://pi.example.test/api/machines/local/sessions/events", + "wss://pi.example.test/api/machines/local/events", + ]); + }); + + it("uses the requested machine scope for terminal sockets", () => { + terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a"); + + expect(webSocketUrls).toEqual([ + "wss://pi.example.test/api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40", + ]); + }); +}); diff --git a/src/client/src/api/sockets.ts b/src/client/src/api/sockets.ts index c328033..40a6214 100644 --- a/src/client/src/api/sockets.ts +++ b/src/client/src/api/sockets.ts @@ -1,18 +1,22 @@ -export function sessionEvents(sessionId: string): WebSocket { - return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`); +export function sessionEvents(sessionId: string, machineId = "local"): WebSocket { + return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${sessionId}/events`); } -export function globalSessionEvents(): WebSocket { - return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`); +export function globalSessionEvents(machineId = "local"): WebSocket { + return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`); } -export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }): WebSocket { +export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket { const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`; - return new WebSocket(`${webSocketBaseUrl()}/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`); + return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`); } -export function realtimeEvents(): WebSocket { - return new WebSocket(`${webSocketBaseUrl()}/api/events`); +export function realtimeEvents(machineId = "local"): WebSocket { + return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`); +} + +function machinePrefix(machineId: string): string { + return `/api/machines/${encodeURIComponent(machineId)}`; } function webSocketBaseUrl(): string { diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index 721735d..8201ac6 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -6,17 +6,26 @@ export function gitDiffUrl(projectId: string, workspaceId: string, options?: { p return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`; } -export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string { +export function machineGitDiffUrl(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string { + const params = new URLSearchParams(); + if (options?.path !== undefined) params.set("path", options.path); + if (options?.staged === true) params.set("staged", "true"); + const query = params.toString(); + return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`; +} + +export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }, machineId = "local"): string { const params = new URLSearchParams(); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.before !== undefined) params.set("before", String(options.before)); const query = params.toString(); - return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`; + return `/api/machines/${encodeURIComponent(machineId)}/sessions/${sessionId}/messages${query ? `?${query}` : ""}`; } -export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string }): string { +export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string { const params = new URLSearchParams(); params.set("path", path); if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt); - return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`; + const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`; + return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index dfdbb78..111c42a 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -14,6 +14,7 @@ import { SessionController } from "../controllers/sessionController"; import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController"; import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; +import { selectedMachineId } from "../controllers/types"; import { RealtimeSocket } from "../sessionSocket"; import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types"; import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme"; @@ -474,6 +475,7 @@ export class PiWebApp extends LitElement { if (workspace !== undefined) void this.refreshActiveTerminals(workspace); void this.refreshWorkspaceActivity(); }, + selectedMachineId(this.state), ); } @@ -501,7 +503,7 @@ export class PiWebApp extends LitElement { private async refreshActiveTerminals(workspace: Workspace): Promise { try { - const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id); + const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, selectedMachineId(this.state)); if (this.state.selectedWorkspace?.id !== workspace.id) return; this.activeTerminalIds.clear(); for (const terminal of terminals) { @@ -1241,7 +1243,7 @@ export class PiWebApp extends LitElement {
${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> + 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} @@ -1251,7 +1253,7 @@ export class PiWebApp extends LitElement { ${this.renderWorkspacePanel()} ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} - ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} + ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} ${this.renderRefreshMenu()} diff --git a/src/client/src/components/ProjectDialog.ts b/src/client/src/components/ProjectDialog.ts index b89a82d..b598844 100644 --- a/src/client/src/components/ProjectDialog.ts +++ b/src/client/src/components/ProjectDialog.ts @@ -7,6 +7,7 @@ import { css } from "lit"; export class ProjectDialog extends LitElement { @property({ attribute: false }) onSubmit?: (path: string, create: boolean) => void; @property({ attribute: false }) onCancel?: () => void; + @property() machineId = "local"; @state() private path = ""; @state() private createMissing = true; @state() private suggestions: FileSuggestion[] = []; @@ -29,7 +30,7 @@ export class ProjectDialog extends LitElement { const requestId = ++this.requestId; this.loading = true; try { - const suggestions = await api.projectDirectories(this.path); + const suggestions = await api.projectDirectories(this.path, this.machineId); if (requestId !== this.requestId) return; this.suggestions = suggestions; this.selected = Math.min(this.selected, Math.max(0, suggestions.length - 1)); diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 78f2581..d42ca52 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -16,6 +16,7 @@ export class PromptEditor extends LitElement { @property({ type: Boolean }) disabled = false; @property() sessionId?: string; @property() cwd?: string; + @property() machineId = "local"; @property({ type: Boolean }) canSteer = false; @property({ type: Boolean }) isCompacting = false; @property({ type: Boolean }) canStop = false; @@ -168,7 +169,7 @@ export class PromptEditor extends LitElement { return; } if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") { - const commands = await api.commands(this.sessionId).catch(emptySlashCommands); + const commands = await api.commands(this.sessionId, this.machineId).catch(emptySlashCommands); if (version !== this.requestVersion) return; this.completions = commands .filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase())) @@ -182,7 +183,7 @@ export class PromptEditor extends LitElement { ...(command.description === undefined ? {} : { description: command.description }), })); } else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") { - const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode).catch(emptyFileSuggestions); + const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode, this.machineId).catch(emptyFileSuggestions); if (version !== this.requestVersion) return; this.completions = files .slice(0, 12) diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 941a093..8c243c5 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -19,6 +19,7 @@ const COMMAND_RUN_POLL_INTERVAL_MS = 1000; @customElement("terminal-panel") export class TerminalPanel extends LitElement { @property({ attribute: false }) workspace: Workspace | undefined; + @property() machineId = "local"; @property({ attribute: false }) selectedTerminalId: string | undefined; @property({ type: Boolean }) autoStart = false; @property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined; @@ -116,7 +117,7 @@ export class TerminalPanel extends LitElement { if (workspace === undefined) return; const shouldAutoStart = this.consumeAutoStart(); const [terminals, commandRuns] = await Promise.all([ - terminalsApi.terminals(workspace.projectId, workspace.id), + terminalsApi.terminals(workspace.projectId, workspace.id, this.machineId), terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }), ]); this.terminals = terminals; @@ -173,7 +174,7 @@ export class TerminalPanel extends LitElement { this.error = undefined; try { const size = this.measureTerminalSize() ?? DEFAULT_TERMINAL_SIZE; - const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, size); + const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, size, this.machineId); this.terminals = [...this.terminals, terminal]; this.selectTerminal(terminal.id); } catch (error) { @@ -185,7 +186,7 @@ export class TerminalPanel extends LitElement { event.stopPropagation(); try { if (this.workspace === undefined) return; - await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id); + await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id, this.machineId); const next = this.terminals.filter((terminal) => terminal.id !== id); this.terminals = next; if (this.selectedId === id || this.selectedTerminalId === id) { @@ -296,7 +297,7 @@ export class TerminalPanel extends LitElement { } private connectSocket(projectId: string, workspaceId: string, terminalId: string, terminal: Terminal, initialSize: TerminalSize | undefined): void { - const socket = terminalSocket(projectId, workspaceId, terminalId, initialSize); + const socket = terminalSocket(projectId, workspaceId, terminalId, initialSize, this.machineId); socket.binaryType = "arraybuffer"; this.socket = socket; socket.addEventListener("open", () => { this.fitAndNotify(); }); diff --git a/src/client/src/controllers/activityController.ts b/src/client/src/controllers/activityController.ts index b20f569..7dc97fd 100644 --- a/src/client/src/controllers/activityController.ts +++ b/src/client/src/controllers/activityController.ts @@ -1,6 +1,6 @@ import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api"; import { isWorkspaceActivityActive } from "../../../shared/activity"; -import type { GetState, SetState } from "./types"; +import { selectedMachineId, type GetState, type SetState } from "./types"; export interface ActivityControllerDependencies { api?: Pick; @@ -14,7 +14,7 @@ export class ActivityController { } async refresh(): Promise { - const snapshot = await this.api.workspaceActivity(); + const snapshot = await this.api.workspaceActivity(selectedMachineId(this.getState())); this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) }); } diff --git a/src/client/src/controllers/authController.ts b/src/client/src/controllers/authController.ts index 20add64..c209cde 100644 --- a/src/client/src/controllers/authController.ts +++ b/src/client/src/controllers/authController.ts @@ -1,5 +1,5 @@ import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api"; -import type { GetState, SetState } from "./types"; +import { selectedMachineId, type GetState, type SetState } from "./types"; export interface AuthControllerDependencies { api?: typeof defaultApi; @@ -43,7 +43,7 @@ export class AuthController { async chooseLoginMethod(authType: AuthType): Promise { try { - const { providers } = await this.api.authProviders({ mode: "login", authType }); + const { providers } = await this.api.authProviders({ mode: "login", authType, machineId: selectedMachineId(this.getState()) }); this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } }); } catch (error) { this.setState({ error: String(error) }); @@ -79,7 +79,7 @@ export class AuthController { delete clean.error; this.setState({ authDialog: { ...clean, saving: true } }); try { - await this.api.saveApiKey(dialog.provider.id, key); + await this.api.saveApiKey(dialog.provider.id, key, selectedMachineId(this.getState())); this.closeDialog(); void this.refreshStatus(); } catch (error) { @@ -89,7 +89,7 @@ export class AuthController { async openLogout(providerId?: string): Promise { try { - const { providers } = await this.api.authProviders({ mode: "logout" }); + const { providers } = await this.api.authProviders({ mode: "logout", machineId: selectedMachineId(this.getState()) }); if (providerId !== undefined && providerId !== "") { const provider = providers.find((candidate) => candidate.id === providerId); if (provider !== undefined) await this.logoutProvider(provider.id); @@ -104,7 +104,7 @@ export class AuthController { async logoutProvider(providerId: string): Promise { try { - await this.api.logoutProvider(providerId); + await this.api.logoutProvider(providerId, selectedMachineId(this.getState())); this.closeDialog(); void this.refreshStatus(); } catch (error) { @@ -130,7 +130,7 @@ export class AuthController { delete clean.error; this.setState({ authDialog: { ...clean, responding: true } }); try { - const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue); + const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue, selectedMachineId(this.getState())); this.updateOAuthFlow(flow); } catch (error) { this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } }); @@ -145,7 +145,7 @@ export class AuthController { } this.stopPolling(); try { - await this.api.cancelOAuthFlow(dialog.flow.flowId); + await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState())); } catch { // Best-effort cancel. The dialog closes either way. } @@ -159,7 +159,7 @@ export class AuthController { private async openLoginProvider(providerId: string): Promise { try { - const { providers } = await this.api.authProviders({ mode: "login" }); + const { providers } = await this.api.authProviders({ mode: "login", machineId: selectedMachineId(this.getState()) }); const exact = providers.filter((provider) => provider.id === providerId); if (exact.length === 0) { this.setState({ error: `Auth provider not found: ${providerId}` }); @@ -180,7 +180,7 @@ export class AuthController { private async startOAuth(provider: AuthProviderOption): Promise { try { - const flow = await this.api.startOAuthLogin(provider.id); + const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState())); this.updateOAuthFlow(flow); this.startPolling(flow.flowId); } catch (error) { @@ -224,7 +224,7 @@ export class AuthController { return; } try { - this.updateOAuthFlow(await this.api.oauthFlow(flowId)); + this.updateOAuthFlow(await this.api.oauthFlow(flowId, selectedMachineId(this.getState()))); } catch (error) { this.stopPolling(); this.setState({ authDialog: { ...dialog, error: String(error) } }); @@ -235,7 +235,7 @@ export class AuthController { const sessionId = this.sessionId(); if (sessionId === undefined) return; try { - this.applyStatus(await this.api.status(sessionId)); + this.applyStatus(await this.api.status(sessionId, selectedMachineId(this.getState()))); } catch { // Status refresh is opportunistic after login completes. } diff --git a/src/client/src/controllers/fileExplorerController.ts b/src/client/src/controllers/fileExplorerController.ts index 6633147..e9dd073 100644 --- a/src/client/src/controllers/fileExplorerController.ts +++ b/src/client/src/controllers/fileExplorerController.ts @@ -1,6 +1,6 @@ import { api } from "../api"; import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs"; -import type { GetState, SetState, UpdateUrl } from "./types"; +import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files"); @@ -12,9 +12,10 @@ export class FileExplorerController { const workspace = this.getState().selectedWorkspace; if (project === undefined || workspace === undefined) return; try { - const root = await api.workspaceTree(project.id, workspace.id); + const machineId = selectedMachineId(this.getState()); + const root = await api.workspaceTree(project.id, workspace.id, "", machineId); const expanded = { ...this.getState().expandedDirs }; - await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path)).entries; })); + await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path, machineId)).entries; })); this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" }); } catch (error) { this.setState({ error: String(error) }); @@ -30,7 +31,7 @@ export class FileExplorerController { return; } try { - const response = await api.workspaceTree(project.id, workspace.id, path); + const response = await api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState())); this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" }); } catch (error) { this.setState({ error: String(error) }); @@ -50,7 +51,7 @@ export class FileExplorerController { if (project === undefined || workspace === undefined) return; this.setState({ selectedFilePath: path, selectedFileContent: undefined }); try { - const content = await api.workspaceFile(project.id, workspace.id, path); + const content = await api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState())); if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" }); } catch (error) { if (this.getState().selectedFilePath !== path) return; diff --git a/src/client/src/controllers/gitController.ts b/src/client/src/controllers/gitController.ts index c1a1409..d2a9182 100644 --- a/src/client/src/controllers/gitController.ts +++ b/src/client/src/controllers/gitController.ts @@ -1,6 +1,6 @@ import { api } from "../api"; import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs"; -import type { GetState, SetState, UpdateUrl } from "./types"; +import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git"); @@ -19,7 +19,7 @@ export class GitController { const workspace = this.getState().selectedWorkspace; if (project === undefined || workspace === undefined) return; try { - const status = await api.gitStatus(project.id, workspace.id); + const status = await api.gitStatus(project.id, workspace.id, selectedMachineId(this.getState())); this.setState({ gitStatus: status, gitStale: false, error: "" }); const selectedDiffPath = this.getState().selectedDiffPath; if (selectedDiffPath !== undefined) { @@ -52,8 +52,8 @@ export class GitController { if (project === undefined || workspace === undefined) return; try { const [selectedDiff, selectedStagedDiff] = await Promise.all([ - api.gitDiff(project.id, workspace.id, { path }), - api.gitDiff(project.id, workspace.id, { path, staged: true }), + api.gitDiff(project.id, workspace.id, { path }, selectedMachineId(this.getState())), + api.gitDiff(project.id, workspace.id, { path, staged: true }, selectedMachineId(this.getState())), ]); this.setState({ selectedDiff, selectedStagedDiff, error: "" }); } catch (error) { diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index 3b751f0..21be000 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -1,5 +1,5 @@ import { api } from "../api"; -import type { GetState, SetState } from "./types"; +import { selectedMachineId, type GetState, type SetState } from "./types"; import type { WorkspaceController } from "./workspaceController"; export class ProjectController { @@ -13,7 +13,7 @@ export class ProjectController { } this.setState({ error: "", isLoadingProjects: true }); try { - const projects = await api.projects(); + const projects = await api.projects(selectedMachineId(this.getState())); const projectIds = new Set(projects.map((project) => project.id)); const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId))); this.setState({ projects, workspacesByProjectId }); @@ -31,7 +31,7 @@ export class ProjectController { } if (path.trim() === "") return; try { - const project = await api.addProject(path.trim(), undefined, create); + const project = await api.addProject(path.trim(), undefined, create, selectedMachineId(this.getState())); const projects = this.getState().projects; this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project], projectDialogOpen: false }); await this.workspaces.selectProject(project); @@ -42,7 +42,7 @@ export class ProjectController { async closeProject(projectId: string) { try { - await api.closeProject(projectId); + await api.closeProject(projectId, selectedMachineId(this.getState())); this.workspaces.forgetProject(projectId); const state = this.getState(); this.setState({ projects: state.projects.filter((p) => p.id !== projectId) }); diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index b8b8918..0f27a12 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -8,12 +8,12 @@ import { isShellInput } from "../inputModes"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; import { isSessionActive } from "../../../shared/activity"; import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; -import type { GetState, SetState, UpdateUrl } from "./types"; +import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; const MESSAGE_PAGE_SIZE = 100; export interface SessionEventSocket { - connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void; + connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void; setHandler(onEvent: (event: SessionUiEvent) => void): void; close(): void; } @@ -82,7 +82,7 @@ export class SessionController { const workspace = this.getState().selectedWorkspace; if (!workspace) return; try { - const session = await this.api.startSession(workspace.path); + const session = await this.api.startSession(workspace.path, selectedMachineId(this.getState())); rememberCachedNewSession(session); const cachedSession = markCachedNewSessionInfo(session); this.setState({ sessions: [cachedSession, ...this.getState().sessions] }); @@ -113,7 +113,7 @@ export class SessionController { }); try { if (session.archived === true) { - const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }); + const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())); if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; const history = this.transcripts.mergeHistory(session.id, page); this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined }); @@ -125,8 +125,9 @@ export class SessionController { session.id, (event) => buffered.push(event), () => { void this.refreshSelectedSession(session.id); }, + selectedMachineId(this.getState()), ); - const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), this.api.status(session.id)]); + const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session.id, selectedMachineId(this.getState()))]); if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; const history = this.transcripts.mergeHistory(session.id, page); const isReceivingPartialStream = status.isStreaming; @@ -152,7 +153,7 @@ export class SessionController { if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return; this.setState({ isLoadingEarlierMessages: true }); try { - const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }); + const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())); if (this.getState().selectedSession?.id !== session.id) return; const history = this.transcripts.mergeHistory(session.id, page); this.setState(history); @@ -170,7 +171,7 @@ export class SessionController { const session = this.getState().selectedSession; if (!session || session.archived === true) return; try { - await this.api.prompt(session.id, text, streamingBehavior); + await this.api.prompt(session.id, text, streamingBehavior, selectedMachineId(this.getState())); this.markCachedNewSessionPersisted(session); } catch (error) { this.setState({ error: String(error) }); @@ -182,7 +183,7 @@ export class SessionController { if (!session || session.archived === true) return; this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); try { - await this.api.shell(session.id, text); + await this.api.shell(session.id, text, selectedMachineId(this.getState())); this.markCachedNewSessionPersisted(session); } catch (error) { this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) }); @@ -194,7 +195,7 @@ export class SessionController { if (!session || session.archived === true) return; this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); try { - this.applyCommandResult(await this.api.runCommand(session.id, text)); + this.applyCommandResult(await this.api.runCommand(session.id, text, selectedMachineId(this.getState()))); this.markCachedNewSessionPersisted(session); } catch (error) { this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) }); @@ -206,7 +207,7 @@ export class SessionController { if (!session) return; this.setState({ commandDialog: undefined }); try { - this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value)); + this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value, selectedMachineId(this.getState()))); } catch (error) { this.setState({ error: String(error) }); } @@ -227,7 +228,7 @@ export class SessionController { return; } try { - await this.api.archive(session.id); + await this.api.archive(session.id, selectedMachineId(this.getState())); const state = this.getState(); const sessions = markSessionArchived(state.sessions, session.id, new Date().toISOString()); const selectionChange = selectionAfterArchivingSession(sessions, state.selectedSession?.id, session.id); @@ -243,7 +244,7 @@ export class SessionController { async archiveSessionWithDescendants(session = this.getState().selectedSession) { if (!session || isCachedNewSessionInfo(session)) return; try { - const response = await this.api.archiveWithDescendants(session.id); + const response = await this.api.archiveWithDescendants(session.id, selectedMachineId(this.getState())); const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id]; const state = this.getState(); const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString()); @@ -259,7 +260,7 @@ export class SessionController { async deleteCachedNewSession(session = this.getState().selectedSession) { if (!isCachedNewSessionInfo(session)) return; - void this.api.stop(session.id).catch(() => { + void this.api.stop(session.id, selectedMachineId(this.getState())).catch(() => { // Best-effort cleanup for browser-cached sessions that may not exist server-side anymore. }); forgetCachedNewSession(session.id); @@ -278,7 +279,7 @@ export class SessionController { async restoreSession(session = this.getState().selectedSession) { if (!session) return; try { - await this.api.restore(session.id); + await this.api.restore(session.id, selectedMachineId(this.getState())); const restored = { ...session }; delete restored.archived; delete restored.archivedAt; @@ -292,7 +293,7 @@ export class SessionController { async detachParent(session = this.getState().selectedSession) { if (session?.parentSessionPath === undefined) return; try { - await this.api.detachParent(session.id); + await this.api.detachParent(session.id, selectedMachineId(this.getState())); const detached = { ...session }; delete detached.parentSessionPath; this.replaceSession(detached); @@ -305,7 +306,7 @@ export class SessionController { const session = this.getState().selectedSession; if (!session || session.archived === true) return []; try { - return (await this.api.models(session.id)).models; + return (await this.api.models(session.id, selectedMachineId(this.getState()))).models; } catch (error) { this.setState({ error: String(error) }); return []; @@ -316,7 +317,7 @@ export class SessionController { const session = this.getState().selectedSession; if (!session || session.archived === true) return; try { - this.applyStatus(await this.api.setModel(session.id, provider, modelId)); + this.applyStatus(await this.api.setModel(session.id, provider, modelId, selectedMachineId(this.getState()))); } catch (error) { this.setState({ error: String(error) }); } @@ -326,7 +327,7 @@ export class SessionController { const session = this.getState().selectedSession; if (!session || session.archived === true) return; try { - this.applyStatus(await this.api.cycleModel(session.id, direction)); + this.applyStatus(await this.api.cycleModel(session.id, direction, selectedMachineId(this.getState()))); } catch (error) { this.setState({ error: String(error) }); } @@ -336,7 +337,7 @@ export class SessionController { const session = this.getState().selectedSession; if (!session || session.archived === true) return []; try { - return (await this.api.thinkingLevels(session.id)).levels; + return (await this.api.thinkingLevels(session.id, selectedMachineId(this.getState()))).levels; } catch (error) { this.setState({ error: String(error) }); return []; @@ -347,7 +348,7 @@ export class SessionController { const session = this.getState().selectedSession; if (!session || session.archived === true) return; try { - this.applyStatus(await this.api.setThinkingLevel(session.id, level)); + this.applyStatus(await this.api.setThinkingLevel(session.id, level, selectedMachineId(this.getState()))); } catch (error) { this.setState({ error: String(error) }); } @@ -357,7 +358,7 @@ export class SessionController { const session = this.getState().selectedSession; if (!session || session.archived === true) return; try { - this.applyStatus(await this.api.cycleThinkingLevel(session.id)); + this.applyStatus(await this.api.cycleThinkingLevel(session.id, selectedMachineId(this.getState()))); } catch (error) { this.setState({ error: String(error) }); } @@ -367,7 +368,7 @@ export class SessionController { const session = this.getState().selectedSession; if (!session) return; try { - await this.api.abort(session.id); + await this.api.abort(session.id, selectedMachineId(this.getState())); } catch (error) { this.setState({ error: String(error) }); } @@ -378,7 +379,7 @@ export class SessionController { if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return; try { this.flushPendingTranscriptEvents(); - const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), this.api.status(sessionId)]); + const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(sessionId, selectedMachineId(this.getState()))]); if (this.getState().selectedSession?.id !== sessionId) return; const history = this.transcripts.mergeHistory(sessionId, page); this.setState({ @@ -403,7 +404,7 @@ export class SessionController { private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise { try { - const replacement = await this.api.startSession(session.cwd); + const replacement = await this.api.startSession(session.cwd, selectedMachineId(this.getState())); rememberCachedNewSession(replacement); moveDraft(session.id, replacement.id); forgetCachedNewSession(session.id); @@ -535,7 +536,7 @@ export class SessionController { private async refreshMessages(sessionId: string) { try { - const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }); + const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())); if (this.getState().selectedSession?.id !== sessionId) return; this.setState(this.transcripts.mergeHistory(sessionId, page)); } catch (error) { diff --git a/src/client/src/controllers/types.ts b/src/client/src/controllers/types.ts index 1f64ccb..2a85bad 100644 --- a/src/client/src/controllers/types.ts +++ b/src/client/src/controllers/types.ts @@ -1,5 +1,9 @@ import type { AppState } from "../appState"; +export function selectedMachineId(state: Pick): string { + return state.selectedMachine?.id ?? "local"; +} + export type GetState = () => AppState; export type SetState = (patch: Partial) => void; export type UpdateUrl = (options?: { replace?: boolean | undefined }) => void; diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index e06064c..ea9e44a 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -1,7 +1,7 @@ import { api as defaultApi, type Project, type Workspace } from "../api"; import { resetWorkspaceScopedState } from "../appState"; import { mergeCachedNewSessions } from "../cachedNewSessions"; -import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types"; +import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types"; import type { SessionController } from "./sessionController"; import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection"; @@ -39,7 +39,7 @@ export class WorkspaceController { this.sessions.clearActiveSession(); this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() }); try { - const workspaces = await this.api.workspaces(project.id); + const workspaces = await api.workspaces(project.id, selectedMachineId(this.getState())); this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false }); const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) }); if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl }); @@ -54,7 +54,7 @@ export class WorkspaceController { this.sessions.clearActiveSession(); this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() }); try { - const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path)); + const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path, selectedMachineId(this.getState()))); this.setState({ sessions }); const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId); if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl }); diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 5ac2514..24214fa 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -86,7 +86,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp

Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}

`; } - const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt }); + const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.state.selectedMachine?.id ?? "local" }); return html`
${file.path}${metadata}
@@ -97,7 +97,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp function renderTerminal(context: WorkspacePanelContext): TemplateResult { loadTerminalPanel(); - return html``; + return html``; } function renderGit(context: WorkspacePanelContext): TemplateResult { diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index a221aa7..c07fc56 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -12,9 +12,11 @@ export class SessionSocket { private shouldReconnect = false; private hasOpened = false; private onReconnect: (() => void) | undefined; + private machineId = "local"; - connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void { + connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void { this.close(); + this.machineId = machineId; this.sessionId = sessionId; this.onEvent = onEvent; this.onReconnect = onReconnect; @@ -35,11 +37,12 @@ export class SessionSocket { this.onEvent = undefined; this.onReconnect = undefined; this.hasOpened = false; + this.machineId = "local"; } private open(): void { if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return; - const socket = sessionEvents(this.sessionId); + const socket = sessionEvents(this.sessionId, this.machineId); this.socket = socket; socket.onopen = () => { this.reconnectDelay = 500; @@ -75,9 +78,11 @@ export class RealtimeSocket { private reconnectTimer?: number; private reconnectDelay = 500; private shouldReconnect = false; + private machineId = "local"; - connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void): void { + connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void, machineId = "local"): void { this.close(); + this.machineId = machineId; this.onEvent = onEvent; this.onOpen = onOpen; this.shouldReconnect = true; @@ -91,11 +96,12 @@ export class RealtimeSocket { this.socket = undefined; this.onEvent = undefined; this.onOpen = undefined; + this.machineId = "local"; } private open(): void { if (!this.shouldReconnect) return; - const socket = realtimeEvents(); + const socket = realtimeEvents(this.machineId); this.socket = socket; socket.onopen = () => { this.reconnectDelay = 500; @@ -129,9 +135,11 @@ export class GlobalSessionSocket { private reconnectTimer?: number; private reconnectDelay = 500; private shouldReconnect = false; + private machineId = "local"; - connect(onEvent: (event: GlobalSessionEvent) => void): void { + connect(onEvent: (event: GlobalSessionEvent) => void, machineId = "local"): void { this.close(); + this.machineId = machineId; this.onEvent = onEvent; this.shouldReconnect = true; this.open(); @@ -143,11 +151,12 @@ export class GlobalSessionSocket { closeSocketQuietly(this.socket); this.socket = undefined; this.onEvent = undefined; + this.machineId = "local"; } private open(): void { if (!this.shouldReconnect) return; - const socket = globalSessionEvents(); + const socket = globalSessionEvents(this.machineId); this.socket = socket; socket.onopen = () => { this.reconnectDelay = 500; diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 865659c..f56c554 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -77,6 +77,31 @@ describe("buildApp", () => { expect(emptyListResponse.json()).toEqual([]); }); + it("serves local session proxy routes through machine-scoped aliases", async () => { + const response = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toHaveProperty("error"); + }); + + it("serves local projects and workspaces through machine-scoped aliases", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/machines/local/projects", + payload: { name: "Machine Local", path: projectDir, create: true }, + }); + expect(addResponse.statusCode).toBe(200); + const project = addResponse.json(); + + const listResponse = await app.inject({ method: "GET", url: "/api/machines/local/projects" }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toEqual([project]); + + const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` }); + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]); + }); + it("serves the PI WEB plugin manifest and plugin assets", async () => { const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); expect(manifestResponse.statusCode).toBe(200); diff --git a/src/server/app.ts b/src/server/app.ts index fd516b6..e02fc9c 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -27,6 +27,56 @@ export interface AppDependencies { logger?: FastifyServerOptions["logger"]; } +function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void { + app.get(`${prefix}/projects`, async () => projects.list()); + + app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => { + try { + return await projects.add(request.body); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.delete<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId`, async (request, reply) => { + try { + await projects.close(request.params.projectId); + return { closed: true }; + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.get<{ Querystring: { q?: string } }>(`${prefix}/project-directories`, async (request, reply) => { + try { + return await listDirectorySuggestions(request.query.q ?? ""); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => { + try { + const project = await projects.requireProject(request.params.projectId); + return await workspaces.list(project); + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); +} + +function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void { + app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>(`${prefix}/files`, async (request, reply) => { + if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); + try { + if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? ""); + return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); +} + export async function buildApp(deps: AppDependencies = {}): Promise { const app = Fastify({ logger: deps.logger ?? true }); await app.register(fastifyWebsocket); @@ -48,56 +98,20 @@ export async function buildApp(deps: AppDependencies = {}): Promise projects.list()); - - app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => { - try { - return await projects.add(request.body); - } catch (error) { - return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); - - app.delete<{ Params: { projectId: string } }>("/api/projects/:projectId", async (request, reply) => { - try { - await projects.close(request.params.projectId); - return { closed: true }; - } catch (error) { - return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); - - app.get<{ Querystring: { q?: string } }>("/api/project-directories", async (request, reply) => { - try { - return await listDirectorySuggestions(request.query.q ?? ""); - } catch (error) { - return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); - - app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces", async (request, reply) => { - try { - const project = await projects.requireProject(request.params.projectId); - return await workspaces.list(project); - } catch (error) { - return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); + registerLocalProjectRoutes(app, projects, workspaces, "/api"); + registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local"); registerSessionProxyRoutes(app); + registerSessionProxyRoutes(app, undefined, "/api/machines/local"); registerWorkspaceExplorerRoutes(app, projects, workspaces); + registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local"); registerGitRoutes(app, projects, workspaces); + registerGitRoutes(app, projects, workspaces, "/api/machines/local"); registerTerminalProxyRoutes(app, projects, workspaces); + registerTerminalProxyRoutes(app, projects, workspaces, undefined, "/api/machines/local"); - app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => { - if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); - try { - if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? ""); - return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); - } catch (error) { - return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); + registerLocalFileSuggestionRoutes(app, "/api"); + registerLocalFileSuggestionRoutes(app, "/api/machines/local"); const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client"); const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client")); diff --git a/src/server/gitRoutes.ts b/src/server/gitRoutes.ts index 0246265..beb7e0a 100644 --- a/src/server/gitRoutes.ts +++ b/src/server/gitRoutes.ts @@ -4,8 +4,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js"; import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; import { gitDiff, gitStatus } from "./git/gitService.js"; -export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void { - app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/status", async (request, reply) => { +export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void { + app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/status`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await gitStatus(context.root); @@ -14,7 +14,7 @@ export function registerGitRoutes(app: FastifyInstance, projects: ProjectService } }); - app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/diff", async (request, reply) => { + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/diff`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" }); diff --git a/src/server/sessiond/sessionProxyRoutes.test.ts b/src/server/sessiond/sessionProxyRoutes.test.ts new file mode 100644 index 0000000..7911741 --- /dev/null +++ b/src/server/sessiond/sessionProxyRoutes.test.ts @@ -0,0 +1,49 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import fastifyWebsocket from "@fastify/websocket"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { registerSessionProxyRoutes } from "./sessionProxyRoutes"; + +let app: FastifyInstance; +let daemon: FakeSessionDaemon; + +beforeEach(async () => { + app = Fastify({ logger: false }); + await app.register(fastifyWebsocket); + daemon = new FakeSessionDaemon(); + registerSessionProxyRoutes(app, daemon, "/api/machines/local"); +}); + +afterEach(async () => { + await app.close(); +}); + +describe("machine-scoped session proxy routes", () => { + it("strips the machine prefix before forwarding session requests", async () => { + const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions?cwd=/repo" }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]); + }); + + it("strips the machine prefix before forwarding auth requests", async () => { + const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]); + }); +}); + +class FakeSessionDaemon { + readonly requests: { method: string; path: string; body: unknown }[] = []; + + request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }> { + this.requests.push({ method, path, body }); + return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }); + } + + connectWebSocket(): never { + throw new Error("not implemented"); + } +} diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index 37789d7..7a64630 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -2,10 +2,15 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import { WebSocket, type RawData } from "ws"; import { SessionDaemonClient } from "./sessionDaemonClient.js"; -export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void { +export interface SessionProxyDaemon { + request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; + connectWebSocket(path: string): WebSocket; +} + +export function registerSessionProxyRoutes(app: FastifyInstance, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void { const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => { try { - const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body); + const upstream = await daemon.request(request.method, stripPrefix(request.url, prefix), request.body); reply.code(upstream.statusCode); const contentType = upstream.headers["content-type"]; if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType); @@ -16,29 +21,31 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se } }; - app.get("/api/sessiond/health", (_request, reply) => proxy({ method: "GET", url: "/api/health" }, reply)); + app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply)); - app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => { + app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => { bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`)); }); - app.get("/api/sessions/events", { websocket: true }, (socket) => { + app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => { bridgeSockets(socket, daemon.connectWebSocket("/sessions/events")); }); - app.get("/api/events", { websocket: true }, (socket) => { + app.get(`${prefix}/events`, { websocket: true }, (socket) => { bridgeSockets(socket, daemon.connectWebSocket("/events")); }); - app.all("/api/activity", (request, reply) => proxy(request, reply)); - app.all("/api/auth", (request, reply) => proxy(request, reply)); - app.all("/api/auth/*", (request, reply) => proxy(request, reply)); - app.all("/api/sessions", (request, reply) => proxy(request, reply)); - app.all("/api/sessions/*", (request, reply) => proxy(request, reply)); + app.all(`${prefix}/activity`, (request, reply) => proxy(request, reply)); + app.all(`${prefix}/auth`, (request, reply) => proxy(request, reply)); + app.all(`${prefix}/auth/*`, (request, reply) => proxy(request, reply)); + app.all(`${prefix}/sessions`, (request, reply) => proxy(request, reply)); + app.all(`${prefix}/sessions/*`, (request, reply) => proxy(request, reply)); } -function stripApiPrefix(url: string): string { - const stripped = url.startsWith("/api") ? url.slice(4) : url; +function stripPrefix(url: string, prefix: string): string { + const path = url.split("?", 1)[0] ?? url; + const query = url.slice(path.length); + const stripped = path.startsWith(prefix) ? `${path.slice(prefix.length)}${query}` : url; return stripped === "" ? "/" : stripped; } diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts index d5e2dee..58c21bd 100644 --- a/src/server/terminalProxyRoutes.ts +++ b/src/server/terminalProxyRoutes.ts @@ -6,8 +6,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js"; import { terminalSizeQuery } from "./terminals/terminalSize.js"; import { bridgeSockets } from "./webSocketBridge.js"; -export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient()): void { - app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => { +export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient(), prefix = "/api"): void { + app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply); @@ -17,7 +17,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => { + app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply); @@ -27,7 +27,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue", async (request, reply) => { + app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue`, async (request, reply) => { try { await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply); @@ -37,7 +37,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId", async (request, reply) => { + app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId`, async (request, reply) => { try { await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply); @@ -47,7 +47,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>("/api/projects/:projectId/workspaces/:workspaceId/terminal-command-runs", async (request, reply) => { + app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminal-command-runs`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "POST", "/terminal-command-runs", { @@ -65,7 +65,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.get<{ Querystring: TerminalCommandRunQuery }>("/api/terminal-command-runs", async (request, reply) => { + app.get<{ Querystring: TerminalCommandRunQuery }>(`${prefix}/terminal-command-runs`, async (request, reply) => { try { return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply); } catch (error) { @@ -74,7 +74,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.post<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId/cancel", async (request, reply) => { + app.post<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId/cancel`, async (request, reply) => { try { return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply); } catch (error) { @@ -83,7 +83,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.get<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId", async (request, reply) => { + app.get<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId`, async (request, reply) => { try { return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply); } catch (error) { @@ -92,7 +92,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket", { websocket: true }, async (socket, request) => { + app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket`, { websocket: true }, async (socket, request) => { try { await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); const sizeQuery = terminalSizeQuery(request.query.cols, request.query.rows); diff --git a/src/server/workspaceExplorerRoutes.ts b/src/server/workspaceExplorerRoutes.ts index 9a793cc..1c76e50 100644 --- a/src/server/workspaceExplorerRoutes.ts +++ b/src/server/workspaceExplorerRoutes.ts @@ -6,8 +6,8 @@ import { listWorkspaceTree } from "./workspaces/fileTreeService.js"; import { readWorkspaceFile } from "./workspaces/fileContentService.js"; import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js"; -export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void { - app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => { +export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void { + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await listWorkspaceTree(context.root, request.query.path); @@ -16,7 +16,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: } }); - app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file", async (request, reply) => { + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await readWorkspaceFile(context.root, request.query.path); @@ -25,7 +25,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: } }); - app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file/preview", async (request, reply) => { + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); const preview = await readWorkspaceImagePreview(context.root, request.query.path); From a142f5ed40b11117bab26c95a1656b90e0e16125 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Mon, 25 May 2026 18:23:16 +0200 Subject: [PATCH 06/10] feat: add machine federation --- .changeset/remote-machine-federation.md | 5 + MACHINE_FEDERATION_PLAN.md | 1050 ----------------- README.md | 22 +- src/client/src/api/clients.ts | 2 + src/client/src/api/parsers.ts | 17 +- src/client/src/cachedNewSessions.test.ts | 25 +- src/client/src/cachedNewSessions.ts | 26 +- src/client/src/components/MachineList.ts | 31 +- src/client/src/components/PiWebApp.ts | 65 +- src/client/src/components/PromptEditor.ts | 26 +- src/client/src/components/StatusBar.ts | 4 +- src/client/src/controllers/authController.ts | 16 +- .../src/controllers/machineController.ts | 53 + .../src/controllers/projectController.ts | 9 - .../src/controllers/sessionController.test.ts | 13 +- .../src/controllers/sessionController.ts | 52 +- src/client/src/controllers/types.ts | 3 +- .../src/controllers/workspaceController.ts | 13 +- src/client/src/machineKeys.ts | 13 + src/client/src/plugins/core/actions.ts | 30 + src/client/src/plugins/registry.test.ts | 4 + src/client/src/plugins/types.ts | 4 + src/server/app.test.ts | 94 +- src/server/app.ts | 3 + src/server/machines/machineClient.ts | 158 +++ src/server/machines/machineProxyRoutes.ts | 149 +++ src/server/machines/machineRoutes.ts | 6 + src/server/machines/machineService.test.ts | 5 + src/server/machines/machineService.ts | 104 +- 29 files changed, 852 insertions(+), 1150 deletions(-) create mode 100644 .changeset/remote-machine-federation.md delete mode 100644 MACHINE_FEDERATION_PLAN.md create mode 100644 src/client/src/machineKeys.ts create mode 100644 src/server/machines/machineClient.ts create mode 100644 src/server/machines/machineProxyRoutes.ts diff --git a/.changeset/remote-machine-federation.md b/.changeset/remote-machine-federation.md new file mode 100644 index 0000000..f2c3c62 --- /dev/null +++ b/.changeset/remote-machine-federation.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add remote machine federation so PI WEB can register trusted remote runtimes and proxy their projects, workspaces, sessions, files, git state, activity, and terminals through the current web server. diff --git a/MACHINE_FEDERATION_PLAN.md b/MACHINE_FEDERATION_PLAN.md deleted file mode 100644 index 9345684..0000000 --- a/MACHINE_FEDERATION_PLAN.md +++ /dev/null @@ -1,1050 +0,0 @@ -# Machine Federation Plan - -Goal: extend Pi Web from the current hierarchy: - -```text -Project -> Workspace -> Session -``` - -to: - -```text -Machine -> Project -> Workspace -> Session -``` - -A machine is a Pi Web runtime endpoint. The local machine is the current Pi Web install. Remote machines are other Pi Web installs reachable over HTTP/WebSocket, ideally through a trusted network/tunnel such as Tailscale, WireGuard, SSH forwarding, or a reverse proxy with auth. - -## Design principles - -1. **Upstreamable, not permanent-fork-only** - - Keep existing behavior working by auto-providing a default `local` machine. - - Keep current non-machine-scoped API routes as compatibility aliases for the local machine. - - Implement in small, reviewable phases. - -2. **Machine is a server-side concept first** - - Do not implement federation only in browser plugins. - - The browser should keep a single origin: the currently opened Pi Web server. - - The local Pi Web server acts as a gateway/proxy to remote Pi Web servers. - -3. **No direct remote browser calls by default** - - Avoid CORS problems and scattered credentials in browser code. - - Proxy HTTP and WebSocket traffic through the local Pi Web server. - -4. **Security explicitness** - - Pi Web is currently documented as trusted-user/trusted-path tooling, not a secure multi-tenant platform. - - Remote machines must be opt-in and should support token/header configuration before being exposed beyond private networks. - -5. **Minimal domain disruption** - - Projects, workspaces, sessions, files, git, terminals, activity, and auth remain owned by each target machine. - - Federation initially aggregates/proxies; it does not replicate remote state locally beyond machine registry and optional health cache. - -## Non-goals for first implementation - -- Multi-user RBAC. -- Public internet exposure guidance beyond warnings and token/private-network support. -- Cross-machine project import/sync. -- Cross-machine worktree management. -- Shared session IDs across machines. Session IDs are unique only within a machine unless namespaced client-side. -- Running remote session daemons directly from the central server. Remote machines should run their own Pi Web. - -## Data model - -Add shared API types in `src/shared/apiTypes.ts`: - -```ts -export type MachineKind = "local" | "remote"; -export type MachineStatus = "unknown" | "online" | "offline" | "error"; - -export interface Machine { - id: string; - name: string; - kind: MachineKind; - baseUrl?: string; // absent for local - createdAt: string; - updatedAt: string; - status?: MachineStatus; // optional summary from health checks - statusMessage?: string; -} - -export interface MachineHealth { - machineId: string; - ok: boolean; - checkedAt: string; - status?: MachineStatus; - web?: PiWebComponentStatus; - sessiond?: PiWebComponentStatus; - error?: string; -} -``` - -Server-only stored record can include fields that should not be echoed casually: - -```ts -interface StoredMachine { - id: string; - name: string; - kind: "local" | "remote"; - baseUrl?: string; - token?: string; - headers?: Record; - createdAt: string; - updatedAt: string; -} -``` - -Initial storage file: - -```text -$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": [ - { - "id": "local", - "name": "Local", - "kind": "local" - } - ] -} -``` - -## API shape - -### Machine registry - -New canonical routes: - -```text -GET /api/machines -POST /api/machines -GET /api/machines/:machineId -PATCH /api/machines/:machineId -DELETE /api/machines/:machineId -GET /api/machines/:machineId/health -``` - -Example create request: - -```json -{ - "name": "Dev Box", - "baseUrl": "https://devbox.example.ts.net", - "token": "optional-token" -} -``` - -Rules: - -- `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 - -Canonical new routes: - -```text -GET /api/machines/:machineId/projects -POST /api/machines/:machineId/projects -DELETE /api/machines/:machineId/projects/:projectId -GET /api/machines/:machineId/project-directories?q=... -GET /api/machines/:machineId/projects/:projectId/workspaces -GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/tree?path=... -GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/file?path=... -GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/file/preview?path=... -GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/git/status -GET /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/git/diff?path=...&staged=true -GET /api/machines/:machineId/files?cwd=...&q=...&kind=...&mode=... -``` - -Compatibility aliases keep using local machine: - -```text -/api/projects... -/api/project-directories... -/api/files... -``` - -### Machine-scoped sessions/auth/activity - -Canonical new routes: - -```text -GET /api/machines/:machineId/activity -GET /api/machines/:machineId/auth... -GET /api/machines/:machineId/sessions?cwd=... -POST /api/machines/:machineId/sessions -GET /api/machines/:machineId/sessions/:sessionId/messages -GET /api/machines/:machineId/sessions/:sessionId/status -POST /api/machines/:machineId/sessions/:sessionId/prompt -POST /api/machines/:machineId/sessions/:sessionId/shell -POST /api/machines/:machineId/sessions/:sessionId/archive -... -``` - -Compatibility aliases keep using local machine: - -```text -/api/activity -/api/auth... -/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: - -```text -WS /api/machines/:machineId/events -WS /api/machines/:machineId/sessions/events -WS /api/machines/:machineId/sessions/:sessionId/events -WS /api/machines/:machineId/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket -``` - -Compatibility aliases keep using local machine: - -```text -WS /api/events -WS /api/sessions/events -WS /api/sessions/:sessionId/events -WS /api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket -``` - -## Server architecture - -Add these server modules: - -```text -src/server/machines/machineStore.ts -src/server/machines/machineService.ts -src/server/machines/machineClient.ts -src/server/machines/machineRoutes.ts -src/server/machines/machineProxyRoutes.ts -``` - -### `MachineStore` - -Responsibilities: - -- 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. - -### `MachineService` - -Responsibilities: - -- 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; - - remote target: `RemoteMachineClient`. - -### `RemoteMachineClient` - -Responsibilities: - -- HTTP proxy requests to remote Pi Web base URL. -- WebSocket proxy requests to remote Pi Web base URL. -- Attach auth headers/token when configured. -- Normalize remote failures into useful gateway errors. - -Pseudo-interface: - -```ts -interface MachineHttpResponse { - statusCode: number; - headers: Record; - body: string | Buffer | NodeJS.ReadableStream; -} - -interface MachineClient { - request(method: string, path: string, body?: unknown): Promise; - 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 - -1. Extract current route registration to support a path prefix and a target selector where possible. -2. Keep existing local routes untouched initially. -3. Add machine-scoped wrappers: - - If `machineId === "local"`, call current local services. - - Else proxy equivalent path to remote machine without the `/api/machines/:machineId` prefix. - -Path translation must be explicit and tested: - -```text -/api/machines/:machineId/ - -> /api/ for remote Pi Web HTTP/WebSocket routes - -> / for local sessiond routes where sessiond expects non-/api paths -``` - -Examples: - -```text -GET /api/machines/devbox/projects - -> GET https://devbox.example.ts.net/api/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 - -In `src/client/src/appState.ts`, add: - -```ts -machines: Machine[]; -selectedMachine: Machine | undefined; -isLoadingMachines: boolean; -machineStatuses: Record; -projectsByMachineId: Record; -workspacesByMachineProjectId: Record; -``` - -Consider eventually replacing current flat `projects`, `workspaces`, `sessions` with selected-machine views. For the first pass, keep flat selected lists and reload them when machine changes: - -```ts -projects // projects for selectedMachine -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: - -```ts -machinesApi.machines() -machinesApi.addMachine(...) -machinesApi.deleteMachine(...) -machinesApi.health(machineId) -``` - -Then add machine-scoped variants or a helper: - -```ts -const machinePrefix = (machineId: string) => `/api/machines/${encodeURIComponent(machineId)}`; - -projects(machineId) -addProject(machineId, path, name, create) -workspaces(machineId, projectId) -sessions(machineId, cwd) -... -``` - -Initial compatibility choice: - -- Update controllers to require `selectedMachine?.id ?? "local"`. -- Keep API function names but add `machineId` as the first arg where needed. - -### Controllers - -Add: - -```text -src/client/src/controllers/machineController.ts -``` - -Responsibilities: - -- load machines; -- select machine; -- add/edit/delete machine; -- refresh machine health; -- clear project/workspace/session state on machine switch; -- select default local machine on startup if route has none. - -Modify existing controllers: - -- `ProjectController`: load/add/close projects for selected machine. -- `WorkspaceController`: select project within selected machine. -- `SessionController`: all session operations use selected machine; session sockets become machine-scoped. -- `ActivityController`: activity socket/API becomes machine-scoped or subscribes per selected machine first. -- `FileExplorerController`, `GitController`, terminal calls: use selected machine. - -### Routing - -Extend `src/client/src/route.ts`: - -```ts -interface AppRoute { - machineId: string | undefined; - projectId: string | undefined; - workspaceId: string | undefined; - sessionId: string | undefined; - tool: QualifiedContributionId | undefined; - view: "chat" | QualifiedContributionId | undefined; -} -``` - -Query param: - -```text -?machine=local&project=...&workspace=...&session=... -``` - -Compatibility: - -- Missing `machine` means `local`. -- Current URLs keep working. - -### UI - -Add a machine list above projects in navigation: - -```text -Machines - Local - Dev Box -Projects - ... -Workspaces - ... -Sessions - ... -``` - -New component: - -```text -src/client/src/components/MachineList.ts -``` - -New/updated dialogs: - -- `MachineDialog` or reuse action palette flow: - - Add Machine - - Edit Machine - - Remove Machine - - Refresh Machine Health - -Action palette additions: - -- `Add Machine` -- `Refresh Machine` -- `Open Selected Machine Pi Web` for remote base URL - -Status/labels: - -- Show online/offline marker next to machines. -- Show selected machine in `StatusBar` so users know which host they are controlling. - -## Plugin API impact - -Current plugin stable context has selected workspace/session. Add selected machine once the client model is stable: - -```ts -interface PluginRuntimeState { - selectedMachine?: Machine; - selectedWorkspace?: Workspace; - selectedSession?: unknown; - ... -} -``` - -Potential future contribution type: - -```ts -machineLabels?: MachineLabelContribution[]; -machinePanels?: MachinePanelContribution[]; -``` - -Do **not** add this in phase 1 unless needed. Keep plugin changes minimal: expose `selectedMachine` in state after core UI works. - -## Testing plan - -### Unit tests - -Add tests for: - -```text -src/server/machines/machineStore.test.ts -src/server/machines/machineService.test.ts -src/server/machines/machineClient.test.ts -src/server/machines/machineRoutes.test.ts -src/client/src/controllers/machineController.test.ts -src/client/src/route.test.ts -``` - -Cover: - -- 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, including username/password, query, and hash components; -- do not expose token in response; -- 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 - -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 - -1. Fresh install, no `machines.json`: - - UI loads Local machine. - - Existing project/workspace/session behavior works. - - Existing URLs without `machine` work. - -2. Add local project and start session: - - No regressions in chat, files, git, terminal. - -3. Register remote Pi Web over Tailscale/localhost tunnel: - - Machine appears online. - - Remote projects list loads. - - Remote workspaces list loads. - - Remote sessions list loads. - - Start/select session works. - - WebSocket events stream. - - Terminal socket works. - -4. Remote machine offline: - - UI shows offline/error. - - Selecting machine does not crash app. - - Error messages are clear. - -## Implementation phases - -### Phase 0: Planning and baseline - -- Keep this plan updated. -- Run baseline tests/typecheck before code changes. -- Identify current failures, if any. - -Commands: - -```bash -npm install -npm run typecheck -npm test -``` - -### Phase 1: Local machine registry only - -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 remote-only `MachineStore`, `MachineService`, and `/api/machines` routes that synthesize `local`. -- Add client `machinesApi`. -- Add `MachineController`. -- Add `selectedMachine` to app state. -- Add `MachineList` above `ProjectList`. -- Route supports `?machine=local` but does not require it. -- Existing `/api/projects` routes remain unchanged. - -Acceptance: - -- Fresh UI shows synthesized `Local` under Machines. -- Current project/workspace/session workflows unchanged. -- Current URLs continue to work. - -### Phase 2: Machine-scoped local aliases - -Deliverable: machine-scoped APIs work for the synthesized `local` machine, and the browser uses those endpoints for normal local operation. Remote machine rows may still be listed, but remote project/session control remains unavailable until Phase 3/4. - -Implementation strategy: - -- Prefer extracting existing route registration functions to accept a path prefix when this is low-risk. -- If extraction would be broad, add small wrapper route modules first and refactor later. -- Keep every existing non-machine-scoped route as a compatibility alias for `local`. -- Migrate client calls in route-family slices so regressions are easy to isolate: - 1. projects, project directories, workspaces; - 2. files, file previews, git; - 3. sessions, auth providers, activity HTTP; - 4. local WebSockets and terminals if they are not deferred to Phase 4. - -Local service route mapping: - -```text -GET /api/machines/local/projects - -> ProjectService.list() -POST /api/machines/local/projects - -> ProjectService.add() -DELETE /api/machines/local/projects/:projectId - -> ProjectService.close() - -GET /api/machines/local/project-directories?q=... - -> listDirectorySuggestions() - -GET /api/machines/local/projects/:projectId/workspaces - -> WorkspaceService.list(project) - -GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/tree?path=... - -> listWorkspaceTree() -GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/file?path=... - -> readWorkspaceFile() -GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/file/preview?path=... - -> readWorkspaceImagePreview() streaming response - -GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/git/status - -> current git status route behavior -GET /api/machines/local/projects/:projectId/workspaces/:workspaceId/git/diff?path=...&staged=true - -> current git diff route behavior - -GET /api/machines/local/files?cwd=...&q=...&kind=...&mode=... - -> listFileSuggestions() / listPathSuggestions() -``` - -Local session daemon route mapping: - -```text -GET/POST/etc /api/machines/local/activity - -> local sessiond /activity -GET/POST/etc /api/machines/local/auth - -> local sessiond /auth -GET/POST/etc /api/machines/local/auth/* - -> local sessiond /auth/* -GET/POST/etc /api/machines/local/sessions - -> local sessiond /sessions -GET/POST/etc /api/machines/local/sessions/* - -> local sessiond /sessions/* -``` - -Local WebSocket mapping: - -```text -WS /api/machines/local/events - -> local sessiond /events -WS /api/machines/local/sessions/events - -> local sessiond /sessions/events -WS /api/machines/local/sessions/:sessionId/events - -> local sessiond /sessions/:sessionId/events -WS /api/machines/local/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=... - -> existing local terminal socket behavior with query preserved -``` - -Client API changes: - -```ts -const machinePrefix = (machineId: string) => `/api/machines/${encodeURIComponent(machineId)}`; - -projects(machineId) -addProject(machineId, path, name, create) -closeProject(machineId, projectId) -projectDirectories(machineId, query) -workspaces(machineId, projectId) -workspaceTree(machineId, projectId, workspaceId, path) -workspaceFile(machineId, projectId, workspaceId, path) -workspaceFilePreview(machineId, projectId, workspaceId, path) -gitStatus(machineId, projectId, workspaceId) -gitDiff(machineId, projectId, workspaceId, options) -files(machineId, cwd, query, kind, mode) -sessions(machineId, cwd) -... -``` - -Controller rules: - -- Controllers must derive `machineId` from `selectedMachine?.id ?? "local"`. -- While only local aliases are implemented, remote machines should remain non-operational in the project/session controllers and show clear “remote control coming soon” copy. -- Route restoration must restore machine selection before project/workspace/session selection. -- Cache keys introduced in this phase should be machine-scoped if they can outlive the selected-machine view. - -Acceptance: - -- Browser network panel shows `/api/machines/local/...` for local project/workspace/session activity. -- Current compatibility routes such as `/api/projects` and `/api/sessions` still pass tests. -- Existing URLs without `machine` continue to restore local projects/workspaces/sessions. -- `?machine=local` is accepted but normal URL writes omit it. -- Selecting any remote row does not show local projects or local sessions under the remote machine. - -### Phase 3: Remote HTTP proxy - -Deliverable: remote machines can list projects/workspaces/sessions and perform non-WebSocket actions through the local Pi Web gateway. - -Remote proxy route allowlist: - -```text -GET /api/machines/:id/projects -POST /api/machines/:id/projects -DELETE /api/machines/:id/projects/:projectId -GET /api/machines/:id/project-directories?q=... -GET /api/machines/:id/projects/:projectId/workspaces -GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/tree?path=... -GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/file?path=... -GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/file/preview?path=... -GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/git/status -GET /api/machines/:id/projects/:projectId/workspaces/:workspaceId/git/diff?path=...&staged=true -GET /api/machines/:id/files?cwd=...&q=...&kind=...&mode=... -GET /api/machines/:id/activity -GET /api/machines/:id/sessions?cwd=... -POST /api/machines/:id/sessions -GET /api/machines/:id/sessions/:sessionId/messages -GET /api/machines/:id/sessions/:sessionId/status -GET /api/machines/:id/sessions/:sessionId/models -POST /api/machines/:id/sessions/:sessionId/model -POST /api/machines/:id/sessions/:sessionId/model/cycle -GET /api/machines/:id/sessions/:sessionId/thinking-levels -POST /api/machines/:id/sessions/:sessionId/thinking-level -POST /api/machines/:id/sessions/:sessionId/thinking-level/cycle -GET /api/machines/:id/sessions/:sessionId/commands -POST /api/machines/:id/sessions/:sessionId/prompt -POST /api/machines/:id/sessions/:sessionId/shell -POST /api/machines/:id/sessions/:sessionId/commands/run -POST /api/machines/:id/sessions/:sessionId/commands/respond -POST /api/machines/:id/sessions/:sessionId/abort -POST /api/machines/:id/sessions/:sessionId/stop -POST /api/machines/:id/sessions/:sessionId/archive -POST /api/machines/:id/sessions/:sessionId/archive-tree -POST /api/machines/:id/sessions/:sessionId/restore -POST /api/machines/:id/sessions/:sessionId/detach-parent -GET /api/machines/:id/auth/providers -POST /api/machines/:id/auth/api-key -POST /api/machines/:id/auth/logout // API-key/logout only if safe for selected provider -``` - -Do not add a catch-all remote proxy in the first remote phase. Any route not explicitly allowlisted should return `404` or `501` with a clear message. - -Remote path mapping: - -```text -/api/machines/:id/?query - -> /api/?query -``` - -Rules: - -- Preserve query strings exactly after the machine prefix is stripped. -- Forward JSON request bodies with `content-type: application/json` unless the original route needs a different explicit content type. -- Stream file preview responses; do not coerce previews into JSON strings. -- Use the same request body limits as the local Fastify API. -- Use bounded timeouts for normal HTTP proxy requests, and shorter timeouts for health checks. - -Remote auth/header policy: - -- `token` means `Authorization: Bearer ` by default. -- `headers` are additional gateway-to-remote headers. -- Never forward browser cookies, browser `Authorization`, or other browser credentials to a remote machine by default. -- Reject or ignore configured header names that affect transport/proxy semantics: - - `host` - - `connection` - - `upgrade` - - `transfer-encoding` - - `content-length` - - `keep-alive` - - `proxy-authenticate` - - `proxy-authorization` - - `te` - - `trailer` -- If both `token` and `headers.authorization` are provided, reject the machine config or require one explicit winner. Prefer rejecting ambiguity. -- Never disable TLS verification by default. -- Do not follow redirects for proxied API requests in v1. - -Remote response header policy: - -- Preserve safe response headers where useful: - - `content-type` - - `content-length` - - `cache-control` - - `last-modified` - - `etag` -- Strip hop-by-hop and credential-bearing headers: - - `connection` - - `transfer-encoding` - - `upgrade` - - `keep-alive` - - `proxy-authenticate` - - `proxy-authorization` - - `set-cookie` -- Normalize remote failures to JSON gateway errors for JSON endpoints. - -Error response contract: - -```json -{ - "error": "Remote machine unavailable", - "machineId": "devbox", - "statusCode": 502, - "detail": "connect ECONNREFUSED 100.64.0.2:8504" -} -``` - -Guidance: - -- Remote DNS/connect/TLS failure: `502`. -- Remote timeout: `504`. -- Unknown machine: `404`. -- Route known but not implemented remotely/gateway-side: preserve remote `404` when it came from remote, use gateway `501` when the gateway intentionally does not support it. -- Avoid leaking configured tokens/headers in errors or logs. - -Health endpoint contract: - -```text -GET /api/machines/:id/health -``` - -- `local` health combines existing Pi Web status and sessiond health where practical. -- Remote health calls `/api/pi-web/status` with a short timeout. -- If the remote is old and lacks `/api/pi-web/status`, fall back to a lightweight `GET /api/projects` or root request only if that fallback is deliberate and tested. -- Responses should include `checkedAt`, `ok`, `status`, and optional component statuses. -- Cache health in memory for a short TTL so selecting machines does not block on repeated offline checks. - -Remote auth provider policy: - -- Gateway-to-remote machine credentials are separate from model-provider credentials. -- API-key provider flows may be proxied after basic remote HTTP proxying works. -- OAuth login/logout remains remote-direct in Phase 3. UI should show “Open remote Pi Web to configure OAuth” rather than proxying callback-sensitive flows. - -Acceptance: - -- Register another running Pi Web by URL. -- Health shows online/offline without blocking the UI. -- List remote projects/workspaces/sessions through machine-scoped endpoints. -- Start a remote session and send a prompt via proxied HTTP. -- Remote file tree/file content/git status work. -- Remote file previews stream with correct content type. -- Remote unreachable returns `502`; timeout returns `504`; token/header values never appear in responses or logs. -- Existing local and compatibility routes still pass tests. - -### Phase 4: Remote WebSocket proxy - -Deliverable: remote live sessions and terminals work through the local Pi Web gateway. - -Remote WebSocket route mapping: - -```text -WS /api/machines/:id/events - -> /api/events -WS /api/machines/:id/sessions/events - -> /api/sessions/events -WS /api/machines/:id/sessions/:sessionId/events - -> /api/sessions/:sessionId/events -WS /api/machines/:id/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=... - -> /api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=... -``` - -Rules: - -- Convert remote `http:` base URLs to `ws:` and `https:` base URLs to `wss:`. -- Preserve query strings exactly, especially terminal `cols` and `rows`. -- Attach the same gateway-to-remote credentials as HTTP proxying where WebSocket libraries support headers. -- Do not forward browser cookies or browser credentials. -- If upstream connect fails, close the browser WebSocket with a clear close code/reason where possible and log a sanitized gateway error. -- Forward upstream close codes/reasons to the browser when safe. -- Forward browser close to upstream and upstream close to browser. -- Buffer browser messages only while the upstream socket is connecting, with a small max buffer. Drop/close on overflow rather than unbounded buffering. -- Reuse or extend `src/server/webSocketBridge.ts` so buffering/close/error behavior is consistent. -- Add heartbeat/ping behavior only if tests or real remote tunnels show idle sockets are dropped; do not introduce timers without cleanup. - -Client socket API changes: - -```ts -sessionEvents(machineId: string, sessionId: string): WebSocket -globalSessionEvents(machineId: string): WebSocket -realtimeEvents(machineId: string): WebSocket -terminalSocket(machineId: string, projectId: string, workspaceId: string, terminalId: string, initialSize?: TerminalSize): WebSocket -``` - -Controller/socket ownership rules: - -- `SessionSocket` reconnects using the selected machine ID. -- `RealtimeSocket` reconnects when selected machine changes. -- Terminal sockets are scoped to the selected machine and are closed when switching machines/workspaces. -- Activity/status maps use machine-scoped cache keys if data from multiple machines can coexist. - -Acceptance: - -- Remote assistant streaming appears live. -- Remote status/activity updates appear. -- Remote terminals work, including initial size query parameters. -- Closing the browser tab/session closes upstream WebSockets. -- Offline remote WebSocket attempts fail visibly without crashing the app. -- Local WebSockets and compatibility aliases still pass tests. - -### Phase 5: UX polish and docs - -Deliverable: feature is usable and explainable. - -Tasks: - -- Machine health indicators. -- Selected machine in status bar. -- Empty states updated from “Add project” to “Select/add machine, then add project”. -- Docs for Tailscale/SSH/reverse-proxy setup. -- Security warnings. -- Plugin state includes `selectedMachine`. - -Acceptance: - -- New users understand local vs remote control. -- Remote errors are actionable. -- Docs explain safe setup. - -## Key files likely touched - -Server: - -```text -src/shared/apiTypes.ts -src/server/app.ts -src/server/machines/* -src/server/sessiond/sessionProxyRoutes.ts -src/server/terminalProxyRoutes.ts -src/server/workspaceExplorerRoutes.ts -src/server/gitRoutes.ts -src/server/storage/projectStore.ts // probably not changed in phase 1 -src/server/projects/projectService.ts // probably not changed in phase 1 -``` - -Client: - -```text -src/client/src/appState.ts -src/client/src/api/clients.ts -src/client/src/api/parsers.ts -src/client/src/api/sockets.ts -src/client/src/api/urls.ts -src/client/src/components/PiWebApp.ts -src/client/src/components/MachineList.ts -src/client/src/components/ProjectDialog.ts // maybe later for machine-aware copy -src/client/src/components/StatusBar.ts -src/client/src/controllers/machineController.ts -src/client/src/controllers/projectController.ts -src/client/src/controllers/workspaceController.ts -src/client/src/controllers/sessionController.ts -src/client/src/controllers/activityController.ts -src/client/src/controllers/fileExplorerController.ts -src/client/src/controllers/gitController.ts -src/client/src/route.ts -src/client/src/sessionSocket.ts -src/client/src/plugins/types.ts // later -``` - -Docs: - -```text -README.md -docs/machines.md or docs/federation.md -``` - -## Open questions - -1. Should remote machine auth be a bearer token, arbitrary headers, or both? -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? -6. Should machine-scoped remote routes target the remote compatibility aliases forever, or require remote Pi Web to also be machine-aware? -7. How much of this should be proposed upstream in one PR vs several PRs? - -## Suggested first PR scope - -The safest first PR is Phase 1 only: - -> Introduce a first-class `Machine` model with a default local machine and a machine selector UI, without changing remote behavior yet. - -That PR should be easy to review because it preserves all existing runtime behavior and creates the seam for federation. diff --git a/README.md b/README.md index 344a195..0ce027f 100644 --- a/README.md +++ b/README.md @@ -34,23 +34,27 @@ PI WEB connects those two worlds. The work stays in the server-side environment ## Core model -PI WEB organizes work into three levels: +PI WEB organizes work into four levels: ```text -Project a folder on the server +Machine a local or remote PI WEB runtime endpoint +Project a folder on that machine Workspace a git worktree, or the project folder for non-git projects Session a chat with Pi Coding Agent running inside a workspace ``` This maps naturally to real development work: -- add a project once; +- select the local machine or another registered PI WEB runtime; +- add a project once on the selected machine; - use worktrees to separate branches, features, experiments, and reviews; - start one or more agent sessions inside each workspace; - leave sessions running even when the browser disconnects or the UI restarts. ## Features +- Add and list local or remote PI WEB machines from the action palette. +- Proxy remote projects, workspaces, files, git state, sessions, and terminals through the currently opened PI WEB server. - Add and list server-side projects. - Discover git worktrees automatically with `git worktree list --porcelain`. - Support non-git folders as single-workspace projects. @@ -92,10 +96,17 @@ The web process serves the API and browser UI. In development it can autoreload PI WEB keeps its own state intentionally small: +- Machines: `~/.pi-web/machines.json` stores only opt-in remote machine records; the local machine is synthesized. - Projects: `~/.pi-web/projects.json` - Workspaces: discovered from git worktrees, not stored -- Sessions and chat history: Pi's default JSONL session storage -- Active session runtimes and WebSockets: memory in the session daemon +- Sessions and chat history: Pi's default JSONL session storage on the selected machine +- Active session runtimes and WebSockets: memory in each selected machine's session daemon + +## Machine federation + +The Machines section lets one PI WEB instance act as a gateway to other PI WEB runtimes. Register a remote machine from **Actions → Add Machine** with the remote PI WEB base URL, for example a Tailscale, WireGuard, SSH tunnel, or trusted reverse-proxy URL. The browser continues talking to the local PI WEB origin; project, workspace, file, git, session, activity, and terminal HTTP/WebSocket traffic is proxied server-to-server. + +Remote model-provider credentials and OAuth state stay on the target machine. API-key provider configuration can be proxied, but OAuth login should be completed by opening the remote PI WEB directly. Register remote machines only when you trust the endpoint and the network path: adding a machine gives this PI WEB server permission to contact that URL with the optional bearer token you configured. ## Plugins @@ -254,6 +265,7 @@ Environment variables: - `PI_WEB_SESSIOND_HOST` — daemon TCP bind host when `PI_WEB_SESSIOND_PORT` is set. Defaults to `127.0.0.1`. - `PI_WEB_SESSIOND_URL` — daemon URL used by the web process when connecting over TCP, for example `http://127.0.0.1:3001`. If you set `PI_WEB_SESSIOND_PORT`, set this for the web process too. - `PI_WEB_PROJECTS_FILE` — optional override for the projects storage JSON file. Defaults to `$PI_WEB_DATA_DIR/projects.json`. +- `PI_WEB_MACHINES_FILE` — optional override for the remote machine registry JSON file. Defaults to `$PI_WEB_DATA_DIR/machines.json`. ## Development services diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 6d86290..ab66a11f 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -15,6 +15,7 @@ import { parseGitDiffResponse, parseGitStatusResponse, parseMachine, + parseMachineHealth, parseMachinesResponse, parseMessagePage, parseModelSelectionResponse, @@ -44,6 +45,7 @@ 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" }), + health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth), }; export const activityApi = { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index c2f477d..b6af6e5 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -1,4 +1,4 @@ -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"; +import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, 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 { return typeof value === "object" && value !== null; @@ -80,6 +80,21 @@ export function parseMachine(value: unknown): Machine { }; } +export function parseMachineHealth(value: unknown): MachineHealth { + const record = requireRecord(value); + const status = optionalMachineStatus(record, "status"); + const error = optionalString(record, "error"); + return { + machineId: requireString(record, "machineId"), + ok: requireBoolean(record, "ok"), + checkedAt: requireString(record, "checkedAt"), + ...(status === undefined ? {} : { status }), + ...(record["web"] === undefined ? {} : { web: parsePiWebComponentStatus(record["web"]) }), + ...(record["sessiond"] === undefined ? {} : { sessiond: parsePiWebComponentStatus(record["sessiond"]) }), + ...(error === undefined ? {} : { error }), + }; +} + function requireMachineKind(record: Record, key: string): MachineKind { const value = requireString(record, key); if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`); diff --git a/src/client/src/cachedNewSessions.test.ts b/src/client/src/cachedNewSessions.test.ts index ed2d5ac..85e235f 100644 --- a/src/client/src/cachedNewSessions.test.ts +++ b/src/client/src/cachedNewSessions.test.ts @@ -44,7 +44,7 @@ describe("cached new sessions", () => { it("stores and reloads new sessions with a browser-cache marker", () => { const storage = new MemoryStorage(); - rememberCachedNewSession(baseSession, storage); + rememberCachedNewSession(baseSession, "local", storage); const cached = loadCachedNewSessions(storage); expect(cached).toHaveLength(1); @@ -54,21 +54,30 @@ describe("cached new sessions", () => { it("merges cached sessions for the selected cwd without duplicating server sessions", () => { const storage = new MemoryStorage(); - rememberCachedNewSession(baseSession, storage); - rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, storage); + rememberCachedNewSession(baseSession, "local", storage); + rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, "local", storage); - expect(mergeCachedNewSessions("/repo", [], storage).map((session) => session.id)).toEqual(["session-1"]); - expect(mergeCachedNewSessions("/repo", [baseSession], storage).map((session) => session.id)).toEqual(["session-1"]); - expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], storage)[0])).toBe(false); + expect(mergeCachedNewSessions("/repo", [], "local", storage).map((session) => session.id)).toEqual(["session-1"]); + expect(mergeCachedNewSessions("/repo", [baseSession], "local", storage).map((session) => session.id)).toEqual(["session-1"]); + expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], "local", storage)[0])).toBe(false); expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]); }); it("forgets cached sessions", () => { const storage = new MemoryStorage(); - rememberCachedNewSession(baseSession, storage); + rememberCachedNewSession(baseSession, "local", storage); - forgetCachedNewSession("session-1", storage); + forgetCachedNewSession("session-1", "local", storage); expect(loadCachedNewSessions(storage)).toEqual([]); }); + + it("keeps browser-cached sessions scoped by machine", () => { + const storage = new MemoryStorage(); + rememberCachedNewSession(baseSession, "local", storage); + rememberCachedNewSession({ ...baseSession, id: "session-2" }, "remote", storage); + + expect(mergeCachedNewSessions("/repo", [], "local", storage).map((session) => session.id)).toEqual(["session-1"]); + expect(mergeCachedNewSessions("/repo", [], "remote", storage).map((session) => session.id)).toEqual(["session-2"]); + }); }); diff --git a/src/client/src/cachedNewSessions.ts b/src/client/src/cachedNewSessions.ts index 1386338..29d8856 100644 --- a/src/client/src/cachedNewSessions.ts +++ b/src/client/src/cachedNewSessions.ts @@ -2,8 +2,9 @@ import type { SessionInfo } from "./api"; const storageKey = "pi-web:cached-new-sessions:v1"; const markerProperty = "browserCachedNew"; +const defaultMachineId = "local"; -export type CachedNewSessionInfo = SessionInfo & { browserCachedNew: true }; +export type CachedNewSessionInfo = SessionInfo & { browserCachedNew: true; machineId: string }; function browserStorage(): Storage | undefined { try { @@ -13,27 +14,27 @@ function browserStorage(): Storage | undefined { } } -export function rememberCachedNewSession(session: SessionInfo, storage = browserStorage()): void { +export function rememberCachedNewSession(session: SessionInfo, machineId = defaultMachineId, storage = browserStorage()): void { if (session.messageCount !== 0 || session.archived === true) return; - const sessions = loadCachedNewSessions(storage).filter((candidate) => candidate.id !== session.id); - saveCachedNewSessions([markCachedNewSessionInfo(session), ...sessions], storage); + const sessions = loadCachedNewSessions(storage).filter((candidate) => candidate.id !== session.id || candidate.machineId !== machineId); + saveCachedNewSessions([markCachedNewSessionInfo(session, machineId), ...sessions], storage); } -export function markCachedNewSessionInfo(session: SessionInfo): CachedNewSessionInfo { - return { ...session, browserCachedNew: true }; +export function markCachedNewSessionInfo(session: SessionInfo, machineId = defaultMachineId): CachedNewSessionInfo { + return { ...session, browserCachedNew: true, machineId }; } -export function forgetCachedNewSession(sessionId: string, storage = browserStorage()): void { - const sessions = loadCachedNewSessions(storage).filter((session) => session.id !== sessionId); +export function forgetCachedNewSession(sessionId: string, machineId = defaultMachineId, storage = browserStorage()): void { + const sessions = loadCachedNewSessions(storage).filter((session) => session.id !== sessionId || session.machineId !== machineId); saveCachedNewSessions(sessions, storage); } -export function mergeCachedNewSessions(cwd: string, sessions: SessionInfo[], storage = browserStorage()): SessionInfo[] { +export function mergeCachedNewSessions(cwd: string, sessions: SessionInfo[], machineId = defaultMachineId, storage = browserStorage()): SessionInfo[] { const sessionIds = new Set(sessions.map((session) => session.id)); const cachedSessions = loadCachedNewSessions(storage); - const retainedCachedSessions = cachedSessions.filter((session) => !sessionIds.has(session.id)); + const retainedCachedSessions = cachedSessions.filter((session) => session.machineId !== machineId || !sessionIds.has(session.id)); if (retainedCachedSessions.length !== cachedSessions.length) saveCachedNewSessions(retainedCachedSessions, storage); - const cached = retainedCachedSessions.filter((session) => session.cwd === cwd); + const cached = retainedCachedSessions.filter((session) => session.machineId === machineId && session.cwd === cwd); return [...cached, ...sessions]; } @@ -53,6 +54,7 @@ export function stripCachedNewSessionMarker(session: SessionInfo): SessionInfo { messageCount: session.messageCount, firstMessage: session.firstMessage, ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }), + ...("machineId" in session && typeof session.machineId === "string" ? { machineId: session.machineId } : { machineId: defaultMachineId }), ...(session.archived === true ? { archived: true } : {}), ...(session.archivedAt === undefined ? {} : { archivedAt: session.archivedAt }), }; @@ -91,6 +93,7 @@ function parseCachedSession(value: unknown): CachedNewSessionInfo[] { if (id === undefined || path === undefined || cwd === undefined || created === undefined || modified === undefined || firstMessage === undefined || messageCount !== 0) return []; const name = optionalStringField(value, "name"); const parentSessionPath = optionalStringField(value, "parentSessionPath"); + const machineId = optionalStringField(value, "machineId") ?? defaultMachineId; return [{ id, path, @@ -101,6 +104,7 @@ function parseCachedSession(value: unknown): CachedNewSessionInfo[] { messageCount, firstMessage, ...(parentSessionPath === undefined ? {} : { parentSessionPath }), + machineId, browserCachedNew: true, }]; } diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index afc33c7..12bbd69 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import type { Machine } from "../api"; +import type { Machine, MachineHealth } from "../api"; import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow"; import { listStyles } from "./shared"; @@ -8,6 +8,7 @@ import { listStyles } from "./shared"; export class MachineList extends LitElement { @property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) selected?: Machine; + @property({ attribute: false }) statuses: Record = {}; @property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsed = false; @property({ attribute: false }) onSelect?: (machine: Machine) => void; @@ -17,19 +18,23 @@ export class MachineList extends LitElement { return html`

${this.renderHeading()}

- ${this.collapsed ? null : this.machines.map((machine) => html` -
{ activateSelectableRow(event, () => this.onSelect?.(machine)); }} - @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }} - > -
- ${machine.name}${machine.kind === "local" ? "Local Pi Web" : `${machine.baseUrl ?? "Remote Pi Web"} · projects coming soon`} + ${this.collapsed ? null : this.machines.map((machine) => { + const status = this.statuses[machine.id]?.status ?? machine.status ?? "unknown"; + const statusLabel = status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown"; + return html` +
{ activateSelectableRow(event, () => this.onSelect?.(machine)); }} + @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }} + > +
+ ${machine.name}${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel} +
-
- `)} + `; + })}
`; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 111c42a..6fa3b97 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -258,6 +258,7 @@ export class PiWebApp extends LitElement { this.state = { ...this.state, ...patch }; this.handleActivityTransition(previous, this.state); this.handleWorkspaceChange(previous, this.state); + this.handleMachineChange(previous, this.state); } private async loadProjectsAndRestoreRoute() { @@ -435,14 +436,18 @@ export class PiWebApp extends LitElement { private rememberSelectedTerminal(terminalId: string | undefined): void { const workspace = this.state.selectedWorkspace; if (workspace === undefined) return; - if (terminalId === undefined) this.terminalSelection.forgetWorkspace(workspace.path); - else this.terminalSelection.rememberTerminal(workspace.path, terminalId); + if (terminalId === undefined) this.terminalSelection.forgetWorkspace(this.terminalWorkspaceKey(workspace)); + else this.terminalSelection.rememberTerminal(this.terminalWorkspaceKey(workspace), terminalId); } private writeSelectedTerminalToUrl(terminalId: string | undefined, options?: { replace?: boolean | undefined }): void { setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", terminalId, options); } + private terminalWorkspaceKey(workspace: Workspace): string { + return `${selectedMachineId(this.state)}:${workspace.path}`; + } + private selectMainView(view: AppState["mainView"]) { if (view !== "navigation" && view !== "chat") { this.openWorkspaceTool(view); @@ -457,7 +462,7 @@ export class PiWebApp extends LitElement { if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id) return; this.terminalAutoStartWorkspaceId = undefined; this.activeTerminalIds.clear(); - const selectedTerminalId = this.routeRestoreInProgress ? this.restoringRouteTerminalId : next.selectedWorkspace === undefined ? undefined : this.terminalSelection.latestTerminalId(next.selectedWorkspace.path); + const selectedTerminalId = this.routeRestoreInProgress ? this.restoringRouteTerminalId : next.selectedWorkspace === undefined ? undefined : this.terminalSelection.latestTerminalId(this.terminalWorkspaceKey(next.selectedWorkspace)); this.setState({ activeTerminalCount: 0, selectedTerminalId }); if (!this.routeRestoreInProgress) this.writeSelectedTerminalToUrl(selectedTerminalId, { replace: true }); if (next.selectedWorkspace === undefined) return; @@ -524,6 +529,15 @@ export class PiWebApp extends LitElement { } } + private handleMachineChange(previous: AppState, next: AppState): void { + if ((previous.selectedMachine?.id ?? "local") === (next.selectedMachine?.id ?? "local")) return; + this.sessions.clearActiveSession(); + this.realtime.close(); + this.connectRealtime(); + this.activeTerminalIds.clear(); + this.git.updatePolling(); + } + private refreshSelectedWorkspaceTool(tool: QualifiedContributionId): void { if (tool === "core:workspace.files") void this.files.refreshFiles(); if (tool === "core:workspace.git") void this.git.refreshGit(); @@ -554,6 +568,7 @@ export class PiWebApp extends LitElement { { this.toggleNavigationSection("machines"); }} @@ -758,6 +773,10 @@ export class PiWebApp extends LitElement { openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, focusPrompt: () => { this.promptEditor?.focusInput(); }, addProject: () => { this.setState({ projectDialogOpen: true }); }, + addMachine: () => this.addMachineFromPrompt(), + refreshSelectedMachine: () => this.machines.refreshMachineHealth(), + removeSelectedMachine: () => this.removeSelectedMachine(), + openSelectedMachine: () => { this.openSelectedMachine(); }, configureAuth: () => this.auth.openLogin(), logoutAuth: () => this.auth.openLogout(), openThemePicker: () => { this.openThemeDialog(); }, @@ -878,6 +897,28 @@ export class PiWebApp extends LitElement { } } + private async addMachineFromPrompt(): Promise { + const name = window.prompt("Machine name", "Dev Box")?.trim(); + if (name === undefined || name === "") return; + const baseUrl = window.prompt("Remote PI WEB base URL", "http://127.0.0.1:8504")?.trim(); + if (baseUrl === undefined || baseUrl === "") return; + const token = window.prompt("Bearer token (optional)", "")?.trim(); + await this.machines.addMachine({ name, baseUrl, ...(token === undefined || token === "" ? {} : { token }) }); + } + + private async removeSelectedMachine(): Promise { + const machine = this.state.selectedMachine; + if (machine === undefined || machine.kind === "local") return; + if (!window.confirm(`Remove ${machine.name}?\n\nThis only removes it from this PI WEB gateway.`)) return; + await this.machines.deleteMachine(machine); + } + + private openSelectedMachine(): void { + const machine = this.state.selectedMachine; + if (machine?.kind !== "remote" || machine.baseUrl === undefined) return; + window.open(machine.baseUrl, "_blank", "noopener,noreferrer"); + } + private runAction(action: AppAction): void { void Promise.resolve() .then(() => action.run()) @@ -1025,9 +1066,11 @@ export class PiWebApp extends LitElement { } private renderContextBar() { + const machine = this.state.selectedMachine; const project = this.state.selectedProject; const workspace = this.state.selectedWorkspace; const session = this.state.selectedSession; + const machineLabel = machineContextLabel(machine); const projectLabel = projectContextLabel(project); const showRefresh = this.shouldShowAppRefreshInContextBar(); const workspaceLabel = workspaceContextLabel(workspace); @@ -1036,6 +1079,12 @@ export class PiWebApp extends LitElement {
+ `; + } + private renderHeading() { if (!this.collapsible) return "Machines"; const selectedSummary = this.selected?.name ?? "No machine selected"; @@ -46,5 +100,44 @@ export class MachineList extends LitElement { return html``; } - static override styles = listStyles; + private toggleMenu(machineId: string, target: EventTarget | null): void { + if (this.openMenuMachineId === machineId) { + this.openMenuMachineId = undefined; + return; + } + this.menuStyle = actionMenuPanelStyle(target); + this.openMenuMachineId = machineId; + } + + private removeMachine(machine: Machine): void { + this.openMenuMachineId = undefined; + void this.onRemove?.(machine); + } + + private handleMachineKeydown(event: KeyboardEvent, machine: Machine): void { + if (event.key === "Escape" && this.openMenuMachineId === machine.id) { + event.preventDefault(); + event.stopPropagation(); + this.openMenuMachineId = undefined; + return; + } + activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); + } + + static override styles = [ + listStyles, + css` + .machine-row.no-actions .action-main { border-radius: 8px; } + .machine-menu-panel button.danger { color: var(--pi-danger); } + .machine-menu-panel button.danger:hover, .machine-menu-panel button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); } + `, + ]; +} + +export function canRemoveMachine(machine: Machine): boolean { + return machine.kind === "remote"; +} + +function machineMenuId(machineId: string): string { + return `machine-menu-${machineId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 8672df9..dc717f2 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -595,6 +595,7 @@ export class PiWebApp extends LitElement { this.mobileNavigation.expand("projects"); await this.machines.selectMachine(machine); })} + .onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }} .projects=${this.state.projects} .selectedProject=${this.state.selectedProject} .workspaceActivities=${this.state.workspaceActivities} @@ -761,7 +762,7 @@ export class PiWebApp extends LitElement { addProject: () => { this.setState({ projectDialogOpen: true }); }, addMachine: () => this.addMachineFromPrompt(), refreshSelectedMachine: () => this.machines.refreshMachineHealth(), - removeSelectedMachine: () => this.removeSelectedMachine(), + removeSelectedMachine: () => this.removeMachine(), openSelectedMachine: () => { this.openSelectedMachine(); }, configureAuth: () => this.auth.openLogin(), logoutAuth: () => this.auth.openLogout(), @@ -901,8 +902,7 @@ export class PiWebApp extends LitElement { await this.machines.addMachine({ name, baseUrl, ...(token === undefined || token === "" ? {} : { token }) }); } - private async removeSelectedMachine(): Promise { - const machine = this.state.selectedMachine; + private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise { if (machine === undefined || machine.kind === "local") return; if (!window.confirm(`Remove ${machine.name}?\n\nThis only removes it from this PI WEB gateway.`)) return; await this.machines.deleteMachine(machine); diff --git a/src/client/src/components/appShell/AppNavigationPanel.test.ts b/src/client/src/components/appShell/AppNavigationPanel.test.ts new file mode 100644 index 0000000..57757c8 --- /dev/null +++ b/src/client/src/components/appShell/AppNavigationPanel.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import type { Machine } from "../../api"; +import { shouldShowMachinesSection } from "./AppNavigationPanel"; + +describe("shouldShowMachinesSection", () => { + it("hides the machines section when there is no machine choice", () => { + expect(shouldShowMachinesSection([])).toBe(false); + expect(shouldShowMachinesSection([machine("local")])).toBe(false); + }); + + it("shows the machines section when there are multiple machines", () => { + expect(shouldShowMachinesSection([machine("local"), machine("remote-a")])).toBe(true); + }); +}); + +function machine(id: string): Machine { + return { + id, + name: id, + kind: id === "local" ? "local" : "remote", + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }; +} diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 853bb74..34ac8d2 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -49,6 +49,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; + @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise; override render() { return html` @@ -59,15 +60,18 @@ export class AppNavigationPanel extends LitElement { - { this.onToggleMachines?.(); }} - .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} - > + ${shouldShowMachinesSection(this.machines) ? html` + { this.onToggleMachines?.(); }} + .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} + .onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)} + > + ` : null} 1; +}