Archived
feat: add machine federation
This commit is contained in:
@@ -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