Archived
feat: add machine federation
This commit is contained in:
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>, key: string): MachineKind {
|
||||
const value = requireString(record, key);
|
||||
if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -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<string, MachineHealth> = {};
|
||||
@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`
|
||||
<section>
|
||||
<h2>${this.renderHeading()}</h2>
|
||||
${this.collapsed ? null : this.machines.map((machine) => html`
|
||||
<div
|
||||
class=${`action-row ${this.selected?.id === machine.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
title=${machine.kind === "remote" ? "Remote project browsing is not available yet" : machine.baseUrl ?? machine.name}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : `${machine.baseUrl ?? "Remote Pi Web"} · projects coming soon`}</small>
|
||||
${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`
|
||||
<div
|
||||
class=${`action-row ${this.selected?.id === machine.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
title=${machine.baseUrl ?? machine.name}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine)); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${machine.name}</span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
})}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
<machine-list
|
||||
.machines=${this.state.machines}
|
||||
.selected=${this.state.selectedMachine}
|
||||
.statuses=${this.state.machineStatuses}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("machines")}
|
||||
.onToggleCollapsed=${() => { 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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
<nav class=${this.contextBarClass()} aria-label="Current location">
|
||||
<span class="context-bar-label">Location</span>
|
||||
<ol class="context-items" @scroll=${this.onContextScroll}>
|
||||
<li class="context-item">
|
||||
<button type="button" class=${machine === undefined ? "context-chip empty" : "context-chip"} title=${machineContextTitle(machine)} aria-label=${`Machine: ${machineLabel}. Open machine selection.`} @click=${() => { this.openNavigationSection("machines"); }}>
|
||||
<span class="context-kind">Machine</span>
|
||||
<span class="context-value">${machineLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="context-item">
|
||||
<button type="button" class=${project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(project)} aria-label=${`Project: ${projectLabel}. Open project selection.`} @click=${() => { this.openNavigationSection("projects"); }}>
|
||||
<span class="context-kind">Project</span>
|
||||
@@ -1244,7 +1293,7 @@ export class PiWebApp extends LitElement {
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 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())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
|
||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
|
||||
<status-bar .status=${state.status} .machine=${state.selectedMachine} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
|
||||
@@ -1270,6 +1319,14 @@ function createPluginRegistry(): PluginRegistry {
|
||||
return registry;
|
||||
}
|
||||
|
||||
function machineContextLabel(machine: Machine | undefined): string {
|
||||
return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
|
||||
}
|
||||
|
||||
function machineContextTitle(machine: Machine | undefined): string {
|
||||
return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name;
|
||||
}
|
||||
|
||||
function projectContextLabel(project: Project | undefined): string {
|
||||
return project?.name ?? "No project";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
|
||||
import { inputModeForDraft } from "../inputModes";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { promptEditorStyles, type CompletionItem } from "./shared";
|
||||
import "./AutocompleteMenu";
|
||||
@@ -35,10 +36,13 @@ export class PromptEditor extends LitElement {
|
||||
private readonly readOnlyCompartment = new Compartment();
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>) {
|
||||
if (!changed.has("sessionId")) return;
|
||||
const previousSessionId = changed.get("sessionId");
|
||||
if (previousSessionId !== undefined && previousSessionId !== "") saveDraft(previousSessionId, this.draft);
|
||||
this.draft = this.sessionId !== undefined && this.sessionId !== "" ? loadDraft(this.sessionId) : "";
|
||||
if (!changed.has("sessionId") && !changed.has("machineId")) return;
|
||||
const previousSessionId = changed.has("sessionId") ? changed.get("sessionId") : this.sessionId;
|
||||
const previousMachineId = changed.has("machineId") ? changed.get("machineId") : this.machineId;
|
||||
const previousKey = draftStorageKey(previousMachineId, previousSessionId);
|
||||
if (previousKey !== undefined) saveDraft(previousKey, this.draft);
|
||||
const currentKey = draftStorageKey(this.machineId, this.sessionId);
|
||||
this.draft = currentKey !== undefined ? loadDraft(currentKey) : "";
|
||||
this.completions = [];
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
@@ -49,7 +53,7 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
protected override updated(changed: PropertyValues) {
|
||||
if (changed.has("disabled")) this.updateEditorDisabledState();
|
||||
if (changed.has("draft") || changed.has("sessionId")) this.syncEditorDoc();
|
||||
if (changed.has("draft") || changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -156,7 +160,8 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
private updateDraft(value: string) {
|
||||
this.draft = value;
|
||||
if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft);
|
||||
const key = draftStorageKey(this.machineId, this.sessionId);
|
||||
if (key !== undefined) saveDraft(key, this.draft);
|
||||
void this.refreshCompletions();
|
||||
}
|
||||
|
||||
@@ -279,7 +284,8 @@ export class PromptEditor extends LitElement {
|
||||
const text = this.draft.trim();
|
||||
if (text === "" || this.disabled) return;
|
||||
this.draft = "";
|
||||
if (this.sessionId !== undefined && this.sessionId !== "") clearDraft(this.sessionId);
|
||||
const key = draftStorageKey(this.machineId, this.sessionId);
|
||||
if (key !== undefined) clearDraft(key);
|
||||
this.completions = [];
|
||||
this.onSend?.(text, this.canSteer || this.isCompacting ? streamingBehavior : undefined);
|
||||
}
|
||||
@@ -287,6 +293,12 @@ export class PromptEditor extends LitElement {
|
||||
static override styles = promptEditorStyles;
|
||||
}
|
||||
|
||||
function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined {
|
||||
if (typeof machineId !== "string" || machineId === "") return undefined;
|
||||
if (typeof sessionId !== "string" || sessionId === "") return undefined;
|
||||
return machineSessionKey(machineId, sessionId);
|
||||
}
|
||||
|
||||
function fileInsertText(path: string, pathMode: boolean, quoted: boolean): string {
|
||||
const prefix = pathMode ? "" : "@";
|
||||
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { SessionStatus, Workspace } from "../api";
|
||||
import type { Machine, SessionStatus, Workspace } from "../api";
|
||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||
import { formatCost, formatTokenCount } from "../utils/format";
|
||||
import { statusBarStyles } from "./shared";
|
||||
@@ -9,6 +9,7 @@ import { renderWorkspaceLabel } from "./workspaceLabel";
|
||||
@customElement("status-bar")
|
||||
export class StatusBar extends LitElement {
|
||||
@property({ attribute: false }) status?: SessionStatus;
|
||||
@property({ attribute: false }) machine?: Machine;
|
||||
@property({ attribute: false }) workspace?: Workspace;
|
||||
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
|
||||
|
||||
@@ -24,6 +25,7 @@ export class StatusBar extends LitElement {
|
||||
const tokens = status.tokens;
|
||||
return html`
|
||||
<div class="bar">
|
||||
<span>${this.machine?.name ?? "Local"}</span>
|
||||
<span>${renderWorkspaceLabel(this.workspace?.label ?? "workspace", this.workspaceLabelItems, this.workspace?.path)}</span>
|
||||
<span>↑${formatTokenCount(tokens.input)}</span>
|
||||
<span>↓${formatTokenCount(tokens.output)}</span>
|
||||
|
||||
@@ -92,8 +92,8 @@ export class AuthController {
|
||||
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);
|
||||
else this.setState({ error: `No stored credentials for ${providerId}` });
|
||||
if (provider !== undefined && !this.rejectRemoteOAuth("logout", provider)) await this.logoutProvider(provider.id);
|
||||
else if (provider === undefined) this.setState({ error: `No stored credentials for ${providerId}` });
|
||||
return;
|
||||
}
|
||||
this.setState({ authDialog: { step: "logout", providers } });
|
||||
@@ -103,6 +103,9 @@ export class AuthController {
|
||||
}
|
||||
|
||||
async logoutProvider(providerId: string): Promise<void> {
|
||||
const dialog = this.getState().authDialog;
|
||||
const provider = dialog?.step === "logout" ? dialog.providers.find((candidate) => candidate.id === providerId) : undefined;
|
||||
if (provider !== undefined && this.rejectRemoteOAuth("logout", provider)) return;
|
||||
try {
|
||||
await this.api.logoutProvider(providerId, selectedMachineId(this.getState()));
|
||||
this.closeDialog();
|
||||
@@ -179,6 +182,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
private async startOAuth(provider: AuthProviderOption): Promise<void> {
|
||||
if (this.rejectRemoteOAuth("login", provider)) return;
|
||||
try {
|
||||
const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState()));
|
||||
this.updateOAuthFlow(flow);
|
||||
@@ -188,6 +192,14 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
private rejectRemoteOAuth(action: "login" | "logout", provider: AuthProviderOption): boolean {
|
||||
const machine = this.getState().selectedMachine;
|
||||
if (provider.authType !== "oauth" || machine?.kind !== "remote") return false;
|
||||
const where = machine.baseUrl ?? "that remote PI WEB instance";
|
||||
this.setState({ error: `OAuth ${action} for remote machines must be configured directly on ${where}.` });
|
||||
return true;
|
||||
}
|
||||
|
||||
private updateOAuthFlow(flow: OAuthFlowState): void {
|
||||
if (flow.status === "complete") {
|
||||
this.stopPolling();
|
||||
|
||||
@@ -12,6 +12,7 @@ export class MachineController {
|
||||
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 });
|
||||
void this.refreshMachineHealthFor(machines);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
} finally {
|
||||
@@ -33,9 +34,61 @@ export class MachineController {
|
||||
messagePageTotal: 0,
|
||||
status: undefined,
|
||||
activity: undefined,
|
||||
sessionStatuses: {},
|
||||
sessionActivities: {},
|
||||
workspaceActivities: {},
|
||||
workspacesByProjectId: {},
|
||||
...resetWorkspaceScopedState(),
|
||||
});
|
||||
if (options.updateUrl !== false) this.updateUrl();
|
||||
await this.projects.loadProjects();
|
||||
void this.refreshMachineHealth(machine.id);
|
||||
}
|
||||
|
||||
async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<void> {
|
||||
this.setState({ error: "" });
|
||||
try {
|
||||
const machine = await api.addMachine(input);
|
||||
this.setState({ machines: [...this.getState().machines.filter((candidate) => candidate.id !== machine.id), machine] });
|
||||
await this.selectMachine(machine);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMachine(machine: Machine | undefined = this.getState().selectedMachine): Promise<void> {
|
||||
if (machine === undefined) return;
|
||||
if (machine.kind === "local") {
|
||||
this.setState({ error: "The local machine cannot be removed." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteMachine(machine.id);
|
||||
const machines = this.getState().machines.filter((candidate) => candidate.id !== machine.id);
|
||||
const local = machines.find((candidate) => candidate.id === "local") ?? machines[0];
|
||||
this.setState({ machines, machineStatuses: omitKey(this.getState().machineStatuses, machine.id) });
|
||||
if (this.getState().selectedMachine?.id === machine.id && local !== undefined) await this.selectMachine(local);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async refreshMachineHealth(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
|
||||
try {
|
||||
const health = await api.health(machineId);
|
||||
this.setState({ machineStatuses: { ...this.getState().machineStatuses, [health.machineId]: health } });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshMachineHealthFor(machines: Machine[]): Promise<void> {
|
||||
const results = await Promise.allSettled(machines.map((machine) => api.health(machine.id)));
|
||||
const health = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));
|
||||
if (Object.keys(health).length > 0) this.setState({ machineStatuses: { ...this.getState().machineStatuses, ...health } });
|
||||
}
|
||||
}
|
||||
|
||||
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
|
||||
return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit));
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ 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(selectedMachineId(this.getState()));
|
||||
@@ -25,10 +20,6 @@ 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, selectedMachineId(this.getState()));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { api as defaultApi, type MessagePage, type SessionActivity, type SessionInfo, type SessionStatus, type Workspace } from "../api";
|
||||
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { SessionController, type SessionEventSocket } from "./sessionController";
|
||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||
@@ -170,7 +171,7 @@ describe("SessionController", () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
rememberCachedNewSession(oldSession);
|
||||
saveDraft(oldSession.id, "draft text");
|
||||
saveDraft(sessionKey(oldSession.id), "draft text");
|
||||
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
@@ -197,8 +198,8 @@ describe("SessionController", () => {
|
||||
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]);
|
||||
expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]);
|
||||
expect(loadDraft(oldSession.id)).toBe("");
|
||||
expect(loadDraft(replacementSession.id)).toBe("draft text");
|
||||
expect(loadDraft(sessionKey(oldSession.id))).toBe("");
|
||||
expect(loadDraft(sessionKey(replacementSession.id))).toBe("draft text");
|
||||
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]);
|
||||
expect(urlUpdates).toEqual([{ replace: true }]);
|
||||
});
|
||||
@@ -232,7 +233,7 @@ describe("SessionController", () => {
|
||||
await controller.respondToCommand("r1", "m1");
|
||||
|
||||
expect(state.commandDialog).toBeUndefined();
|
||||
expect(loadDraft(replacementSession.id)).toBe("fork me");
|
||||
expect(loadDraft(sessionKey(replacementSession.id))).toBe("fork me");
|
||||
});
|
||||
|
||||
it("forgets the selected active session when archiving leaves only archived sessions", async () => {
|
||||
@@ -315,3 +316,7 @@ describe("SessionController", () => {
|
||||
expect(urlUpdates).toEqual([undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
function sessionKey(sessionId: string): string {
|
||||
return machineSessionKey("local", sessionId);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api as defaultApi, type CommandResult, type SessionActivity, type Sessi
|
||||
import type { AppState } from "../appState";
|
||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||
import { textMessage } from "../chatMessages";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||
import { isShellInput } from "../inputModes";
|
||||
@@ -67,7 +68,7 @@ export class SessionController {
|
||||
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
|
||||
const state = this.getState();
|
||||
const cwd = state.selectedSession?.cwd ?? state.selectedWorkspace?.path;
|
||||
if (options?.forgetRememberedSelection === true && cwd !== undefined) this.sessionSelection.forgetWorkspace(cwd);
|
||||
if (options?.forgetRememberedSelection === true && cwd !== undefined) this.sessionSelection.forgetWorkspace(this.workspaceSelectionKey(cwd));
|
||||
this.clearActiveSession();
|
||||
if (options?.updateUrl !== false) this.updateUrl();
|
||||
}
|
||||
@@ -82,9 +83,10 @@ export class SessionController {
|
||||
const workspace = this.getState().selectedWorkspace;
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const session = await this.api.startSession(workspace.path, selectedMachineId(this.getState()));
|
||||
rememberCachedNewSession(session);
|
||||
const cachedSession = markCachedNewSessionInfo(session);
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const session = await this.api.startSession(workspace.path, machineId);
|
||||
rememberCachedNewSession(session, machineId);
|
||||
const cachedSession = markCachedNewSessionInfo(session, machineId);
|
||||
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
|
||||
await this.selectSession(cachedSession);
|
||||
} catch (error) {
|
||||
@@ -93,16 +95,17 @@ export class SessionController {
|
||||
}
|
||||
|
||||
preferredSession(cwd: string, sessions: SessionInfo[], targetSessionId: string | undefined): SessionInfo | undefined {
|
||||
return selectPreferredSession(sessions, { targetSessionId, latestSessionId: this.sessionSelection.latestSessionId(cwd) });
|
||||
return selectPreferredSession(sessions, { targetSessionId, latestSessionId: this.sessionSelection.latestSessionId(this.workspaceSelectionKey(cwd)) });
|
||||
}
|
||||
|
||||
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
|
||||
this.sessionSelection.rememberSession(session);
|
||||
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
|
||||
const seq = ++this.selectionSeq;
|
||||
this.socket.close();
|
||||
this.catchupStreamSessionId = undefined;
|
||||
this.clearPendingTranscriptEvents();
|
||||
const cached = this.transcripts.cachedView(session.id);
|
||||
const transcriptKey = this.sessionCacheKey(session.id);
|
||||
const cached = this.transcripts.cachedView(transcriptKey);
|
||||
this.setState({
|
||||
selectedSession: session,
|
||||
...cached,
|
||||
@@ -115,7 +118,7 @@ export class SessionController {
|
||||
if (session.archived === true) {
|
||||
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);
|
||||
const history = this.transcripts.mergeHistory(transcriptKey, page);
|
||||
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||
if (options?.updateUrl !== false) this.updateUrl();
|
||||
return;
|
||||
@@ -129,7 +132,7 @@ export class SessionController {
|
||||
);
|
||||
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 history = this.transcripts.mergeHistory(transcriptKey, page);
|
||||
const isReceivingPartialStream = status.isStreaming;
|
||||
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
|
||||
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] });
|
||||
@@ -155,7 +158,7 @@ export class SessionController {
|
||||
try {
|
||||
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);
|
||||
const history = this.transcripts.mergeHistory(this.sessionCacheKey(session.id), page);
|
||||
this.setState(history);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
@@ -263,8 +266,8 @@ export class SessionController {
|
||||
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);
|
||||
clearDraft(session.id);
|
||||
forgetCachedNewSession(session.id, selectedMachineId(this.getState()));
|
||||
clearDraft(this.sessionCacheKey(session.id));
|
||||
const sessions = this.getState().sessions.filter((candidate) => candidate.id !== session.id);
|
||||
this.setState({ sessions });
|
||||
if (this.getState().selectedSession?.id !== session.id) return;
|
||||
@@ -381,7 +384,7 @@ export class SessionController {
|
||||
this.flushPendingTranscriptEvents();
|
||||
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);
|
||||
const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page);
|
||||
this.setState({
|
||||
...history,
|
||||
status,
|
||||
@@ -394,6 +397,14 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private sessionCacheKey(sessionId: string): string {
|
||||
return machineSessionKey(selectedMachineId(this.getState()), sessionId);
|
||||
}
|
||||
|
||||
private workspaceSelectionKey(cwd: string): string {
|
||||
return `${selectedMachineId(this.getState())}:${cwd}`;
|
||||
}
|
||||
|
||||
private replaceSession(session: SessionInfo) {
|
||||
const current = this.getState().selectedSession;
|
||||
this.setState({
|
||||
@@ -404,11 +415,12 @@ export class SessionController {
|
||||
|
||||
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
|
||||
try {
|
||||
const replacement = await this.api.startSession(session.cwd, selectedMachineId(this.getState()));
|
||||
rememberCachedNewSession(replacement);
|
||||
moveDraft(session.id, replacement.id);
|
||||
forgetCachedNewSession(session.id);
|
||||
const cachedReplacement = markCachedNewSessionInfo(replacement);
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const replacement = await this.api.startSession(session.cwd, machineId);
|
||||
rememberCachedNewSession(replacement, machineId);
|
||||
moveDraft(this.sessionCacheKey(session.id), this.sessionCacheKey(replacement.id));
|
||||
forgetCachedNewSession(session.id, machineId);
|
||||
const cachedReplacement = markCachedNewSessionInfo(replacement, machineId);
|
||||
this.setState({ sessions: [cachedReplacement, ...this.getState().sessions.filter((candidate) => candidate.id !== session.id)], error: "" });
|
||||
await this.selectSession(cachedReplacement, { updateUrl: false });
|
||||
this.updateUrl(options?.updateUrl === false ? { replace: true } : undefined);
|
||||
@@ -431,7 +443,7 @@ export class SessionController {
|
||||
const message = result.type === "unsupported" ? result.message : result.message;
|
||||
if (message !== undefined && message !== "") this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] });
|
||||
if (result.type === "done" && result.session) {
|
||||
if (result.promptDraft !== undefined) saveDraft(result.session.id, result.promptDraft);
|
||||
if (result.promptDraft !== undefined) saveDraft(this.sessionCacheKey(result.session.id), result.promptDraft);
|
||||
const current = this.getState().selectedSession;
|
||||
const sessions = [result.session, ...this.getState().sessions.filter((session) => session.id !== result.session?.id)];
|
||||
this.setState({ sessions, selectedSession: current?.id === result.session.id ? result.session : current });
|
||||
@@ -538,7 +550,7 @@ export class SessionController {
|
||||
try {
|
||||
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));
|
||||
this.setState(this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page));
|
||||
} catch (error) {
|
||||
if (this.getState().selectedSession?.id === sessionId) this.setState({ error: String(error) });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AppState } from "../appState";
|
||||
import { LOCAL_MACHINE_ID } from "../machineKeys";
|
||||
|
||||
export function selectedMachineId(state: Pick<AppState, "selectedMachine">): string {
|
||||
return state.selectedMachine?.id ?? "local";
|
||||
return state.selectedMachine?.id ?? LOCAL_MACHINE_ID;
|
||||
}
|
||||
|
||||
export type GetState = () => AppState;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api as defaultApi, type Project, type Workspace } from "../api";
|
||||
import { resetWorkspaceScopedState } from "../appState";
|
||||
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
||||
import { machineProjectKey } from "../machineKeys";
|
||||
import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types";
|
||||
import type { SessionController } from "./sessionController";
|
||||
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
||||
@@ -30,7 +31,7 @@ export class WorkspaceController {
|
||||
}
|
||||
|
||||
forgetProject(projectId: string): void {
|
||||
this.workspaceSelection.forgetProject(projectId);
|
||||
this.workspaceSelection.forgetProject(machineProjectKey(selectedMachineId(this.getState()), projectId));
|
||||
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([candidate]) => candidate !== projectId));
|
||||
this.setState({ workspacesByProjectId });
|
||||
}
|
||||
@@ -39,9 +40,10 @@ export class WorkspaceController {
|
||||
this.sessions.clearActiveSession();
|
||||
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
|
||||
try {
|
||||
const workspaces = await api.workspaces(project.id, selectedMachineId(this.getState()));
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const workspaces = await api.workspaces(project.id, machineId);
|
||||
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) });
|
||||
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(machineProjectKey(machineId, project.id)) });
|
||||
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
|
||||
else if (target?.updateUrl !== false) this.updateUrl();
|
||||
} catch (error) {
|
||||
@@ -50,11 +52,12 @@ export class WorkspaceController {
|
||||
}
|
||||
|
||||
async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) {
|
||||
this.workspaceSelection.rememberWorkspace(workspace);
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
this.workspaceSelection.rememberWorkspace({ ...workspace, projectId: machineProjectKey(machineId, workspace.projectId) });
|
||||
this.sessions.clearActiveSession();
|
||||
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
|
||||
try {
|
||||
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path, selectedMachineId(this.getState())));
|
||||
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path, machineId), machineId);
|
||||
this.setState({ sessions });
|
||||
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
||||
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export const LOCAL_MACHINE_ID = "local";
|
||||
|
||||
export function machineProjectKey(machineId: string, projectId: string): string {
|
||||
return `${machineId}:${projectId}`;
|
||||
}
|
||||
|
||||
export function machineWorkspaceKey(machineId: string, projectId: string, workspaceId: string): string {
|
||||
return `${machineId}:${projectId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
export function machineSessionKey(machineId: string, sessionId: string): string {
|
||||
return `${machineId}:${sessionId}`;
|
||||
}
|
||||
@@ -21,6 +21,36 @@ export function createCoreActions(): PluginAction[] {
|
||||
enabled: (context) => context.state.selectedSession !== undefined,
|
||||
run: (context) => { context.focusPrompt(); },
|
||||
},
|
||||
{
|
||||
id: "machine.add",
|
||||
title: "Add Machine",
|
||||
description: "Register another PI WEB runtime reachable from this gateway",
|
||||
group: "Machine",
|
||||
run: (context) => context.addMachine(),
|
||||
},
|
||||
{
|
||||
id: "machine.refresh",
|
||||
title: "Refresh Selected Machine",
|
||||
description: "Check whether the selected PI WEB runtime is online",
|
||||
group: "Machine",
|
||||
run: (context) => context.refreshSelectedMachine(),
|
||||
},
|
||||
{
|
||||
id: "machine.open",
|
||||
title: "Open Selected Machine PI WEB",
|
||||
description: "Open the selected remote PI WEB directly in a new tab",
|
||||
group: "Machine",
|
||||
enabled: (context) => context.state.selectedMachine?.kind === "remote" && context.state.selectedMachine.baseUrl !== undefined,
|
||||
run: (context) => context.openSelectedMachine(),
|
||||
},
|
||||
{
|
||||
id: "machine.remove",
|
||||
title: "Remove Selected Machine",
|
||||
description: "Remove the selected remote machine from this gateway",
|
||||
group: "Machine",
|
||||
enabled: (context) => context.state.selectedMachine?.kind === "remote",
|
||||
run: (context) => context.removeSelectedMachine(),
|
||||
},
|
||||
{
|
||||
id: "project.add",
|
||||
title: "Add Project",
|
||||
|
||||
@@ -21,6 +21,10 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
|
||||
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
|
||||
addProject: vi.fn(() => { calls.push("addProject"); }),
|
||||
addMachine: vi.fn(() => { calls.push("addMachine"); }),
|
||||
refreshSelectedMachine: vi.fn(() => { calls.push("refreshSelectedMachine"); }),
|
||||
removeSelectedMachine: vi.fn(() => { calls.push("removeSelectedMachine"); }),
|
||||
openSelectedMachine: vi.fn(() => { calls.push("openSelectedMachine"); }),
|
||||
configureAuth: vi.fn(() => { calls.push("configureAuth"); }),
|
||||
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
|
||||
openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }),
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface PluginRuntimeContext {
|
||||
openActionPalette: () => void;
|
||||
focusPrompt: () => void;
|
||||
addProject: () => void | Promise<void>;
|
||||
addMachine: () => void | Promise<void>;
|
||||
refreshSelectedMachine: () => void | Promise<void>;
|
||||
removeSelectedMachine: () => void | Promise<void>;
|
||||
openSelectedMachine: () => void | Promise<void>;
|
||||
configureAuth: () => void | Promise<void>;
|
||||
logoutAuth: () => void | Promise<void>;
|
||||
openThemePicker: () => void;
|
||||
|
||||
+92
-2
@@ -1,11 +1,13 @@
|
||||
import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "./app.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { MachineStore } from "./machines/machineStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
@@ -15,14 +17,33 @@ import type { Project, Workspace } from "./types.js";
|
||||
let app: FastifyInstance;
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
let remoteClient: MachineClient | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
|
||||
projectDir = join(tempDir, "project");
|
||||
remoteClient = undefined;
|
||||
app = await buildApp({
|
||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||
workspaces: new WorkspaceService(),
|
||||
machines: new MachineService(new MachineStore(join(tempDir, "machines.json"))),
|
||||
machines: new MachineService(new MachineStore(join(tempDir, "machines.json")), {
|
||||
remoteClientFactory: () => {
|
||||
if (remoteClient === undefined) throw new Error("No remote machine client configured");
|
||||
return remoteClient;
|
||||
},
|
||||
now: () => new Date("2026-05-25T00:00:00.000Z"),
|
||||
localStatus: () => Promise.resolve({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", stale: false, available: true },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
|
||||
messages: [],
|
||||
}),
|
||||
}),
|
||||
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),
|
||||
@@ -53,6 +74,66 @@ describe("buildApp", () => {
|
||||
expect(addResponse.json()).not.toHaveProperty("token");
|
||||
});
|
||||
|
||||
it("reports machine health for local and remote machines", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson: MachineClient["requestJson"] = () => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "Remote Web", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "Remote Sessiond", stale: false, available: true },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const localHealth = await app.inject({ method: "GET", url: "/api/machines/local/health" });
|
||||
const remoteHealth = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/health` });
|
||||
|
||||
expect(localHealth.statusCode).toBe(200);
|
||||
expect(localHealth.json()).toMatchObject({ machineId: "local", ok: true, status: "online" });
|
||||
expect(remoteHealth.statusCode).toBe(200);
|
||||
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
|
||||
});
|
||||
|
||||
it("proxies allowlisted remote HTTP routes through the selected machine", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json", connection: "close" },
|
||||
body: Readable.from([JSON.stringify([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }])]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects?active=true` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("application/json");
|
||||
expect(response.json()).toEqual([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }]);
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.reject(new RemoteMachineRequestError("timed out", 504)));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/prompt`, payload: { text: "hello" } });
|
||||
|
||||
expect(response.statusCode).toBe(504);
|
||||
expect(response.json()).toMatchObject({ error: "Remote machine timeout", machineId: remote.id, statusCode: 504 });
|
||||
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/prompt", { text: "hello" });
|
||||
});
|
||||
|
||||
it("adds, lists, and closes projects through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -189,3 +270,12 @@ describe("buildApp", () => {
|
||||
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
|
||||
});
|
||||
});
|
||||
|
||||
function fakeRemoteClient(overrides: Partial<MachineClient>): MachineClient {
|
||||
return {
|
||||
request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }),
|
||||
requestJson: () => Promise.resolve({ statusCode: 200, headers: {}, body: undefined }),
|
||||
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { getPiWebStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||
|
||||
export interface AppDependencies {
|
||||
projects?: ProjectService;
|
||||
@@ -113,6 +114,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
registerLocalFileSuggestionRoutes(app, "/api");
|
||||
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
|
||||
|
||||
registerMachineProxyRoutes(app, machines);
|
||||
|
||||
const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client");
|
||||
const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client"));
|
||||
if (clientDist !== false && existsSync(clientDist)) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { WebSocket } from "ws";
|
||||
import type { StoredMachine } from "./machineStore.js";
|
||||
|
||||
export interface MachineHttpResponse {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body?: NodeJS.ReadableStream;
|
||||
}
|
||||
|
||||
export interface MachineJsonResponse {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface MachineRequestOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface MachineClient {
|
||||
request(method: string, path: string, body?: unknown, options?: MachineRequestOptions): Promise<MachineHttpResponse>;
|
||||
requestJson(method: string, path: string, body?: unknown, options?: MachineRequestOptions): Promise<MachineJsonResponse>;
|
||||
connectWebSocket(path: string): WebSocket;
|
||||
}
|
||||
|
||||
export const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
export const DEFAULT_REMOTE_HEALTH_TIMEOUT_MS = 3_000;
|
||||
|
||||
const BLOCKED_CONFIGURED_HEADER_NAMES = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"upgrade",
|
||||
"transfer-encoding",
|
||||
"content-length",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"authorization",
|
||||
"cookie",
|
||||
]);
|
||||
|
||||
export class RemoteMachineRequestError extends Error {
|
||||
constructor(message: string, readonly statusCode: 502 | 504) {
|
||||
super(message);
|
||||
this.name = "RemoteMachineRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteMachineClient implements MachineClient {
|
||||
constructor(private readonly machine: Pick<StoredMachine, "baseUrl" | "token" | "headers">, private readonly fetchImpl: typeof fetch = fetch) {}
|
||||
|
||||
async request(method: string, path: string, body?: unknown, options: MachineRequestOptions = {}): Promise<MachineHttpResponse> {
|
||||
const response = await this.fetchResponse(method, path, body, options);
|
||||
return {
|
||||
statusCode: response.status,
|
||||
headers: headersToRecord(response.headers),
|
||||
...(response.body === null ? {} : { body: readableFromWebResponseBody(response.body) }),
|
||||
};
|
||||
}
|
||||
|
||||
async requestJson(method: string, path: string, body?: unknown, options: MachineRequestOptions = {}): Promise<MachineJsonResponse> {
|
||||
const response = await this.fetchResponse(method, path, body, options);
|
||||
const text = await response.text();
|
||||
const parsed: unknown = text === "" ? undefined : JSON.parse(text);
|
||||
return {
|
||||
statusCode: response.status,
|
||||
headers: headersToRecord(response.headers),
|
||||
body: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
connectWebSocket(path: string): WebSocket {
|
||||
const url = this.remoteUrl(path);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return new WebSocket(url, { headers: this.remoteHeaders() });
|
||||
}
|
||||
|
||||
private async fetchResponse(method: string, path: string, body: unknown, options: MachineRequestOptions): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => { controller.abort(); }, options.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: this.requestHeaders(body),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
};
|
||||
if (body !== undefined && method !== "GET" && method !== "HEAD") init.body = JSON.stringify(body);
|
||||
return await this.fetchImpl(this.remoteUrl(path), init);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw new RemoteMachineRequestError("Remote machine request timed out", 504);
|
||||
throw new RemoteMachineRequestError(error instanceof Error ? error.message : String(error), 502);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private requestHeaders(body: unknown): HeadersInit {
|
||||
return {
|
||||
...this.remoteHeaders(),
|
||||
accept: "*/*",
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
};
|
||||
}
|
||||
|
||||
private remoteHeaders(): Record<string, string> {
|
||||
return {
|
||||
...(this.machine.token === undefined || this.machine.token === "" ? {} : { authorization: `Bearer ${this.machine.token}` }),
|
||||
...filterConfiguredHeaders(this.machine.headers),
|
||||
};
|
||||
}
|
||||
|
||||
private remoteUrl(path: string): URL {
|
||||
const url = new URL(this.machine.baseUrl);
|
||||
const separator = path.indexOf("?");
|
||||
const rawPath = separator === -1 ? path : path.slice(0, separator);
|
||||
const rawQuery = separator === -1 ? "" : path.slice(separator + 1);
|
||||
const basePath = url.pathname.replace(/\/$/u, "");
|
||||
const nextPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
url.pathname = `${basePath}${nextPath}`;
|
||||
url.search = rawQuery === "" ? "" : `?${rawQuery}`;
|
||||
url.hash = "";
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfiguredMachineHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
|
||||
if (headers === undefined) return undefined;
|
||||
return Object.fromEntries(Object.entries(headers).map(([key, value]) => {
|
||||
const name = key.trim();
|
||||
if (name === "") throw new Error("Machine header names must not be empty");
|
||||
if (typeof value !== "string") throw new Error("Machine headers must be strings");
|
||||
if (BLOCKED_CONFIGURED_HEADER_NAMES.has(name.toLowerCase())) throw new Error(`Machine header is not allowed: ${name}`);
|
||||
return [name, value];
|
||||
}));
|
||||
}
|
||||
|
||||
function filterConfiguredHeaders(headers: Record<string, string> | undefined): Record<string, string> {
|
||||
if (headers === undefined) return {};
|
||||
return Object.fromEntries(Object.entries(headers).filter(([key]) => !BLOCKED_CONFIGURED_HEADER_NAMES.has(key.toLowerCase())));
|
||||
}
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
function readableFromWebResponseBody(body: Response["body"]): NodeJS.ReadableStream {
|
||||
if (body === null) throw new Error("Response body is not readable");
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Node fetch returns a web stream that is runtime-compatible with Readable.fromWeb, but DOM and node:stream/web types are not structurally identical in this TS config.
|
||||
return Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { FastifyInstance, FastifyReply, HTTPMethods } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
import { RemoteMachineRequestError } from "./machineClient.js";
|
||||
import { MachineService } from "./machineService.js";
|
||||
|
||||
interface HttpRouteSpec {
|
||||
method: HTTPMethods;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const REMOTE_HTTP_ROUTES: HttpRouteSpec[] = [
|
||||
{ method: "GET", path: "/projects" },
|
||||
{ method: "POST", path: "/projects" },
|
||||
{ method: "DELETE", path: "/projects/:projectId" },
|
||||
{ method: "GET", path: "/project-directories" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" },
|
||||
{ method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
|
||||
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminals" },
|
||||
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId" },
|
||||
{ method: "GET", path: "/files" },
|
||||
{ method: "GET", path: "/activity" },
|
||||
{ method: "GET", path: "/sessions" },
|
||||
{ method: "POST", path: "/sessions" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/messages" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/status" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/models" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/model" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/model/cycle" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/thinking-levels" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/thinking-level" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/commands/respond" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/abort" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/stop" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/archive" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/archive-tree" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/restore" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/detach-parent" },
|
||||
{ method: "GET", path: "/auth/providers" },
|
||||
{ method: "POST", path: "/auth/api-key" },
|
||||
{ method: "POST", path: "/auth/logout" },
|
||||
];
|
||||
|
||||
const REMOTE_WEBSOCKET_ROUTES = [
|
||||
"/events",
|
||||
"/sessions/events",
|
||||
"/sessions/:sessionId/events",
|
||||
"/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket",
|
||||
];
|
||||
|
||||
const SAFE_RESPONSE_HEADERS = new Set([
|
||||
"content-type",
|
||||
"content-length",
|
||||
"cache-control",
|
||||
"last-modified",
|
||||
"etag",
|
||||
]);
|
||||
|
||||
export function registerMachineProxyRoutes(app: FastifyInstance, machines = new MachineService()): void {
|
||||
for (const spec of REMOTE_HTTP_ROUTES) {
|
||||
app.route<{ Params: { machineId: string }; Body: unknown }>({
|
||||
method: spec.method,
|
||||
url: `/api/machines/:machineId${spec.path}`,
|
||||
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, reply),
|
||||
});
|
||||
}
|
||||
|
||||
for (const path of REMOTE_WEBSOCKET_ROUTES) {
|
||||
app.get<{ Params: { machineId: string } }>(`/api/machines/:machineId${path}`, { websocket: true }, async (socket, request) => {
|
||||
await proxyWebSocket(machines, request.params.machineId, request.url, socket);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (machineId === "local") {
|
||||
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
|
||||
}
|
||||
|
||||
const client = await machines.remoteClient(machineId);
|
||||
if (client === undefined) {
|
||||
return reply.code(404).send({ error: "Machine not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await client.request(method, remoteApiPath(machineId, requestUrl), body);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
if (upstream.body === undefined) return await reply.send();
|
||||
return await reply.send(upstream.body);
|
||||
} catch (error) {
|
||||
return sendGatewayError(reply, machineId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyWebSocket(machines: MachineService, machineId: string, requestUrl: string, socket: WebSocket): Promise<void> {
|
||||
if (machineId === "local") {
|
||||
socket.close(1011, "Local machine route is not registered for this endpoint");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await machines.remoteClient(machineId);
|
||||
if (client === undefined) {
|
||||
socket.close(1008, "Machine not found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
bridgeSockets(socket, client.connectWebSocket(remoteApiPath(machineId, requestUrl)));
|
||||
} catch {
|
||||
socket.close(1011, "Remote machine unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function remoteApiPath(machineId: string, requestUrl: string): string {
|
||||
const machinePrefix = `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
const stripped = requestUrl.startsWith(machinePrefix) ? requestUrl.slice(machinePrefix.length) : requestUrl;
|
||||
const compatPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
||||
return `/api${compatPath}`;
|
||||
}
|
||||
|
||||
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined) continue;
|
||||
if (!SAFE_RESPONSE_HEADERS.has(name.toLowerCase())) continue;
|
||||
reply.header(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply {
|
||||
const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502;
|
||||
const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable";
|
||||
return reply.code(statusCode).send({
|
||||
error: label,
|
||||
machineId,
|
||||
statusCode,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,12 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/health", async (request, reply) => {
|
||||
const health = await machines.health(request.params.machineId);
|
||||
if (health === undefined) return reply.code(404).send({ error: "Machine not found" });
|
||||
return health;
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
@@ -43,6 +43,11 @@ describe("MachineService", () => {
|
||||
await expect(service.add({ name: "Bad", baseUrl: "https://example.test/path?q=1" })).rejects.toThrow("query or hash");
|
||||
});
|
||||
|
||||
it("rejects configured machine headers that would override proxy transport semantics", async () => {
|
||||
await expect(service.add({ name: "Bad", baseUrl: "https://example.test", headers: { Authorization: "Bearer secret" } })).rejects.toThrow("not allowed");
|
||||
await expect(service.add({ name: "Bad", baseUrl: "https://example.test", headers: { Connection: "close" } })).rejects.toThrow("not allowed");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Machine } from "../../shared/apiTypes.js";
|
||||
import type { Machine, MachineHealth, PiWebComponentStatus, PiWebStatusResponse } from "../../shared/apiTypes.js";
|
||||
import { getPiWebStatus } from "../piWebStatus.js";
|
||||
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
|
||||
import { MachineStore, type StoredMachine } from "./machineStore.js";
|
||||
|
||||
export interface CreateMachineInput {
|
||||
@@ -10,10 +12,20 @@ export interface CreateMachineInput {
|
||||
|
||||
export type UpdateMachineInput = Partial<CreateMachineInput>;
|
||||
|
||||
export interface MachineServiceDependencies {
|
||||
localStatus?: () => Promise<PiWebStatusResponse>;
|
||||
remoteClientFactory?: (machine: StoredMachine) => MachineClient;
|
||||
now?: () => Date;
|
||||
healthCacheTtlMs?: number;
|
||||
}
|
||||
|
||||
const LOCAL_MACHINE_TIMESTAMP = "1970-01-01T00:00:00.000Z";
|
||||
const DEFAULT_HEALTH_CACHE_TTL_MS = 5_000;
|
||||
|
||||
export class MachineService {
|
||||
constructor(private readonly store = new MachineStore()) {}
|
||||
private readonly healthCache = new Map<string, { expiresAt: number; health: MachineHealth }>();
|
||||
|
||||
constructor(private readonly store = new MachineStore(), private readonly deps: MachineServiceDependencies = {}) {}
|
||||
|
||||
async list(): Promise<Machine[]> {
|
||||
return [localMachine(), ...(await this.store.list()).map(publicMachine)];
|
||||
@@ -40,12 +52,69 @@ export class MachineService {
|
||||
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);
|
||||
if (stored !== undefined) this.healthCache.delete(id);
|
||||
return stored === undefined ? undefined : publicMachine(stored);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
if (id === "local") throw new Error("Local machine cannot be deleted");
|
||||
return await this.store.remove(id);
|
||||
const removed = await this.store.remove(id);
|
||||
if (removed) this.healthCache.delete(id);
|
||||
return removed;
|
||||
}
|
||||
|
||||
async storedRemote(id: string): Promise<StoredMachine | undefined> {
|
||||
if (id === "local") return undefined;
|
||||
return (await this.store.list()).find((machine) => machine.id === id);
|
||||
}
|
||||
|
||||
async remoteClient(id: string): Promise<MachineClient | undefined> {
|
||||
const machine = await this.storedRemote(id);
|
||||
return machine === undefined ? undefined : this.clientFor(machine);
|
||||
}
|
||||
|
||||
async health(id: string): Promise<MachineHealth | undefined> {
|
||||
const cached = this.healthCache.get(id);
|
||||
const now = this.now().getTime();
|
||||
if (cached !== undefined && cached.expiresAt > now) return cached.health;
|
||||
|
||||
const health = id === "local" ? await this.localHealth() : await this.remoteHealth(id);
|
||||
if (health === undefined) return undefined;
|
||||
this.healthCache.set(id, { expiresAt: now + (this.deps.healthCacheTtlMs ?? DEFAULT_HEALTH_CACHE_TTL_MS), health });
|
||||
return health;
|
||||
}
|
||||
|
||||
private async localHealth(): Promise<MachineHealth> {
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
const status = await (this.deps.localStatus ?? getPiWebStatus)();
|
||||
return { machineId: "local", ok: true, checkedAt, status: "online", web: status.components.web, sessiond: status.components.sessiond };
|
||||
} catch (error) {
|
||||
return { machineId: "local", ok: false, checkedAt, status: "error", error: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
private async remoteHealth(id: string): Promise<MachineHealth | undefined> {
|
||||
const machine = await this.storedRemote(id);
|
||||
if (machine === undefined) return undefined;
|
||||
const checkedAt = this.now().toISOString();
|
||||
try {
|
||||
const response = await this.clientFor(machine).requestJson("GET", "/api/pi-web/status", undefined, { timeoutMs: DEFAULT_REMOTE_HEALTH_TIMEOUT_MS });
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && isPiWebStatusResponse(response.body)) {
|
||||
return { machineId: id, ok: true, checkedAt, status: "online", web: response.body.components.web, sessiond: response.body.components.sessiond };
|
||||
}
|
||||
return { machineId: id, ok: false, checkedAt, status: "error", error: `Remote health returned HTTP ${String(response.statusCode)}` };
|
||||
} catch (error) {
|
||||
return { machineId: id, ok: false, checkedAt, status: "offline", error: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
private clientFor(machine: StoredMachine): MachineClient {
|
||||
return this.deps.remoteClientFactory?.(machine) ?? new RemoteMachineClient(machine);
|
||||
}
|
||||
|
||||
private now(): Date {
|
||||
return this.deps.now?.() ?? new Date();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +155,29 @@ function optionalSecrets(input: CreateMachineInput): { token?: string; headers?:
|
||||
}
|
||||
|
||||
function validateHeaders(value: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, headerValue]) => {
|
||||
if (typeof headerValue !== "string") throw new Error("Machine headers must be strings");
|
||||
return [key, headerValue];
|
||||
}));
|
||||
return validateConfiguredMachineHeaders(value) ?? {};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isPiWebStatusResponse(value: unknown): value is PiWebStatusResponse {
|
||||
if (!isRecord(value)) return false;
|
||||
const components = value["components"];
|
||||
if (!isRecord(components)) return false;
|
||||
return isPiWebComponentStatus(components["web"]) && isPiWebComponentStatus(components["sessiond"]);
|
||||
}
|
||||
|
||||
function isPiWebComponentStatus(value: unknown): value is PiWebComponentStatus {
|
||||
if (!isRecord(value)) return false;
|
||||
const component = value["component"];
|
||||
return (component === "web" || component === "sessiond")
|
||||
&& typeof value["label"] === "string"
|
||||
&& typeof value["stale"] === "boolean"
|
||||
&& typeof value["available"] === "boolean";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user