Archived
Merge pull request #10 from jmfederico/review/pr-5-machine-federation-fixes
Add machine federation and management
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix workspace selection in the web UI so local machine project and session loading no longer fails with `api is not defined`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Hide the Machines navigation section when only one machine is configured, align Machines list spacing with the other navigation sections, and add a remove action to remote machine rows.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add machine-scoped local project, workspace, file, and git API aliases as the next step toward machine federation.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fall back to the local machine when a bookmarked or restored remote machine is offline, and clear stale remote workspace route state.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add the first machine registry API and show the synthesized Local machine in the web UI as the foundation for machine federation.
|
||||||
@@ -34,23 +34,27 @@ PI WEB connects those two worlds. The work stays in the server-side environment
|
|||||||
|
|
||||||
## Core model
|
## Core model
|
||||||
|
|
||||||
PI WEB organizes work into three levels:
|
PI WEB organizes work into four levels:
|
||||||
|
|
||||||
```text
|
```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
|
Workspace a git worktree, or the project folder for non-git projects
|
||||||
Session a chat with Pi Coding Agent running inside a workspace
|
Session a chat with Pi Coding Agent running inside a workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
This maps naturally to real development work:
|
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;
|
- use worktrees to separate branches, features, experiments, and reviews;
|
||||||
- start one or more agent sessions inside each workspace;
|
- start one or more agent sessions inside each workspace;
|
||||||
- leave sessions running even when the browser disconnects or the UI restarts.
|
- leave sessions running even when the browser disconnects or the UI restarts.
|
||||||
|
|
||||||
## Features
|
## 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.
|
- Add and list server-side projects.
|
||||||
- Discover git worktrees automatically with `git worktree list --porcelain`.
|
- Discover git worktrees automatically with `git worktree list --porcelain`.
|
||||||
- Support non-git folders as single-workspace projects.
|
- 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:
|
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`
|
- Projects: `~/.pi-web/projects.json`
|
||||||
- Workspaces: discovered from git worktrees, not stored
|
- Workspaces: discovered from git worktrees, not stored
|
||||||
- Sessions and chat history: Pi's default JSONL session storage
|
- Sessions and chat history: Pi's default JSONL session storage on the selected machine
|
||||||
- Active session runtimes and WebSockets: memory in the session daemon
|
- 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
|
## Plugins
|
||||||
|
|
||||||
@@ -260,6 +271,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_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_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_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
|
## Development services
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { activityApi, api, configApi, filesApi, gitApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||||
|
import { terminalsApi } from "./clients";
|
||||||
|
|
||||||
|
const workspace: Workspace = {
|
||||||
|
id: "w/1",
|
||||||
|
projectId: "p 1",
|
||||||
|
path: "/repo",
|
||||||
|
label: "repo",
|
||||||
|
isMain: true,
|
||||||
|
isGitRepo: true,
|
||||||
|
isGitWorktree: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandRun: TerminalCommandRun = {
|
||||||
|
id: "run1",
|
||||||
|
origin: "core",
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
terminalId: "t1",
|
||||||
|
title: "Build",
|
||||||
|
command: "npm test",
|
||||||
|
status: "running",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("machine-scoped terminal command-run API", () => {
|
||||||
|
it("creates command runs through the selected machine scope", async () => {
|
||||||
|
const fetchMock = stubJsonFetch(commandRun);
|
||||||
|
|
||||||
|
await terminalsApi.runTerminalCommand("core", { workspace, title: "Build", command: "npm test", open: true }, "remote a");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [url, init] = fetchCall(fetchMock, 0);
|
||||||
|
expect(url).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/terminal-command-runs");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(JSON.parse(requestBody(init))).toEqual({ origin: "core", title: "Build", command: "npm test", metadata: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists, reads, and cancels command runs through the selected machine scope", async () => {
|
||||||
|
const fetchMock = stubSequenceFetch([
|
||||||
|
jsonResponse([commandRun]),
|
||||||
|
jsonResponse(commandRun),
|
||||||
|
jsonResponse(commandRun),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await terminalsApi.listCommandRuns({ projectId: "p 1", workspaceId: "w/1", statuses: ["running"], metadata: { "pi.operation": "workspace.delete" } }, "remote a");
|
||||||
|
await terminalsApi.getCommandRun("run 1", "remote a");
|
||||||
|
await terminalsApi.cancelCommandRun("run 1", "remote a");
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||||
|
"/api/machines/remote%20a/terminal-command-runs?projectId=p+1&workspaceId=w%2F1&statuses=running&metadata=%7B%22pi.operation%22%3A%22workspace.delete%22%7D",
|
||||||
|
"/api/machines/remote%20a/terminal-command-runs/run%201",
|
||||||
|
"/api/machines/remote%20a/terminal-command-runs/run%201/cancel",
|
||||||
|
]);
|
||||||
|
expect(fetchCall(fetchMock, 2)[1]?.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for missing command runs in the selected machine scope", async () => {
|
||||||
|
const fetchMock = stubResponseFetch(new Response("{}", { status: 404 }));
|
||||||
|
|
||||||
|
await expect(terminalsApi.getCommandRun("missing", "remote-a")).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote-a/terminal-command-runs/missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
type FetchMock = ReturnType<typeof vi.fn<FetchLike>>;
|
||||||
|
|
||||||
|
function stubJsonFetch(value: unknown): FetchMock {
|
||||||
|
return stubResponseFetch(jsonResponse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubSequenceFetch(responses: Response[]): FetchMock {
|
||||||
|
const fetchMock = vi.fn<FetchLike>(() => {
|
||||||
|
const response = responses.shift();
|
||||||
|
if (response === undefined) throw new Error("No fetch response queued");
|
||||||
|
return Promise.resolve(response);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubResponseFetch(response: Response): FetchMock {
|
||||||
|
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(response));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchCall(fetchMock: FetchMock, index: number): Parameters<FetchLike> {
|
||||||
|
const call = fetchMock.mock.calls[index];
|
||||||
|
if (call === undefined) throw new Error(`Missing fetch call ${String(index)}`);
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBody(init: RequestInit | undefined): string {
|
||||||
|
if (typeof init?.body !== "string") throw new Error("Expected string request body");
|
||||||
|
return init.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
parseFileTreeResponse,
|
parseFileTreeResponse,
|
||||||
parseGitDiffResponse,
|
parseGitDiffResponse,
|
||||||
parseGitStatusResponse,
|
parseGitStatusResponse,
|
||||||
|
parseMachine,
|
||||||
|
parseMachineHealth,
|
||||||
|
parseMachinesResponse,
|
||||||
parseMessagePage,
|
parseMessagePage,
|
||||||
parseModelSelectionResponse,
|
parseModelSelectionResponse,
|
||||||
parseOAuthFlowState,
|
parseOAuthFlowState,
|
||||||
@@ -32,12 +35,21 @@ import {
|
|||||||
parseWorkspace,
|
parseWorkspace,
|
||||||
parseWorkspaceActivityResponse,
|
parseWorkspaceActivityResponse,
|
||||||
} from "./parsers";
|
} from "./parsers";
|
||||||
import { gitDiffUrl, messageUrl } from "./urls";
|
import { machineGitDiffUrl, messageUrl } from "./urls";
|
||||||
|
|
||||||
|
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||||
|
|
||||||
export const piWebApi = {
|
export const piWebApi = {
|
||||||
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const machinesApi = {
|
||||||
|
machines: () => request("/api/machines", parseMachinesResponse),
|
||||||
|
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||||
|
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||||
|
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||||
|
};
|
||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
config: () => request("/api/config", parsePiWebConfigResponse),
|
config: () => request("/api/config", parsePiWebConfigResponse),
|
||||||
saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||||
@@ -48,72 +60,72 @@ export const pluginsApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const activityApi = {
|
export const activityApi = {
|
||||||
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
|
workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const projectsApi = {
|
export const projectsApi = {
|
||||||
projects: () => request("/api/projects", arrayOf(parseProject)),
|
projects: (machineId = "local") => request(`${machinePrefix(machineId)}/projects`, arrayOf(parseProject)),
|
||||||
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
|
addProject: (path: string, name?: string, create?: boolean, machineId = "local") => request(`${machinePrefix(machineId)}/projects`, parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
|
||||||
closeProject: (projectId: string) => request(`/api/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }),
|
closeProject: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }),
|
||||||
projectDirectories: (query: string) => request(`/api/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)),
|
projectDirectories: (query: string, machineId = "local") => request(`${machinePrefix(machineId)}/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const workspacesApi = {
|
export const workspacesApi = {
|
||||||
workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||||
workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||||
workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sessionsApi = {
|
export const sessionsApi = {
|
||||||
sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||||
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||||
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
messages: (sessionId: string, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(sessionId, options, machineId), parseMessagePage),
|
||||||
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
status: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/status`, parseSessionStatus),
|
||||||
models: (sessionId: string) => request(`/api/sessions/${sessionId}/models`, parseModelSelectionResponse),
|
models: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/models`, parseModelSelectionResponse),
|
||||||
setModel: (sessionId: string, provider: string, modelId: string) => request(`/api/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
|
setModel: (sessionId: string, provider: string, modelId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
|
||||||
cycleModel: (sessionId: string, direction: "forward" | "backward") => request(`/api/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
|
cycleModel: (sessionId: string, direction: "forward" | "backward", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
|
||||||
thinkingLevels: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
|
thinkingLevels: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
|
||||||
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh") => request(`/api/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
|
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
|
||||||
cycleThinkingLevel: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
|
cycleThinkingLevel: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
|
||||||
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
commands: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
||||||
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
||||||
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
shell: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
runCommand: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
respondToCommand: (sessionId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||||
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
abort: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
||||||
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
stop: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
||||||
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
archive: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
||||||
archiveWithDescendants: (sessionId: string) => request(`/api/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
|
archiveWithDescendants: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
|
||||||
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
restore: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||||
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
|
detachParent: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
|
||||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => {
|
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.mode !== undefined) params.set("mode", options.mode);
|
if (options?.mode !== undefined) params.set("mode", options.mode);
|
||||||
if (options?.authType !== undefined) params.set("authType", options.authType);
|
if (options?.authType !== undefined) params.set("authType", options.authType);
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return request(`/api/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
|
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
|
||||||
},
|
},
|
||||||
saveApiKey: (providerId: string, key: string) => request("/api/auth/api-key", parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
|
saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
|
||||||
logoutProvider: (providerId: string) => request("/api/auth/logout", parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
|
logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
|
||||||
startOAuthLogin: (providerId: string) => request("/api/auth/oauth", parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
|
startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
|
||||||
oauthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
|
oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
|
||||||
respondOAuthFlow: (flowId: string, requestId: string, value: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
respondOAuthFlow: (flowId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||||
cancelOAuthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }),
|
cancelOAuthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const terminalsApi = {
|
export const terminalsApi = {
|
||||||
terminals: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
|
terminals: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
|
||||||
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
||||||
closeTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
closeTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
||||||
continueTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
|
continueTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
|
||||||
runTerminalCommand: (origin: string, input: RunTerminalCommandInput) => request(`/api/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
|
runTerminalCommand: (origin: string, input: RunTerminalCommandInput, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
|
||||||
listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
|
listCommandRuns: (filter?: TerminalCommandRunFilter, machineId = "local") => request(`${machinePrefix(machineId)}/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
|
||||||
getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId),
|
getCommandRun: (runId: string, machineId = "local") => getOptionalTerminalCommandRun(runId, machineId),
|
||||||
cancelCommandRun: (runId: string) => request(`/api/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }),
|
cancelCommandRun: (runId: string, machineId = "local") => request(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}/cancel`, parseTerminalCommandRun, { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getOptionalTerminalCommandRun(runId: string): Promise<TerminalCommandRun | undefined> {
|
async function getOptionalTerminalCommandRun(runId: string, machineId: string): Promise<TerminalCommandRun | undefined> {
|
||||||
const response = await fetch(`/api/terminal-command-runs/${encodeURIComponent(runId)}`);
|
const response = await fetch(`${machinePrefix(machineId)}/terminal-command-runs/${encodeURIComponent(runId)}`);
|
||||||
if (response.status === 404) return undefined;
|
if (response.status === 404) return undefined;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||||
@@ -148,6 +160,7 @@ export interface FileSuggestionQueryOptions {
|
|||||||
kind?: FileSuggestion["kind"] | undefined;
|
kind?: FileSuggestion["kind"] | undefined;
|
||||||
mode?: "file" | "path" | undefined;
|
mode?: "file" | "path" | undefined;
|
||||||
scope?: "tracked" | "all" | undefined;
|
scope?: "tracked" | "all" | undefined;
|
||||||
|
machineId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
@@ -156,17 +169,18 @@ export const filesApi = {
|
|||||||
if (options.kind !== undefined) params.set("kind", options.kind);
|
if (options.kind !== undefined) params.set("kind", options.kind);
|
||||||
if (options.mode !== undefined) params.set("mode", options.mode);
|
if (options.mode !== undefined) params.set("mode", options.mode);
|
||||||
if (options.scope !== undefined) params.set("scope", options.scope);
|
if (options.scope !== undefined) params.set("scope", options.scope);
|
||||||
return request(`/api/files?${params.toString()}`, arrayOf(parseFileSuggestion));
|
return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gitApi = {
|
export const gitApi = {
|
||||||
gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
||||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse),
|
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffUrl(machineId, projectId, workspaceId, options), parseGitDiffResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
...piWebApi,
|
...piWebApi,
|
||||||
|
...machinesApi,
|
||||||
...configApi,
|
...configApi,
|
||||||
...pluginsApi,
|
...pluginsApi,
|
||||||
...activityApi,
|
...activityApi,
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Workspace } from "../../../shared/apiTypes";
|
||||||
|
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||||
|
import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||||
|
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||||
|
import { workspaceImagePreviewUrl } from "./urls";
|
||||||
|
|
||||||
|
const machineId = "remote-a";
|
||||||
|
const workspace: Workspace = {
|
||||||
|
id: "w 1",
|
||||||
|
projectId: "p 1",
|
||||||
|
path: "/repo",
|
||||||
|
label: "repo",
|
||||||
|
isMain: true,
|
||||||
|
isGitRepo: true,
|
||||||
|
isGitWorktree: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("federated route contract", () => {
|
||||||
|
it("covers machine-scoped client HTTP calls with remote proxy routes", async () => {
|
||||||
|
const fetchMock = vi.fn<FetchLike>(() => Promise.resolve(jsonResponse({})));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
||||||
|
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||||
|
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||||
|
ignoreParseFailure(projectsApi.closeProject("p 1", machineId)),
|
||||||
|
ignoreParseFailure(projectsApi.projectDirectories("/r", machineId)),
|
||||||
|
ignoreParseFailure(workspacesApi.workspaces("p 1", machineId)),
|
||||||
|
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
|
||||||
|
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
|
||||||
|
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
|
||||||
|
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
|
||||||
|
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.messages("s 1", { limit: 20, before: 10 }, machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.status("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.models("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.setModel("s 1", "openai", "gpt", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.cycleModel("s 1", "forward", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.thinkingLevels("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.setThinkingLevel("s 1", "medium", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.cycleThinkingLevel("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.commands("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.prompt("s 1", "hello", "followUp", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.shell("s 1", "ls", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.runCommand("s 1", "/help", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.respondToCommand("s 1", "req 1", "yes", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.abort("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.stop("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.archive("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.archiveWithDescendants("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.restore("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.detachParent("s 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||||
|
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.startOAuthLogin("openai", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.oauthFlow("flow 1", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.respondOAuthFlow("flow 1", "req 1", "code", machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.cancelOAuthFlow("flow 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.terminals("p 1", "w 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.startTerminal("p 1", "w 1", { cols: 120, rows: 40 }, machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.closeTerminal("p 1", "w 1", "t 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.continueTerminal("p 1", "w 1", "t 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.runTerminalCommand("core", { workspace, title: "Build", command: "npm test" }, machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.listCommandRuns({ projectId: "p 1", workspaceId: "w 1", statuses: ["running"], metadata: { "pi.operation": "test" } }, machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.getCommandRun("run 1", machineId)),
|
||||||
|
ignoreParseFailure(terminalsApi.cancelCommandRun("run 1", machineId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const observedRoutes = uniqueHttpRoutes([
|
||||||
|
...fetchMock.mock.calls.map((call) => fetchCallToRoute(call, machineId)),
|
||||||
|
routeFromMachineUrl("GET", workspaceImagePreviewUrl("p 1", "w 1", "diagram.svg", { machineId, modifiedAt: "2026-05-25T00:00:00.000Z" }), machineId),
|
||||||
|
]);
|
||||||
|
const unmatched = observedRoutes.filter((route) => !matchesHttpRoute(route, FEDERATED_HTTP_ROUTES));
|
||||||
|
|
||||||
|
expect(unmatched).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers machine-scoped client WebSocket calls with remote proxy routes", () => {
|
||||||
|
const webSocketUrls: string[] = [];
|
||||||
|
function FakeWebSocket(url: string): void {
|
||||||
|
webSocketUrls.push(url);
|
||||||
|
}
|
||||||
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||||
|
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||||
|
|
||||||
|
sessionEvents("s 1", machineId);
|
||||||
|
globalSessionEvents(machineId);
|
||||||
|
realtimeEvents(machineId);
|
||||||
|
terminalSocket("p 1", "w 1", "t 1", { cols: 120, rows: 40 }, machineId);
|
||||||
|
|
||||||
|
const observedPaths = uniqueStrings(webSocketUrls.map((url) => routeFromMachineUrl("GET", url, machineId).path));
|
||||||
|
const unmatched = observedPaths.filter((path) => !FEDERATED_WEBSOCKET_ROUTES.some((route) => pathMatchesPattern(path, route)));
|
||||||
|
|
||||||
|
expect(unmatched).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
interface ObservedHttpRoute {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ignoreParseFailure(promise: Promise<unknown>): Promise<void> {
|
||||||
|
await promise.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchCallToRoute(call: Parameters<FetchLike>, scopedMachineId: string): ObservedHttpRoute {
|
||||||
|
const [url, init] = call;
|
||||||
|
return routeFromMachineUrl((init?.method ?? "GET").toUpperCase(), url, scopedMachineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeFromMachineUrl(method: string, input: string | URL | Request, scopedMachineId: string): ObservedHttpRoute {
|
||||||
|
const url = toUrl(input);
|
||||||
|
const prefix = `/api/machines/${encodeURIComponent(scopedMachineId)}`;
|
||||||
|
if (!url.pathname.startsWith(prefix)) throw new Error(`Expected machine-scoped URL, got ${url.pathname}`);
|
||||||
|
return { method, path: url.pathname.slice(prefix.length) || "/" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUrl(input: string | URL | Request): URL {
|
||||||
|
if (input instanceof URL) return input;
|
||||||
|
if (input instanceof Request) return new URL(input.url);
|
||||||
|
return new URL(input, "https://pi.example.test");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesHttpRoute(route: ObservedHttpRoute, specs: readonly FederatedHttpRouteSpec[]): boolean {
|
||||||
|
return specs.some((spec) => spec.method === route.method && pathMatchesPattern(route.path, spec.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathMatchesPattern(path: string, pattern: string): boolean {
|
||||||
|
const pathSegments = path.split("/").filter((segment) => segment !== "");
|
||||||
|
const patternSegments = pattern.split("/").filter((segment) => segment !== "");
|
||||||
|
return pathSegments.length === patternSegments.length
|
||||||
|
&& patternSegments.every((segment, index) => segment.startsWith(":") || segment === pathSegments[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueHttpRoutes(routes: ObservedHttpRoute[]): ObservedHttpRoute[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return routes.filter((route) => {
|
||||||
|
const key = `${route.method} ${route.path}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: string[]): string[] {
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, 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, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, 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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null;
|
return typeof value === "object" && value !== null;
|
||||||
@@ -57,6 +57,57 @@ export function parseMessagePage(value: unknown): MessagePage {
|
|||||||
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
|
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseMachinesResponse(value: unknown): Machine[] {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return arrayOf(parseMachine)(record["machines"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMachine(value: unknown): Machine {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const kind = requireMachineKind(record, "kind");
|
||||||
|
const baseUrl = optionalString(record, "baseUrl");
|
||||||
|
const status = optionalMachineStatus(record, "status");
|
||||||
|
const statusMessage = optionalString(record, "statusMessage");
|
||||||
|
return {
|
||||||
|
id: requireString(record, "id"),
|
||||||
|
name: requireString(record, "name"),
|
||||||
|
kind,
|
||||||
|
...(baseUrl === undefined ? {} : { baseUrl }),
|
||||||
|
createdAt: requireString(record, "createdAt"),
|
||||||
|
updatedAt: requireString(record, "updatedAt"),
|
||||||
|
...(status === undefined ? {} : { status }),
|
||||||
|
...(statusMessage === undefined ? {} : { statusMessage }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalMachineStatus(record: Record<string, unknown>, key: string): MachineStatus | undefined {
|
||||||
|
const value = optionalString(record, key);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value !== "unknown" && value !== "online" && value !== "offline" && value !== "error") throw new Error(`Expected machine status field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseProject(value: unknown): Project {
|
export function parseProject(value: unknown): Project {
|
||||||
const record = requireRecord(value);
|
const record = requireRecord(value);
|
||||||
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
|
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||||
|
|
||||||
|
const webSocketUrls: string[] = [];
|
||||||
|
|
||||||
|
function FakeWebSocket(url: string): void {
|
||||||
|
webSocketUrls.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
webSocketUrls.length = 0;
|
||||||
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||||
|
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("machine-scoped socket urls", () => {
|
||||||
|
it("defaults session sockets to the local machine scope", () => {
|
||||||
|
sessionEvents("s1");
|
||||||
|
globalSessionEvents();
|
||||||
|
realtimeEvents();
|
||||||
|
|
||||||
|
expect(webSocketUrls).toEqual([
|
||||||
|
"wss://pi.example.test/api/machines/local/sessions/s1/events",
|
||||||
|
"wss://pi.example.test/api/machines/local/sessions/events",
|
||||||
|
"wss://pi.example.test/api/machines/local/events",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the requested machine scope for terminal sockets", () => {
|
||||||
|
terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a");
|
||||||
|
|
||||||
|
expect(webSocketUrls).toEqual([
|
||||||
|
"wss://pi.example.test/api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
export function sessionEvents(sessionId: string): WebSocket {
|
export function sessionEvents(sessionId: string, machineId = "local"): WebSocket {
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`);
|
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${sessionId}/events`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function globalSessionEvents(): WebSocket {
|
export function globalSessionEvents(machineId = "local"): WebSocket {
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
|
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }): WebSocket {
|
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket {
|
||||||
const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`;
|
const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`;
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
|
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function realtimeEvents(): WebSocket {
|
export function realtimeEvents(machineId = "local"): WebSocket {
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/events`);
|
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function machinePrefix(machineId: string): string {
|
||||||
|
return `/api/machines/${encodeURIComponent(machineId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webSocketBaseUrl(): string {
|
function webSocketBaseUrl(): string {
|
||||||
|
|||||||
@@ -6,17 +6,26 @@ export function gitDiffUrl(projectId: string, workspaceId: string, options?: { p
|
|||||||
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
|
export function machineGitDiffUrl(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.path !== undefined) params.set("path", options.path);
|
||||||
|
if (options?.staged === true) params.set("staged", "true");
|
||||||
|
const query = params.toString();
|
||||||
|
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string }): string {
|
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("path", path);
|
params.set("path", path);
|
||||||
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
|
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
|
||||||
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
|
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||||
|
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||||
|
|
||||||
export type NavigationSection = "projects" | "workspaces" | "sessions";
|
export type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
|
||||||
export type ExpandedNavigationSection = NavigationSection | "none" | undefined;
|
export type ExpandedNavigationSection = NavigationSection | "none" | undefined;
|
||||||
|
|
||||||
export interface NavigationSelectionState {
|
export interface NavigationSelectionState {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||||
import type { ChatLine } from "./components/shared";
|
import type { ChatLine } from "./components/shared";
|
||||||
import type { QualifiedContributionId } from "./plugins/ids";
|
import type { QualifiedContributionId } from "./plugins/ids";
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
|
machines: Machine[];
|
||||||
|
selectedMachine: Machine | undefined;
|
||||||
|
isLoadingMachines: boolean;
|
||||||
|
machineStatuses: Record<string, MachineHealth>;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
sessions: SessionInfo[];
|
sessions: SessionInfo[];
|
||||||
@@ -92,6 +96,10 @@ export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
|
|||||||
|
|
||||||
export function initialAppState(): AppState {
|
export function initialAppState(): AppState {
|
||||||
return {
|
return {
|
||||||
|
machines: [],
|
||||||
|
selectedMachine: undefined,
|
||||||
|
isLoadingMachines: false,
|
||||||
|
machineStatuses: {},
|
||||||
projects: [],
|
projects: [],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
sessions: [],
|
sessions: [],
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("cached new sessions", () => {
|
|||||||
it("stores and reloads new sessions with a browser-cache marker", () => {
|
it("stores and reloads new sessions with a browser-cache marker", () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
|
|
||||||
rememberCachedNewSession(baseSession, storage);
|
rememberCachedNewSession(baseSession, "local", storage);
|
||||||
|
|
||||||
const cached = loadCachedNewSessions(storage);
|
const cached = loadCachedNewSessions(storage);
|
||||||
expect(cached).toHaveLength(1);
|
expect(cached).toHaveLength(1);
|
||||||
@@ -54,21 +54,30 @@ describe("cached new sessions", () => {
|
|||||||
|
|
||||||
it("merges cached sessions for the selected cwd without duplicating server sessions", () => {
|
it("merges cached sessions for the selected cwd without duplicating server sessions", () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
rememberCachedNewSession(baseSession, storage);
|
rememberCachedNewSession(baseSession, "local", storage);
|
||||||
rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, storage);
|
rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, "local", storage);
|
||||||
|
|
||||||
expect(mergeCachedNewSessions("/repo", [], storage).map((session) => session.id)).toEqual(["session-1"]);
|
expect(mergeCachedNewSessions("/repo", [], "local", storage).map((session) => session.id)).toEqual(["session-1"]);
|
||||||
expect(mergeCachedNewSessions("/repo", [baseSession], 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], storage)[0])).toBe(false);
|
expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], "local", storage)[0])).toBe(false);
|
||||||
expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]);
|
expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forgets cached sessions", () => {
|
it("forgets cached sessions", () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
rememberCachedNewSession(baseSession, storage);
|
rememberCachedNewSession(baseSession, "local", storage);
|
||||||
|
|
||||||
forgetCachedNewSession("session-1", storage);
|
forgetCachedNewSession("session-1", "local", storage);
|
||||||
|
|
||||||
expect(loadCachedNewSessions(storage)).toEqual([]);
|
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 storageKey = "pi-web:cached-new-sessions:v1";
|
||||||
const markerProperty = "browserCachedNew";
|
const markerProperty = "browserCachedNew";
|
||||||
|
const defaultMachineId = "local";
|
||||||
|
|
||||||
export type CachedNewSessionInfo = SessionInfo & { browserCachedNew: true };
|
export type CachedNewSessionInfo = SessionInfo & { browserCachedNew: true; machineId: string };
|
||||||
|
|
||||||
function browserStorage(): Storage | undefined {
|
function browserStorage(): Storage | undefined {
|
||||||
try {
|
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;
|
if (session.messageCount !== 0 || session.archived === true) return;
|
||||||
const sessions = loadCachedNewSessions(storage).filter((candidate) => candidate.id !== session.id);
|
const sessions = loadCachedNewSessions(storage).filter((candidate) => candidate.id !== session.id || candidate.machineId !== machineId);
|
||||||
saveCachedNewSessions([markCachedNewSessionInfo(session), ...sessions], storage);
|
saveCachedNewSessions([markCachedNewSessionInfo(session, machineId), ...sessions], storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markCachedNewSessionInfo(session: SessionInfo): CachedNewSessionInfo {
|
export function markCachedNewSessionInfo(session: SessionInfo, machineId = defaultMachineId): CachedNewSessionInfo {
|
||||||
return { ...session, browserCachedNew: true };
|
return { ...session, browserCachedNew: true, machineId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function forgetCachedNewSession(sessionId: string, storage = browserStorage()): void {
|
export function forgetCachedNewSession(sessionId: string, machineId = defaultMachineId, storage = browserStorage()): void {
|
||||||
const sessions = loadCachedNewSessions(storage).filter((session) => session.id !== sessionId);
|
const sessions = loadCachedNewSessions(storage).filter((session) => session.id !== sessionId || session.machineId !== machineId);
|
||||||
saveCachedNewSessions(sessions, storage);
|
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 sessionIds = new Set(sessions.map((session) => session.id));
|
||||||
const cachedSessions = loadCachedNewSessions(storage);
|
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);
|
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];
|
return [...cached, ...sessions];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function stripCachedNewSessionMarker(session: SessionInfo): SessionInfo {
|
|||||||
messageCount: session.messageCount,
|
messageCount: session.messageCount,
|
||||||
firstMessage: session.firstMessage,
|
firstMessage: session.firstMessage,
|
||||||
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
||||||
|
...("machineId" in session && typeof session.machineId === "string" ? { machineId: session.machineId } : { machineId: defaultMachineId }),
|
||||||
...(session.archived === true ? { archived: true } : {}),
|
...(session.archived === true ? { archived: true } : {}),
|
||||||
...(session.archivedAt === undefined ? {} : { archivedAt: session.archivedAt }),
|
...(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 [];
|
if (id === undefined || path === undefined || cwd === undefined || created === undefined || modified === undefined || firstMessage === undefined || messageCount !== 0) return [];
|
||||||
const name = optionalStringField(value, "name");
|
const name = optionalStringField(value, "name");
|
||||||
const parentSessionPath = optionalStringField(value, "parentSessionPath");
|
const parentSessionPath = optionalStringField(value, "parentSessionPath");
|
||||||
|
const machineId = optionalStringField(value, "machineId") ?? defaultMachineId;
|
||||||
return [{
|
return [{
|
||||||
id,
|
id,
|
||||||
path,
|
path,
|
||||||
@@ -101,6 +104,7 @@ function parseCachedSession(value: unknown): CachedNewSessionInfo[] {
|
|||||||
messageCount,
|
messageCount,
|
||||||
firstMessage,
|
firstMessage,
|
||||||
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
||||||
|
machineId,
|
||||||
browserCachedNew: true,
|
browserCachedNew: true,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Machine } from "../api";
|
||||||
|
import { canRemoveMachine } from "./MachineList";
|
||||||
|
|
||||||
|
describe("canRemoveMachine", () => {
|
||||||
|
it("only allows remote machines to be removed from the machine list", () => {
|
||||||
|
expect(canRemoveMachine(machine("local", "local"))).toBe(false);
|
||||||
|
expect(canRemoveMachine(machine("remote-a", "remote"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function machine(id: string, kind: Machine["kind"]): Machine {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
kind,
|
||||||
|
createdAt: "2026-06-04T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { LitElement, css, html, type PropertyValues } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import type { Machine, MachineHealth } from "../api";
|
||||||
|
import { actionMenuPanelStyle } from "./actionMenu";
|
||||||
|
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||||
|
import { listStyles } from "./shared";
|
||||||
|
|
||||||
|
@customElement("machine-list")
|
||||||
|
export class MachineList extends LitElement {
|
||||||
|
@property({ attribute: false }) machines: Machine[] = [];
|
||||||
|
@property({ attribute: false }) selected?: Machine;
|
||||||
|
@property({ 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;
|
||||||
|
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||||
|
@state() private openMenuMachineId: string | undefined;
|
||||||
|
@state() private menuStyle = "";
|
||||||
|
|
||||||
|
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||||
|
if (event.composedPath().includes(this)) return;
|
||||||
|
this.openMenuMachineId = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
document.addEventListener("click", this.onDocumentClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
document.removeEventListener("click", this.onDocumentClick);
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override updated(changed: PropertyValues<this>): void {
|
||||||
|
if (changed.has("machines") && this.openMenuMachineId !== undefined && !this.machines.some((machine) => machine.id === this.openMenuMachineId)) this.openMenuMachineId = undefined;
|
||||||
|
if (changed.has("collapsed") && this.collapsed) this.openMenuMachineId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<section>
|
||||||
|
<h2>${this.renderHeading()}</h2>
|
||||||
|
${this.collapsed ? null : html`
|
||||||
|
<div class="list-body">
|
||||||
|
${this.machines.map((machine) => this.renderMachine(machine))}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMachine(machine: Machine) {
|
||||||
|
const status = this.statuses[machine.id]?.status ?? machine.status ?? "unknown";
|
||||||
|
const statusLabel = status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown";
|
||||||
|
const hasRemoveAction = canRemoveMachine(machine) && this.onRemove !== undefined;
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class=${`action-row machine-row ${this.selected?.id === machine.id ? "selected" : ""} ${hasRemoveAction ? "" : "no-actions"}`}
|
||||||
|
tabindex="0"
|
||||||
|
title=${machine.baseUrl ?? machine.name}
|
||||||
|
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(machine)); }}
|
||||||
|
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, 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>
|
||||||
|
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMachineMenu(machine: Machine) {
|
||||||
|
const open = this.openMenuMachineId === machine.id;
|
||||||
|
const menuId = machineMenuId(machine.id);
|
||||||
|
return html`
|
||||||
|
<div class="action-menu">
|
||||||
|
<button
|
||||||
|
class="action-menu-toggle"
|
||||||
|
title="Machine actions"
|
||||||
|
aria-label=${`Actions for ${machine.name}`}
|
||||||
|
aria-expanded=${String(open)}
|
||||||
|
aria-controls=${menuId}
|
||||||
|
@click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(machine.id, event.currentTarget); }}
|
||||||
|
>⋯</button>
|
||||||
|
${open ? html`
|
||||||
|
<div class="action-menu-panel machine-menu-panel" id=${menuId} style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||||
|
<button class="danger" title=${`Remove ${machine.name}`} @click=${() => { this.removeMachine(machine); }}>Remove</button>
|
||||||
|
</div>
|
||||||
|
` : null}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHeading() {
|
||||||
|
if (!this.collapsible) return "Machines";
|
||||||
|
const selectedSummary = this.selected?.name ?? "No machine selected";
|
||||||
|
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
|
||||||
|
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleMenu(machineId: string, target: EventTarget | null): void {
|
||||||
|
if (this.openMenuMachineId === machineId) {
|
||||||
|
this.openMenuMachineId = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.menuStyle = actionMenuPanelStyle(target);
|
||||||
|
this.openMenuMachineId = machineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeMachine(machine: Machine): void {
|
||||||
|
this.openMenuMachineId = undefined;
|
||||||
|
void this.onRemove?.(machine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMachineKeydown(event: KeyboardEvent, machine: Machine): void {
|
||||||
|
if (event.key === "Escape" && this.openMenuMachineId === machine.id) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.openMenuMachineId = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine));
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
listStyles,
|
||||||
|
css`
|
||||||
|
.machine-row.no-actions .action-main { border-radius: 8px; }
|
||||||
|
.machine-menu-panel button.danger { color: var(--pi-danger); }
|
||||||
|
.machine-menu-panel button.danger:hover, .machine-menu-panel button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRemoveMachine(machine: Machine): boolean {
|
||||||
|
return machine.kind === "remote";
|
||||||
|
}
|
||||||
|
|
||||||
|
function machineMenuId(machineId: string): string {
|
||||||
|
return `machine-menu-${machineId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement, query, state } from "lit/decorators.js";
|
import { customElement, query, state } from "lit/decorators.js";
|
||||||
import { configApi, piWebApi, terminalsApi, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
|
import { configApi, piWebApi, terminalsApi, type Machine, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
|
||||||
import type { AppAction } from "../actions";
|
import type { AppAction } from "../actions";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import { isSessionActive } from "../../../shared/activity";
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
@@ -8,11 +8,13 @@ import { ActivityController } from "../controllers/activityController";
|
|||||||
import { AuthController } from "../controllers/authController";
|
import { AuthController } from "../controllers/authController";
|
||||||
import { FileExplorerController } from "../controllers/fileExplorerController";
|
import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||||
import { GitController } from "../controllers/gitController";
|
import { GitController } from "../controllers/gitController";
|
||||||
|
import { MachineController } from "../controllers/machineController";
|
||||||
import { ProjectController } from "../controllers/projectController";
|
import { ProjectController } from "../controllers/projectController";
|
||||||
import { SessionController } from "../controllers/sessionController";
|
import { SessionController } from "../controllers/sessionController";
|
||||||
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
||||||
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
|
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
|
||||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
|
import { selectedMachineId } from "../controllers/types";
|
||||||
import { RealtimeSocket } from "../sessionSocket";
|
import { RealtimeSocket } from "../sessionSocket";
|
||||||
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
|
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
|
||||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||||
@@ -29,6 +31,7 @@ import { readSettingsSection, writeSettingsSection, type SettingsSection } from
|
|||||||
import { applyShortcutPreferences } from "../shortcutPreferences";
|
import { applyShortcutPreferences } from "../shortcutPreferences";
|
||||||
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
||||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||||
|
import "./MachineList";
|
||||||
import "./ProjectList";
|
import "./ProjectList";
|
||||||
import "./WorkspaceList";
|
import "./WorkspaceList";
|
||||||
import "./SessionList";
|
import "./SessionList";
|
||||||
@@ -52,6 +55,7 @@ import "./appShell/AppPanelEdgeControl";
|
|||||||
import "./appShell/AppRefreshControl";
|
import "./appShell/AppRefreshControl";
|
||||||
import { appStyles } from "./shared";
|
import { appStyles } from "./shared";
|
||||||
|
|
||||||
|
|
||||||
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
|
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
|
||||||
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||||
const THEME_AUTO_ON_VALUE = "auto:on";
|
const THEME_AUTO_ON_VALUE = "auto:on";
|
||||||
@@ -90,6 +94,12 @@ export class PiWebApp extends LitElement {
|
|||||||
(patch) => { this.setState(patch); },
|
(patch) => { this.setState(patch); },
|
||||||
this.workspaces,
|
this.workspaces,
|
||||||
);
|
);
|
||||||
|
private readonly machines = new MachineController(
|
||||||
|
() => this.state,
|
||||||
|
(patch) => { this.setState(patch); },
|
||||||
|
() => { this.updateUrl(); },
|
||||||
|
this.projects,
|
||||||
|
);
|
||||||
private readonly files = new FileExplorerController(
|
private readonly files = new FileExplorerController(
|
||||||
() => this.state,
|
() => this.state,
|
||||||
(patch) => { this.setState(patch); },
|
(patch) => { this.setState(patch); },
|
||||||
@@ -206,12 +216,19 @@ export class PiWebApp extends LitElement {
|
|||||||
this.state = { ...this.state, ...patch };
|
this.state = { ...this.state, ...patch };
|
||||||
this.handleActivityTransition(previous, this.state);
|
this.handleActivityTransition(previous, this.state);
|
||||||
this.handleWorkspaceChange(previous, this.state);
|
this.handleWorkspaceChange(previous, this.state);
|
||||||
|
this.handleMachineChange(previous, this.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadProjectsAndRestoreRoute() {
|
private async loadProjectsAndRestoreRoute() {
|
||||||
this.restoreSettingsRoute();
|
this.restoreSettingsRoute();
|
||||||
|
const route = readRoute();
|
||||||
|
await this.machines.loadMachines(route.machineId);
|
||||||
|
const machineFallbackMessage = this.state.error;
|
||||||
|
const effectiveRoute = this.routeForSelectedMachine(route);
|
||||||
|
if (effectiveRoute !== route) this.replaceRouteAndClearWorkspaceQuery(effectiveRoute);
|
||||||
await this.projects.loadProjects();
|
await this.projects.loadProjects();
|
||||||
await this.withChatScrollTransition(() => this.restoreRoute(false));
|
if (machineFallbackMessage !== "" && this.state.error === "") this.setState({ error: machineFallbackMessage });
|
||||||
|
await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false));
|
||||||
await this.refreshWorkspaceDeletionRuns();
|
await this.refreshWorkspaceDeletionRuns();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,10 +290,14 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async restoreRoute(updateUrl: boolean) {
|
private async restoreRoute(updateUrl: boolean) {
|
||||||
const route = readRoute();
|
await this.restoreRouteFor(readRoute(), updateUrl);
|
||||||
const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file");
|
}
|
||||||
const selectedDiffPath = readNamespacedString(queryNamespace("core:workspace.git"), "diff");
|
|
||||||
const selectedTerminalId = readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal");
|
private async restoreRouteFor(route: AppRoute, updateUrl: boolean) {
|
||||||
|
await this.restoreRouteMachine(route, updateUrl);
|
||||||
|
const selectedFilePath = route.projectId === undefined ? undefined : readNamespacedString(queryNamespace("core:workspace.files"), "file");
|
||||||
|
const selectedDiffPath = route.projectId === undefined ? undefined : readNamespacedString(queryNamespace("core:workspace.git"), "diff");
|
||||||
|
const selectedTerminalId = route.projectId === undefined ? undefined : readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal");
|
||||||
this.routeRestoreInProgress = true;
|
this.routeRestoreInProgress = true;
|
||||||
this.restoringRouteTerminalId = selectedTerminalId;
|
this.restoringRouteTerminalId = selectedTerminalId;
|
||||||
try {
|
try {
|
||||||
@@ -301,8 +322,30 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private routeForSelectedMachine(route: AppRoute): AppRoute {
|
||||||
|
const currentMachineId = this.state.selectedMachine?.id ?? "local";
|
||||||
|
if ((route.machineId ?? "local") === currentMachineId) return route;
|
||||||
|
return { machineId: currentMachineId, projectId: undefined, workspaceId: undefined, sessionId: undefined, tool: undefined, view: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceRouteAndClearWorkspaceQuery(route: AppRoute): void {
|
||||||
|
writeRoute(route, { replace: true });
|
||||||
|
setNamespacedQueryKey(queryNamespace("core:workspace.files"), "file", undefined, { replace: true });
|
||||||
|
setNamespacedQueryKey(queryNamespace("core:workspace.git"), "diff", undefined, { replace: true });
|
||||||
|
setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", undefined, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreRouteMachine(route: AppRoute, updateUrl: boolean): Promise<void> {
|
||||||
|
const routeMachineId = route.machineId ?? "local";
|
||||||
|
if (this.state.selectedMachine?.id === routeMachineId) return;
|
||||||
|
const machine = this.state.machines.find((candidate) => candidate.id === routeMachineId);
|
||||||
|
if (machine === undefined) return;
|
||||||
|
await this.machines.selectMachine(machine, { updateUrl });
|
||||||
|
}
|
||||||
|
|
||||||
private routeMatchesCurrentSelection(route: AppRoute): boolean {
|
private routeMatchesCurrentSelection(route: AppRoute): boolean {
|
||||||
return route.workspaceId !== undefined
|
return (route.machineId ?? "local") === (this.state.selectedMachine?.id ?? "local")
|
||||||
|
&& route.workspaceId !== undefined
|
||||||
&& route.workspaceId !== ""
|
&& route.workspaceId !== ""
|
||||||
&& this.state.selectedProject?.id === route.projectId
|
&& this.state.selectedProject?.id === route.projectId
|
||||||
&& this.state.selectedWorkspace?.id === route.workspaceId
|
&& this.state.selectedWorkspace?.id === route.workspaceId
|
||||||
@@ -341,6 +384,7 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private updateUrl(options?: { replace?: boolean | undefined }) {
|
private updateUrl(options?: { replace?: boolean | undefined }) {
|
||||||
writeRoute({
|
writeRoute({
|
||||||
|
machineId: this.state.selectedMachine?.id,
|
||||||
projectId: this.state.selectedProject?.id,
|
projectId: this.state.selectedProject?.id,
|
||||||
workspaceId: this.state.selectedWorkspace?.id,
|
workspaceId: this.state.selectedWorkspace?.id,
|
||||||
sessionId: this.state.selectedSession?.id,
|
sessionId: this.state.selectedSession?.id,
|
||||||
@@ -362,18 +406,36 @@ export class PiWebApp extends LitElement {
|
|||||||
this.openWorkspaceTool("core:workspace.terminal");
|
this.openWorkspaceTool("core:workspace.terminal");
|
||||||
}
|
}
|
||||||
|
|
||||||
private terminalCommandRunsForOrigin(origin: string): TerminalCommandRunsInternalRuntime {
|
private terminalCommandRunsForOrigin(origin: string, machineId = selectedMachineId(this.state)): TerminalCommandRunsInternalRuntime {
|
||||||
const existing = this.terminalCommandRunRuntimes.get(origin);
|
const key = machineScopedKey(machineId, origin);
|
||||||
|
const existing = this.terminalCommandRunRuntimes.get(key);
|
||||||
if (existing !== undefined) return existing;
|
if (existing !== undefined) return existing;
|
||||||
const runtime = createTerminalCommandRunsRuntime(origin, {
|
const runtime = createTerminalCommandRunsRuntime(origin, {
|
||||||
openTerminal: (workspace, options) => { void this.openRuntimeTerminal(workspace, options); },
|
api: {
|
||||||
|
runTerminalCommand: (runtimeOrigin, input) => terminalsApi.runTerminalCommand(runtimeOrigin, input, machineId),
|
||||||
|
listCommandRuns: (filter) => terminalsApi.listCommandRuns(filter, machineId),
|
||||||
|
getCommandRun: (runId) => terminalsApi.getCommandRun(runId, machineId),
|
||||||
|
},
|
||||||
|
openTerminal: (workspace, options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||||
});
|
});
|
||||||
this.terminalCommandRunRuntimes.set(origin, runtime);
|
this.terminalCommandRunRuntimes.set(key, runtime);
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openRuntimeTerminal(workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise<void> {
|
private async openRuntimeTerminal(machineId: string, workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise<void> {
|
||||||
if (workspace !== undefined && this.state.selectedWorkspace?.id !== workspace.id) await this.workspaces.selectWorkspace(workspace);
|
if (selectedMachineId(this.state) !== machineId) {
|
||||||
|
const machine = this.state.machines.find((candidate) => candidate.id === machineId);
|
||||||
|
if (machine === undefined) {
|
||||||
|
this.setState({ error: "Machine not found for terminal command run" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.machines.selectMachine(machine);
|
||||||
|
}
|
||||||
|
if (workspace !== undefined && (this.state.selectedWorkspace?.id !== workspace.id || this.state.selectedProject?.id !== workspace.projectId)) {
|
||||||
|
const project = this.state.projects.find((candidate) => candidate.id === workspace.projectId);
|
||||||
|
if (project !== undefined && this.state.selectedProject?.id !== project.id) await this.workspaces.selectProject(project, { workspaceId: workspace.id });
|
||||||
|
else await this.workspaces.selectWorkspace(workspace);
|
||||||
|
}
|
||||||
this.openTerminal(options);
|
this.openTerminal(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,14 +448,18 @@ export class PiWebApp extends LitElement {
|
|||||||
private rememberSelectedTerminal(terminalId: string | undefined): void {
|
private rememberSelectedTerminal(terminalId: string | undefined): void {
|
||||||
const workspace = this.state.selectedWorkspace;
|
const workspace = this.state.selectedWorkspace;
|
||||||
if (workspace === undefined) return;
|
if (workspace === undefined) return;
|
||||||
if (terminalId === undefined) this.terminalSelection.forgetWorkspace(workspace.path);
|
if (terminalId === undefined) this.terminalSelection.forgetWorkspace(this.terminalWorkspaceKey(workspace));
|
||||||
else this.terminalSelection.rememberTerminal(workspace.path, terminalId);
|
else this.terminalSelection.rememberTerminal(this.terminalWorkspaceKey(workspace), terminalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeSelectedTerminalToUrl(terminalId: string | undefined, options?: { replace?: boolean | undefined }): void {
|
private writeSelectedTerminalToUrl(terminalId: string | undefined, options?: { replace?: boolean | undefined }): void {
|
||||||
setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", terminalId, options);
|
setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", terminalId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private terminalWorkspaceKey(workspace: Workspace): string {
|
||||||
|
return `${selectedMachineId(this.state)}:${workspace.path}`;
|
||||||
|
}
|
||||||
|
|
||||||
private selectMainView(view: AppState["mainView"]) {
|
private selectMainView(view: AppState["mainView"]) {
|
||||||
if (view !== "navigation" && view !== "chat") {
|
if (view !== "navigation" && view !== "chat") {
|
||||||
this.openWorkspaceTool(view);
|
this.openWorkspaceTool(view);
|
||||||
@@ -427,7 +493,7 @@ export class PiWebApp extends LitElement {
|
|||||||
if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id) return;
|
if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id) return;
|
||||||
this.terminalAutoStartWorkspaceId = undefined;
|
this.terminalAutoStartWorkspaceId = undefined;
|
||||||
this.activeTerminalIds.clear();
|
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 });
|
this.setState({ activeTerminalCount: 0, selectedTerminalId });
|
||||||
if (!this.routeRestoreInProgress) this.writeSelectedTerminalToUrl(selectedTerminalId, { replace: true });
|
if (!this.routeRestoreInProgress) this.writeSelectedTerminalToUrl(selectedTerminalId, { replace: true });
|
||||||
if (next.selectedWorkspace === undefined) return;
|
if (next.selectedWorkspace === undefined) return;
|
||||||
@@ -445,6 +511,7 @@ export class PiWebApp extends LitElement {
|
|||||||
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
|
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
|
||||||
void this.refreshWorkspaceActivity();
|
void this.refreshWorkspaceActivity();
|
||||||
},
|
},
|
||||||
|
selectedMachineId(this.state),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,9 +538,10 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
|
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
|
||||||
|
const machineId = selectedMachineId(this.state);
|
||||||
try {
|
try {
|
||||||
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id);
|
const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id, machineId);
|
||||||
if (this.state.selectedWorkspace?.id !== workspace.id) return;
|
if (selectedMachineId(this.state) !== machineId || this.state.selectedWorkspace?.id !== workspace.id) return;
|
||||||
this.activeTerminalIds.clear();
|
this.activeTerminalIds.clear();
|
||||||
for (const terminal of terminals) {
|
for (const terminal of terminals) {
|
||||||
if (!terminal.exited) this.activeTerminalIds.add(terminal.id);
|
if (!terminal.exited) this.activeTerminalIds.add(terminal.id);
|
||||||
@@ -493,6 +561,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 {
|
private refreshSelectedWorkspaceTool(tool: QualifiedContributionId): void {
|
||||||
if (tool === "core:workspace.files") void this.files.refreshFiles();
|
if (tool === "core:workspace.files") void this.files.refreshFiles();
|
||||||
if (tool === "core:workspace.git") void this.git.refreshGit();
|
if (tool === "core:workspace.git") void this.git.refreshGit();
|
||||||
@@ -551,6 +628,16 @@ export class PiWebApp extends LitElement {
|
|||||||
});
|
});
|
||||||
return html`
|
return html`
|
||||||
<app-navigation-panel
|
<app-navigation-panel
|
||||||
|
.machines=${this.state.machines}
|
||||||
|
.selectedMachine=${this.state.selectedMachine}
|
||||||
|
.machineStatuses=${this.state.machineStatuses}
|
||||||
|
.machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")}
|
||||||
|
.onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }}
|
||||||
|
.onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => {
|
||||||
|
this.mobileNavigation.expand("projects");
|
||||||
|
await this.machines.selectMachine(machine);
|
||||||
|
})}
|
||||||
|
.onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }}
|
||||||
.projects=${this.state.projects}
|
.projects=${this.state.projects}
|
||||||
.selectedProject=${this.state.selectedProject}
|
.selectedProject=${this.state.selectedProject}
|
||||||
.workspaceActivities=${this.state.workspaceActivities}
|
.workspaceActivities=${this.state.workspaceActivities}
|
||||||
@@ -725,6 +812,10 @@ export class PiWebApp extends LitElement {
|
|||||||
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
||||||
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
||||||
addProject: () => { this.setState({ projectDialogOpen: true }); },
|
addProject: () => { this.setState({ projectDialogOpen: true }); },
|
||||||
|
addMachine: () => this.addMachineFromPrompt(),
|
||||||
|
refreshSelectedMachine: () => this.machines.refreshMachineHealth(),
|
||||||
|
removeSelectedMachine: () => this.removeMachine(),
|
||||||
|
openSelectedMachine: () => { this.openSelectedMachine(); },
|
||||||
configureAuth: () => this.auth.openLogin(),
|
configureAuth: () => this.auth.openLogin(),
|
||||||
logoutAuth: () => this.auth.openLogout(),
|
logoutAuth: () => this.auth.openLogout(),
|
||||||
openThemePicker: () => { this.openThemeDialog(); },
|
openThemePicker: () => { this.openThemeDialog(); },
|
||||||
@@ -755,25 +846,27 @@ export class PiWebApp extends LitElement {
|
|||||||
const confirmed = confirm(`Delete workspace ${label}?\n\nThis will run git worktree remove and delete:\n${workspace.path}\n\nThe Git branch will not be deleted.`);
|
const confirmed = confirm(`Delete workspace ${label}?\n\nThis will run git worktree remove and delete:\n${workspace.path}\n\nThe Git branch will not be deleted.`);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const machineId = selectedMachineId(this.state);
|
||||||
try {
|
try {
|
||||||
const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId);
|
const mainWorkspace = await this.mainWorkspaceForProject(workspace.projectId);
|
||||||
if (mainWorkspace === undefined) {
|
if (mainWorkspace === undefined) {
|
||||||
this.setState({ error: "Project main workspace not found" });
|
this.setState({ error: "Project main workspace not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handle = await this.terminalCommandRunsForOrigin("core").runCommand({
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
|
const handle = await this.terminalCommandRunsForOrigin("core", machineId).runCommand({
|
||||||
workspace: mainWorkspace,
|
workspace: mainWorkspace,
|
||||||
title: `Delete workspace: ${label}`,
|
title: `Delete workspace: ${label}`,
|
||||||
command: `git worktree remove ${shellQuote(workspace.path)}`,
|
command: `git worktree remove ${shellQuote(workspace.path)}`,
|
||||||
open: true,
|
open: true,
|
||||||
metadata: workspaceDeletionMetadata(workspace),
|
metadata: workspaceDeletionMetadata(workspace),
|
||||||
});
|
});
|
||||||
this.recordWorkspaceDeletionRun(handle.run);
|
this.recordWorkspaceDeletionRun(handle.run, machineId);
|
||||||
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run)).catch((error: unknown) => {
|
void handle.completed.then((run) => this.handleCompletedWorkspaceDeletionRun(run, machineId)).catch((error: unknown) => {
|
||||||
this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
|
if (selectedMachineId(this.state) === machineId) this.setState({ error: `Workspace deletion failed. See terminal output. ${errorMessage(error)}` });
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
|
if (selectedMachineId(this.state) === machineId) this.setState({ error: `Failed to start workspace deletion: ${errorMessage(error)}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +876,8 @@ export class PiWebApp extends LitElement {
|
|||||||
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordWorkspaceDeletionRun(run: TerminalCommandRun): void {
|
private recordWorkspaceDeletionRun(run: TerminalCommandRun, machineId: string): void {
|
||||||
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
const workspaceId = targetWorkspaceIdForRun(run);
|
const workspaceId = targetWorkspaceIdForRun(run);
|
||||||
if (workspaceId === undefined) return;
|
if (workspaceId === undefined) return;
|
||||||
this.setState({ workspaceDeletionRuns: { ...this.state.workspaceDeletionRuns, [workspaceId]: run } });
|
this.setState({ workspaceDeletionRuns: { ...this.state.workspaceDeletionRuns, [workspaceId]: run } });
|
||||||
@@ -792,6 +886,7 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private async refreshWorkspaceDeletionRuns(): Promise<void> {
|
private async refreshWorkspaceDeletionRuns(): Promise<void> {
|
||||||
if (this.refreshingWorkspaceDeletionRuns) return;
|
if (this.refreshingWorkspaceDeletionRuns) return;
|
||||||
|
const machineId = selectedMachineId(this.state);
|
||||||
const project = this.state.selectedProject;
|
const project = this.state.selectedProject;
|
||||||
if (project === undefined) {
|
if (project === undefined) {
|
||||||
this.setState({ workspaceDeletionRuns: {} });
|
this.setState({ workspaceDeletionRuns: {} });
|
||||||
@@ -801,11 +896,12 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
this.refreshingWorkspaceDeletionRuns = true;
|
this.refreshingWorkspaceDeletionRuns = true;
|
||||||
try {
|
try {
|
||||||
const runs = await this.terminalCommandRunsForOrigin("core").listCommandRuns(workspaceDeletionRunFilter(project.id));
|
const runs = await this.terminalCommandRunsForOrigin("core", machineId).listCommandRuns(workspaceDeletionRunFilter(project.id));
|
||||||
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
const latestRuns = latestWorkspaceDeletionRuns(runs);
|
const latestRuns = latestWorkspaceDeletionRuns(runs);
|
||||||
this.setState({ workspaceDeletionRuns: latestRuns });
|
this.setState({ workspaceDeletionRuns: latestRuns });
|
||||||
for (const run of Object.values(latestRuns)) {
|
for (const run of Object.values(latestRuns)) {
|
||||||
if (!isWorkspaceDeletionRunPending(run)) await this.handleCompletedWorkspaceDeletionRun(run);
|
if (!isWorkspaceDeletionRunPending(run)) await this.handleCompletedWorkspaceDeletionRun(run, machineId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to refresh workspace deletion runs", error);
|
console.warn("Failed to refresh workspace deletion runs", error);
|
||||||
@@ -827,14 +923,17 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun): Promise<void> {
|
private async handleCompletedWorkspaceDeletionRun(run: TerminalCommandRun, machineId = selectedMachineId(this.state)): Promise<void> {
|
||||||
if (this.handledWorkspaceDeletionRunIds.has(run.id)) return;
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
|
const runKey = machineScopedKey(machineId, run.id);
|
||||||
|
if (this.handledWorkspaceDeletionRunIds.has(runKey)) return;
|
||||||
const workspaceId = targetWorkspaceIdForRun(run);
|
const workspaceId = targetWorkspaceIdForRun(run);
|
||||||
if (workspaceId === undefined) return;
|
if (workspaceId === undefined) return;
|
||||||
this.handledWorkspaceDeletionRunIds.add(run.id);
|
this.handledWorkspaceDeletionRunIds.add(runKey);
|
||||||
|
|
||||||
if (run.status === "succeeded") {
|
if (run.status === "succeeded") {
|
||||||
await this.workspaces.refreshAfterWorkspaceDeleted(run.projectId, workspaceId);
|
await this.workspaces.refreshAfterWorkspaceDeleted(run.projectId, workspaceId);
|
||||||
|
if (selectedMachineId(this.state) !== machineId) return;
|
||||||
this.setState({ workspaceDeletionRuns: omitWorkspaceDeletionRun(this.state.workspaceDeletionRuns, workspaceId) });
|
this.setState({ workspaceDeletionRuns: omitWorkspaceDeletionRun(this.state.workspaceDeletionRuns, workspaceId) });
|
||||||
this.updateWorkspaceDeletionPolling();
|
this.updateWorkspaceDeletionPolling();
|
||||||
return;
|
return;
|
||||||
@@ -846,6 +945,27 @@ 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 removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
|
||||||
|
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 {
|
private runAction(action: AppAction): void {
|
||||||
void Promise.resolve()
|
void Promise.resolve()
|
||||||
.then(() => action.run())
|
.then(() => action.run())
|
||||||
@@ -996,6 +1116,7 @@ export class PiWebApp extends LitElement {
|
|||||||
if (!this.appShell.isMobileNavigationLayout) return null;
|
if (!this.appShell.isMobileNavigationLayout) return null;
|
||||||
return html`
|
return html`
|
||||||
<app-context-bar
|
<app-context-bar
|
||||||
|
.machine=${this.state.selectedMachine}
|
||||||
.project=${this.state.selectedProject}
|
.project=${this.state.selectedProject}
|
||||||
.workspace=${this.state.selectedWorkspace}
|
.workspace=${this.state.selectedWorkspace}
|
||||||
.session=${this.state.selectedSession}
|
.session=${this.state.selectedSession}
|
||||||
@@ -1049,8 +1170,8 @@ export class PiWebApp extends LitElement {
|
|||||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
||||||
${state.selectedSession ? html`
|
${state.selectedSession ? html`
|
||||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .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>
|
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .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} .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>
|
<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.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.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}
|
${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}
|
||||||
@@ -1060,7 +1181,7 @@ export class PiWebApp extends LitElement {
|
|||||||
${this.renderWorkspacePanelEdgeControl()}
|
${this.renderWorkspacePanelEdgeControl()}
|
||||||
${this.renderWorkspacePanel()}
|
${this.renderWorkspacePanel()}
|
||||||
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||||
${state.projectDialogOpen ? html`<project-dialog .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1077,6 +1198,7 @@ function createPluginRegistry(): PluginRegistry {
|
|||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
|
function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
|
||||||
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
|
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
|
||||||
}
|
}
|
||||||
@@ -1089,6 +1211,10 @@ function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent {
|
|||||||
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
|
return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function machineScopedKey(machineId: string, value: string): string {
|
||||||
|
return JSON.stringify([machineId, value]);
|
||||||
|
}
|
||||||
|
|
||||||
function shellQuote(value: string): string {
|
function shellQuote(value: string): string {
|
||||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { css } from "lit";
|
|||||||
export class ProjectDialog extends LitElement {
|
export class ProjectDialog extends LitElement {
|
||||||
@property({ attribute: false }) onSubmit?: (path: string, create: boolean) => void;
|
@property({ attribute: false }) onSubmit?: (path: string, create: boolean) => void;
|
||||||
@property({ attribute: false }) onCancel?: () => void;
|
@property({ attribute: false }) onCancel?: () => void;
|
||||||
|
@property() machineId = "local";
|
||||||
@state() private path = "";
|
@state() private path = "";
|
||||||
@state() private createMissing = true;
|
@state() private createMissing = true;
|
||||||
@state() private suggestions: FileSuggestion[] = [];
|
@state() private suggestions: FileSuggestion[] = [];
|
||||||
@@ -29,7 +30,7 @@ export class ProjectDialog extends LitElement {
|
|||||||
const requestId = ++this.requestId;
|
const requestId = ++this.requestId;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const suggestions = await api.projectDirectories(this.path);
|
const suggestions = await api.projectDirectories(this.path, this.machineId);
|
||||||
if (requestId !== this.requestId) return;
|
if (requestId !== this.requestId) return;
|
||||||
this.suggestions = suggestions;
|
this.suggestions = suggestions;
|
||||||
this.selected = Math.min(this.selected, Math.max(0, suggestions.length - 1));
|
this.selected = Math.min(this.selected, Math.max(0, suggestions.length - 1));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit";
|
|||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
|
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
|
||||||
import { inputModeForDraft } from "../inputModes";
|
import { inputModeForDraft } from "../inputModes";
|
||||||
|
import { machineSessionKey } from "../machineKeys";
|
||||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||||
import { promptEditorStyles, type CompletionItem } from "./shared";
|
import { promptEditorStyles, type CompletionItem } from "./shared";
|
||||||
import "./AutocompleteMenu";
|
import "./AutocompleteMenu";
|
||||||
@@ -16,6 +17,7 @@ export class PromptEditor extends LitElement {
|
|||||||
@property({ type: Boolean }) disabled = false;
|
@property({ type: Boolean }) disabled = false;
|
||||||
@property() sessionId?: string;
|
@property() sessionId?: string;
|
||||||
@property() cwd?: string;
|
@property() cwd?: string;
|
||||||
|
@property() machineId = "local";
|
||||||
@property({ type: Boolean }) canSteer = false;
|
@property({ type: Boolean }) canSteer = false;
|
||||||
@property({ type: Boolean }) isCompacting = false;
|
@property({ type: Boolean }) isCompacting = false;
|
||||||
@property({ type: Boolean }) canStop = false;
|
@property({ type: Boolean }) canStop = false;
|
||||||
@@ -34,10 +36,13 @@ export class PromptEditor extends LitElement {
|
|||||||
private readonly readOnlyCompartment = new Compartment();
|
private readonly readOnlyCompartment = new Compartment();
|
||||||
|
|
||||||
protected override willUpdate(changed: PropertyValues<this>) {
|
protected override willUpdate(changed: PropertyValues<this>) {
|
||||||
if (!changed.has("sessionId")) return;
|
if (!changed.has("sessionId") && !changed.has("machineId")) return;
|
||||||
const previousSessionId = changed.get("sessionId");
|
const previousSessionId = changed.has("sessionId") ? changed.get("sessionId") : this.sessionId;
|
||||||
if (previousSessionId !== undefined && previousSessionId !== "") saveDraft(previousSessionId, this.draft);
|
const previousMachineId = changed.has("machineId") ? changed.get("machineId") : this.machineId;
|
||||||
this.draft = this.sessionId !== undefined && this.sessionId !== "" ? loadDraft(this.sessionId) : "";
|
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.completions = [];
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
}
|
}
|
||||||
@@ -48,7 +53,7 @@ export class PromptEditor extends LitElement {
|
|||||||
|
|
||||||
protected override updated(changed: PropertyValues) {
|
protected override updated(changed: PropertyValues) {
|
||||||
if (changed.has("disabled")) this.updateEditorDisabledState();
|
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 {
|
override disconnectedCallback(): void {
|
||||||
@@ -155,7 +160,8 @@ export class PromptEditor extends LitElement {
|
|||||||
|
|
||||||
private updateDraft(value: string) {
|
private updateDraft(value: string) {
|
||||||
this.draft = value;
|
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();
|
void this.refreshCompletions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +174,7 @@ export class PromptEditor extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
|
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
|
||||||
const commands = await api.commands(this.sessionId).catch(emptySlashCommands);
|
const commands = await api.commands(this.sessionId, this.machineId).catch(emptySlashCommands);
|
||||||
if (version !== this.requestVersion) return;
|
if (version !== this.requestVersion) return;
|
||||||
this.completions = commands
|
this.completions = commands
|
||||||
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
|
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
|
||||||
@@ -182,7 +188,7 @@ export class PromptEditor extends LitElement {
|
|||||||
...(command.description === undefined ? {} : { description: command.description }),
|
...(command.description === undefined ? {} : { description: command.description }),
|
||||||
}));
|
}));
|
||||||
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
||||||
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope }).catch(emptyFileSuggestions);
|
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions);
|
||||||
if (version !== this.requestVersion) return;
|
if (version !== this.requestVersion) return;
|
||||||
this.completions = files
|
this.completions = files
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
@@ -280,7 +286,8 @@ export class PromptEditor extends LitElement {
|
|||||||
const text = this.draft.trim();
|
const text = this.draft.trim();
|
||||||
if (text === "" || this.disabled) return;
|
if (text === "" || this.disabled) return;
|
||||||
this.draft = "";
|
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.completions = [];
|
||||||
this.onSend?.(text, this.canSteer || this.isCompacting ? streamingBehavior : undefined);
|
this.onSend?.(text, this.canSteer || this.isCompacting ? streamingBehavior : undefined);
|
||||||
}
|
}
|
||||||
@@ -288,6 +295,12 @@ export class PromptEditor extends LitElement {
|
|||||||
static override styles = promptEditorStyles;
|
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, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
||||||
const prefix = allPrefix ?? "@";
|
const prefix = allPrefix ?? "@";
|
||||||
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
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 type { WorkspaceLabelItem } from "../plugins/types";
|
||||||
import { formatCost, formatTokenCount } from "../utils/format";
|
import { formatCost, formatTokenCount } from "../utils/format";
|
||||||
import { statusBarStyles } from "./shared";
|
import { statusBarStyles } from "./shared";
|
||||||
@@ -9,6 +9,7 @@ import { renderWorkspaceLabel } from "./workspaceLabel";
|
|||||||
@customElement("status-bar")
|
@customElement("status-bar")
|
||||||
export class StatusBar extends LitElement {
|
export class StatusBar extends LitElement {
|
||||||
@property({ attribute: false }) status?: SessionStatus;
|
@property({ attribute: false }) status?: SessionStatus;
|
||||||
|
@property({ attribute: false }) machine?: Machine;
|
||||||
@property({ attribute: false }) workspace?: Workspace;
|
@property({ attribute: false }) workspace?: Workspace;
|
||||||
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
|
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export class StatusBar extends LitElement {
|
|||||||
const tokens = status.tokens;
|
const tokens = status.tokens;
|
||||||
return html`
|
return html`
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
|
<span>${this.machine?.name ?? "Local"}</span>
|
||||||
<span>${renderWorkspaceLabel(this.workspace?.label ?? "workspace", this.workspaceLabelItems, this.workspace?.path)}</span>
|
<span>${renderWorkspaceLabel(this.workspace?.label ?? "workspace", this.workspaceLabelItems, this.workspace?.path)}</span>
|
||||||
<span>↑${formatTokenCount(tokens.input)}</span>
|
<span>↑${formatTokenCount(tokens.input)}</span>
|
||||||
<span>↓${formatTokenCount(tokens.output)}</span>
|
<span>↓${formatTokenCount(tokens.output)}</span>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const COMMAND_RUN_POLL_INTERVAL_MS = 1000;
|
|||||||
@customElement("terminal-panel")
|
@customElement("terminal-panel")
|
||||||
export class TerminalPanel extends LitElement {
|
export class TerminalPanel extends LitElement {
|
||||||
@property({ attribute: false }) workspace: Workspace | undefined;
|
@property({ attribute: false }) workspace: Workspace | undefined;
|
||||||
|
@property() machineId = "local";
|
||||||
@property({ attribute: false }) selectedTerminalId: string | undefined;
|
@property({ attribute: false }) selectedTerminalId: string | undefined;
|
||||||
@property({ type: Boolean }) autoStart = false;
|
@property({ type: Boolean }) autoStart = false;
|
||||||
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
|
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
|
||||||
@@ -44,7 +45,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
private intersectionObserver: IntersectionObserver | undefined;
|
private intersectionObserver: IntersectionObserver | undefined;
|
||||||
private themeObserver: MutationObserver | undefined;
|
private themeObserver: MutationObserver | undefined;
|
||||||
private suppressTerminalInput = false;
|
private suppressTerminalInput = false;
|
||||||
private observedCwd: string | undefined;
|
private observedWorkspaceScope: string | undefined;
|
||||||
private loadedCwd: string | undefined;
|
private loadedCwd: string | undefined;
|
||||||
private autoStartConsumedCwd: string | undefined;
|
private autoStartConsumedCwd: string | undefined;
|
||||||
private commandRunPollTimer: number | undefined;
|
private commandRunPollTimer: number | undefined;
|
||||||
@@ -93,9 +94,9 @@ export class TerminalPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override willUpdate(changed: PropertyValues<this>): void {
|
override willUpdate(changed: PropertyValues<this>): void {
|
||||||
const cwd = this.workspace?.path;
|
const workspaceScope = this.workspace === undefined ? undefined : JSON.stringify([this.machineId, this.workspace.path]);
|
||||||
if (cwd !== this.observedCwd) {
|
if (workspaceScope !== this.observedWorkspaceScope) {
|
||||||
this.observedCwd = cwd;
|
this.observedWorkspaceScope = workspaceScope;
|
||||||
this.loadedCwd = undefined;
|
this.loadedCwd = undefined;
|
||||||
this.autoStartConsumedCwd = undefined;
|
this.autoStartConsumedCwd = undefined;
|
||||||
this.terminals = [];
|
this.terminals = [];
|
||||||
@@ -141,8 +142,8 @@ export class TerminalPanel extends LitElement {
|
|||||||
if (workspace === undefined) return;
|
if (workspace === undefined) return;
|
||||||
const shouldAutoStart = this.consumeAutoStart();
|
const shouldAutoStart = this.consumeAutoStart();
|
||||||
const [terminals, commandRuns] = await Promise.all([
|
const [terminals, commandRuns] = await Promise.all([
|
||||||
terminalsApi.terminals(workspace.projectId, workspace.id),
|
terminalsApi.terminals(workspace.projectId, workspace.id, this.machineId),
|
||||||
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }),
|
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }, this.machineId),
|
||||||
]);
|
]);
|
||||||
this.terminals = terminals;
|
this.terminals = terminals;
|
||||||
this.commandRuns = commandRuns;
|
this.commandRuns = commandRuns;
|
||||||
@@ -198,7 +199,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
try {
|
try {
|
||||||
const size = this.measureTerminalSize() ?? DEFAULT_TERMINAL_SIZE;
|
const size = this.measureTerminalSize() ?? DEFAULT_TERMINAL_SIZE;
|
||||||
const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, size);
|
const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, size, this.machineId);
|
||||||
this.terminals = [...this.terminals, terminal];
|
this.terminals = [...this.terminals, terminal];
|
||||||
this.selectTerminal(terminal.id);
|
this.selectTerminal(terminal.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -210,7 +211,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
try {
|
try {
|
||||||
if (this.workspace === undefined) return;
|
if (this.workspace === undefined) return;
|
||||||
await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id);
|
await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id, this.machineId);
|
||||||
const next = this.terminals.filter((terminal) => terminal.id !== id);
|
const next = this.terminals.filter((terminal) => terminal.id !== id);
|
||||||
this.terminals = next;
|
this.terminals = next;
|
||||||
if (this.selectedId === id || this.selectedTerminalId === id) {
|
if (this.selectedId === id || this.selectedTerminalId === id) {
|
||||||
@@ -242,7 +243,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
const workspace = this.workspace;
|
const workspace = this.workspace;
|
||||||
if (workspace === undefined) return;
|
if (workspace === undefined) return;
|
||||||
try {
|
try {
|
||||||
const commandRuns = await terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id });
|
const commandRuns = await terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }, this.machineId);
|
||||||
this.commandRuns = commandRuns;
|
this.commandRuns = commandRuns;
|
||||||
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => commandRuns.some((run) => run.id === runId && isCommandRunPending(run)));
|
this.cancellingRunIds = this.cancellingRunIds.filter((runId) => commandRuns.some((run) => run.id === runId && isCommandRunPending(run)));
|
||||||
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
|
this.updateCommandRunPolling(this.hasPendingCommandRuns(commandRuns));
|
||||||
@@ -271,7 +272,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
this.cancellingRunIds = [...this.cancellingRunIds, run.id];
|
this.cancellingRunIds = [...this.cancellingRunIds, run.id];
|
||||||
try {
|
try {
|
||||||
await terminalsApi.cancelCommandRun(run.id);
|
await terminalsApi.cancelCommandRun(run.id, this.machineId);
|
||||||
await this.loadCommandRuns();
|
await this.loadCommandRuns();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error instanceof Error ? error.message : String(error);
|
this.error = error instanceof Error ? error.message : String(error);
|
||||||
@@ -285,7 +286,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
this.continuingTerminalIds = [...this.continuingTerminalIds, id];
|
this.continuingTerminalIds = [...this.continuingTerminalIds, id];
|
||||||
try {
|
try {
|
||||||
const terminal = await terminalsApi.continueTerminal(this.workspace.projectId, this.workspace.id, id);
|
const terminal = await terminalsApi.continueTerminal(this.workspace.projectId, this.workspace.id, id, this.machineId);
|
||||||
this.terminals = this.terminals.map((item) => item.id === id ? terminal : item);
|
this.terminals = this.terminals.map((item) => item.id === id ? terminal : item);
|
||||||
if (this.socket === undefined) this.disposeTerminalView();
|
if (this.socket === undefined) this.disposeTerminalView();
|
||||||
this.fitAndNotify();
|
this.fitAndNotify();
|
||||||
@@ -320,7 +321,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private connectSocket(projectId: string, workspaceId: string, terminalId: string, terminal: Terminal, initialSize: TerminalSize | undefined): void {
|
private connectSocket(projectId: string, workspaceId: string, terminalId: string, terminal: Terminal, initialSize: TerminalSize | undefined): void {
|
||||||
const socket = terminalSocket(projectId, workspaceId, terminalId, initialSize);
|
const socket = terminalSocket(projectId, workspaceId, terminalId, initialSize, this.machineId);
|
||||||
socket.binaryType = "arraybuffer";
|
socket.binaryType = "arraybuffer";
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.addEventListener("open", () => { this.fitAndNotify(); });
|
socket.addEventListener("open", () => { this.fitAndNotify(); });
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { LitElement, css, html } from "lit";
|
import { LitElement, css, html } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import type { Project, SessionInfo, Workspace } from "../../api";
|
import type { Machine, Project, SessionInfo, Workspace } from "../../api";
|
||||||
import type { NavigationSection } from "../../appShell/navigationState";
|
import type { NavigationSection } from "../../appShell/navigationState";
|
||||||
|
|
||||||
@customElement("app-context-bar")
|
@customElement("app-context-bar")
|
||||||
export class AppContextBar extends LitElement {
|
export class AppContextBar extends LitElement {
|
||||||
|
@property({ attribute: false }) machine?: Machine;
|
||||||
@property({ attribute: false }) project?: Project;
|
@property({ attribute: false }) project?: Project;
|
||||||
@property({ attribute: false }) workspace?: Workspace;
|
@property({ attribute: false }) workspace?: Workspace;
|
||||||
@property({ attribute: false }) session?: SessionInfo;
|
@property({ attribute: false }) session?: SessionInfo;
|
||||||
@@ -35,6 +36,7 @@ export class AppContextBar extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
|
const machineLabel = machineContextLabel(this.machine);
|
||||||
const projectLabel = projectContextLabel(this.project);
|
const projectLabel = projectContextLabel(this.project);
|
||||||
const workspaceLabel = workspaceContextLabel(this.workspace);
|
const workspaceLabel = workspaceContextLabel(this.workspace);
|
||||||
const sessionLabel = sessionContextLabel(this.session);
|
const sessionLabel = sessionContextLabel(this.session);
|
||||||
@@ -42,6 +44,12 @@ export class AppContextBar extends LitElement {
|
|||||||
<nav class=${this.contextBarClass()} aria-label="Current location">
|
<nav class=${this.contextBarClass()} aria-label="Current location">
|
||||||
<span class="context-bar-label">Location</span>
|
<span class="context-bar-label">Location</span>
|
||||||
<ol class="context-items" @scroll=${this.onContextScroll}>
|
<ol class="context-items" @scroll=${this.onContextScroll}>
|
||||||
|
<li class="context-item">
|
||||||
|
<button type="button" class=${this.machine === undefined ? "context-chip empty" : "context-chip"} title=${machineContextTitle(this.machine)} aria-label=${`Machine: ${machineLabel}. Open machine selection.`} @click=${() => { this.onOpenSection?.("machines"); }}>
|
||||||
|
<span class="context-kind">Machine</span>
|
||||||
|
<span class="context-value">${machineLabel}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li class="context-item">
|
<li class="context-item">
|
||||||
<button type="button" class=${this.project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(this.project)} aria-label=${`Project: ${projectLabel}. Open project selection.`} @click=${() => { this.onOpenSection?.("projects"); }}>
|
<button type="button" class=${this.project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(this.project)} aria-label=${`Project: ${projectLabel}. Open project selection.`} @click=${() => { this.onOpenSection?.("projects"); }}>
|
||||||
<span class="context-kind">Project</span>
|
<span class="context-kind">Project</span>
|
||||||
@@ -150,6 +158,14 @@ export class AppContextBar extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function projectContextLabel(project: Project | undefined): string {
|
||||||
return project?.name ?? "No project";
|
return project?.name ?? "No project";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Machine } from "../../api";
|
||||||
|
import { shouldShowMachinesSection } from "./AppNavigationPanel";
|
||||||
|
|
||||||
|
describe("shouldShowMachinesSection", () => {
|
||||||
|
it("hides the machines section when there is no machine choice", () => {
|
||||||
|
expect(shouldShowMachinesSection([])).toBe(false);
|
||||||
|
expect(shouldShowMachinesSection([machine("local")])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the machines section when there are multiple machines", () => {
|
||||||
|
expect(shouldShowMachinesSection([machine("local"), machine("remote-a")])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function machine(id: string): Machine {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
kind: id === "local" ? "local" : "remote",
|
||||||
|
createdAt: "2026-06-04T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { LitElement, css, html } from "lit";
|
import { LitElement, css, html } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import type { Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
|
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
|
||||||
import type { WorkspaceLabelItem } from "../../plugins/types";
|
import type { WorkspaceLabelItem } from "../../plugins/types";
|
||||||
|
import "../MachineList";
|
||||||
import "../ProjectList";
|
import "../ProjectList";
|
||||||
import "../WorkspaceList";
|
import "../WorkspaceList";
|
||||||
import "../SessionList";
|
import "../SessionList";
|
||||||
|
|
||||||
@customElement("app-navigation-panel")
|
@customElement("app-navigation-panel")
|
||||||
export class AppNavigationPanel extends LitElement {
|
export class AppNavigationPanel extends LitElement {
|
||||||
|
@property({ attribute: false }) machines: Machine[] = [];
|
||||||
|
@property({ attribute: false }) selectedMachine?: Machine;
|
||||||
|
@property({ attribute: false }) machineStatuses: Record<string, MachineHealth> = {};
|
||||||
@property({ attribute: false }) projects: Project[] = [];
|
@property({ attribute: false }) projects: Project[] = [];
|
||||||
@property({ attribute: false }) selectedProject?: Project;
|
@property({ attribute: false }) selectedProject?: Project;
|
||||||
@property({ attribute: false }) workspaces: Workspace[] = [];
|
@property({ attribute: false }) workspaces: Workspace[] = [];
|
||||||
@@ -22,11 +26,13 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||||
@property({ attribute: false }) refreshControl: unknown;
|
@property({ attribute: false }) refreshControl: unknown;
|
||||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||||
|
@property({ type: Boolean }) machinesCollapsed = false;
|
||||||
@property({ type: Boolean }) projectsCollapsed = false;
|
@property({ type: Boolean }) projectsCollapsed = false;
|
||||||
@property({ type: Boolean }) workspacesCollapsed = false;
|
@property({ type: Boolean }) workspacesCollapsed = false;
|
||||||
@property({ type: Boolean }) sessionsCollapsed = false;
|
@property({ type: Boolean }) sessionsCollapsed = false;
|
||||||
@property({ type: Boolean }) canStartSession = false;
|
@property({ type: Boolean }) canStartSession = false;
|
||||||
@property({ attribute: false }) onShowActions?: () => void;
|
@property({ attribute: false }) onShowActions?: () => void;
|
||||||
|
@property({ attribute: false }) onToggleMachines?: () => void;
|
||||||
@property({ attribute: false }) onToggleProjects?: () => void;
|
@property({ attribute: false }) onToggleProjects?: () => void;
|
||||||
@property({ attribute: false }) onToggleWorkspaces?: () => void;
|
@property({ attribute: false }) onToggleWorkspaces?: () => void;
|
||||||
@property({ attribute: false }) onToggleSessions?: () => void;
|
@property({ attribute: false }) onToggleSessions?: () => void;
|
||||||
@@ -42,6 +48,8 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
@property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise<void>;
|
@property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return html`
|
return html`
|
||||||
@@ -52,6 +60,18 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
|
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
${shouldShowMachinesSection(this.machines) ? html`
|
||||||
|
<machine-list
|
||||||
|
.machines=${this.machines}
|
||||||
|
.selected=${this.selectedMachine}
|
||||||
|
.statuses=${this.machineStatuses}
|
||||||
|
.collapsible=${this.collapsible}
|
||||||
|
.collapsed=${this.machinesCollapsed}
|
||||||
|
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
||||||
|
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||||
|
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||||
|
></machine-list>
|
||||||
|
` : null}
|
||||||
<project-list
|
<project-list
|
||||||
.projects=${this.projects}
|
.projects=${this.projects}
|
||||||
.selected=${this.selectedProject}
|
.selected=${this.selectedProject}
|
||||||
@@ -102,14 +122,20 @@ export class AppNavigationPanel extends LitElement {
|
|||||||
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
|
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
|
||||||
:host([collapsible]) header { display: none; }
|
:host([collapsible]) header { display: none; }
|
||||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
|
machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
|
||||||
session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||||
|
:host([collapsible]) machine-list,
|
||||||
:host([collapsible]) project-list,
|
:host([collapsible]) project-list,
|
||||||
:host([collapsible]) workspace-list,
|
:host([collapsible]) workspace-list,
|
||||||
:host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
|
:host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
|
||||||
|
:host([collapsible]) machine-list[collapsed],
|
||||||
:host([collapsible]) project-list[collapsed],
|
:host([collapsible]) project-list[collapsed],
|
||||||
:host([collapsible]) workspace-list[collapsed],
|
:host([collapsible]) workspace-list[collapsed],
|
||||||
:host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
|
:host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
|
||||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldShowMachinesSection(machines: readonly Machine[]): boolean {
|
||||||
|
return machines.length > 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
|
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
|
||||||
import { isWorkspaceActivityActive } from "../../../shared/activity";
|
import { isWorkspaceActivityActive } from "../../../shared/activity";
|
||||||
import type { GetState, SetState } from "./types";
|
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||||
|
|
||||||
export interface ActivityControllerDependencies {
|
export interface ActivityControllerDependencies {
|
||||||
api?: Pick<typeof defaultApi, "workspaceActivity">;
|
api?: Pick<typeof defaultApi, "workspaceActivity">;
|
||||||
@@ -14,7 +14,7 @@ export class ActivityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
const snapshot = await this.api.workspaceActivity();
|
const snapshot = await this.api.workspaceActivity(selectedMachineId(this.getState()));
|
||||||
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
|
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
|
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
|
||||||
import type { GetState, SetState } from "./types";
|
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||||
|
|
||||||
export interface AuthControllerDependencies {
|
export interface AuthControllerDependencies {
|
||||||
api?: typeof defaultApi;
|
api?: typeof defaultApi;
|
||||||
@@ -43,7 +43,7 @@ export class AuthController {
|
|||||||
|
|
||||||
async chooseLoginMethod(authType: AuthType): Promise<void> {
|
async chooseLoginMethod(authType: AuthType): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { providers } = await this.api.authProviders({ mode: "login", authType });
|
const { providers } = await this.api.authProviders({ mode: "login", authType, machineId: selectedMachineId(this.getState()) });
|
||||||
this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } });
|
this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -79,7 +79,7 @@ export class AuthController {
|
|||||||
delete clean.error;
|
delete clean.error;
|
||||||
this.setState({ authDialog: { ...clean, saving: true } });
|
this.setState({ authDialog: { ...clean, saving: true } });
|
||||||
try {
|
try {
|
||||||
await this.api.saveApiKey(dialog.provider.id, key);
|
await this.api.saveApiKey(dialog.provider.id, key, selectedMachineId(this.getState()));
|
||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
void this.refreshStatus();
|
void this.refreshStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -89,11 +89,11 @@ export class AuthController {
|
|||||||
|
|
||||||
async openLogout(providerId?: string): Promise<void> {
|
async openLogout(providerId?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { providers } = await this.api.authProviders({ mode: "logout" });
|
const { providers } = await this.api.authProviders({ mode: "logout", machineId: selectedMachineId(this.getState()) });
|
||||||
if (providerId !== undefined && providerId !== "") {
|
if (providerId !== undefined && providerId !== "") {
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
if (provider !== undefined) await this.logoutProvider(provider.id);
|
if (provider !== undefined && !this.rejectRemoteOAuth("logout", provider)) await this.logoutProvider(provider.id);
|
||||||
else this.setState({ error: `No stored credentials for ${providerId}` });
|
else if (provider === undefined) this.setState({ error: `No stored credentials for ${providerId}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState({ authDialog: { step: "logout", providers } });
|
this.setState({ authDialog: { step: "logout", providers } });
|
||||||
@@ -103,8 +103,11 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async logoutProvider(providerId: string): Promise<void> {
|
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 {
|
try {
|
||||||
await this.api.logoutProvider(providerId);
|
await this.api.logoutProvider(providerId, selectedMachineId(this.getState()));
|
||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
void this.refreshStatus();
|
void this.refreshStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -130,7 +133,7 @@ export class AuthController {
|
|||||||
delete clean.error;
|
delete clean.error;
|
||||||
this.setState({ authDialog: { ...clean, responding: true } });
|
this.setState({ authDialog: { ...clean, responding: true } });
|
||||||
try {
|
try {
|
||||||
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue);
|
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue, selectedMachineId(this.getState()));
|
||||||
this.updateOAuthFlow(flow);
|
this.updateOAuthFlow(flow);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
|
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
|
||||||
@@ -145,7 +148,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
try {
|
try {
|
||||||
await this.api.cancelOAuthFlow(dialog.flow.flowId);
|
await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState()));
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cancel. The dialog closes either way.
|
// Best-effort cancel. The dialog closes either way.
|
||||||
}
|
}
|
||||||
@@ -159,7 +162,7 @@ export class AuthController {
|
|||||||
|
|
||||||
private async openLoginProvider(providerId: string): Promise<void> {
|
private async openLoginProvider(providerId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { providers } = await this.api.authProviders({ mode: "login" });
|
const { providers } = await this.api.authProviders({ mode: "login", machineId: selectedMachineId(this.getState()) });
|
||||||
const exact = providers.filter((provider) => provider.id === providerId);
|
const exact = providers.filter((provider) => provider.id === providerId);
|
||||||
if (exact.length === 0) {
|
if (exact.length === 0) {
|
||||||
this.setState({ error: `Auth provider not found: ${providerId}` });
|
this.setState({ error: `Auth provider not found: ${providerId}` });
|
||||||
@@ -179,8 +182,9 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startOAuth(provider: AuthProviderOption): Promise<void> {
|
private async startOAuth(provider: AuthProviderOption): Promise<void> {
|
||||||
|
if (this.rejectRemoteOAuth("login", provider)) return;
|
||||||
try {
|
try {
|
||||||
const flow = await this.api.startOAuthLogin(provider.id);
|
const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState()));
|
||||||
this.updateOAuthFlow(flow);
|
this.updateOAuthFlow(flow);
|
||||||
this.startPolling(flow.flowId);
|
this.startPolling(flow.flowId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -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 {
|
private updateOAuthFlow(flow: OAuthFlowState): void {
|
||||||
if (flow.status === "complete") {
|
if (flow.status === "complete") {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
@@ -224,7 +236,7 @@ export class AuthController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.updateOAuthFlow(await this.api.oauthFlow(flowId));
|
this.updateOAuthFlow(await this.api.oauthFlow(flowId, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
this.setState({ authDialog: { ...dialog, error: String(error) } });
|
this.setState({ authDialog: { ...dialog, error: String(error) } });
|
||||||
@@ -235,7 +247,7 @@ export class AuthController {
|
|||||||
const sessionId = this.sessionId();
|
const sessionId = this.sessionId();
|
||||||
if (sessionId === undefined) return;
|
if (sessionId === undefined) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.status(sessionId));
|
this.applyStatus(await this.api.status(sessionId, selectedMachineId(this.getState())));
|
||||||
} catch {
|
} catch {
|
||||||
// Status refresh is opportunistic after login completes.
|
// Status refresh is opportunistic after login completes.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||||
|
|
||||||
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
|
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
|
||||||
|
|
||||||
@@ -12,9 +12,10 @@ export class FileExplorerController {
|
|||||||
const workspace = this.getState().selectedWorkspace;
|
const workspace = this.getState().selectedWorkspace;
|
||||||
if (project === undefined || workspace === undefined) return;
|
if (project === undefined || workspace === undefined) return;
|
||||||
try {
|
try {
|
||||||
const root = await api.workspaceTree(project.id, workspace.id);
|
const machineId = selectedMachineId(this.getState());
|
||||||
|
const root = await api.workspaceTree(project.id, workspace.id, "", machineId);
|
||||||
const expanded = { ...this.getState().expandedDirs };
|
const expanded = { ...this.getState().expandedDirs };
|
||||||
await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path)).entries; }));
|
await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path, machineId)).entries; }));
|
||||||
this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" });
|
this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -30,7 +31,7 @@ export class FileExplorerController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await api.workspaceTree(project.id, workspace.id, path);
|
const response = await api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState()));
|
||||||
this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" });
|
this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -50,7 +51,7 @@ export class FileExplorerController {
|
|||||||
if (project === undefined || workspace === undefined) return;
|
if (project === undefined || workspace === undefined) return;
|
||||||
this.setState({ selectedFilePath: path, selectedFileContent: undefined });
|
this.setState({ selectedFilePath: path, selectedFileContent: undefined });
|
||||||
try {
|
try {
|
||||||
const content = await api.workspaceFile(project.id, workspace.id, path);
|
const content = await api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState()));
|
||||||
if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" });
|
if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.getState().selectedFilePath !== path) return;
|
if (this.getState().selectedFilePath !== path) return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||||
|
|
||||||
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
|
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export class GitController {
|
|||||||
const workspace = this.getState().selectedWorkspace;
|
const workspace = this.getState().selectedWorkspace;
|
||||||
if (project === undefined || workspace === undefined) return;
|
if (project === undefined || workspace === undefined) return;
|
||||||
try {
|
try {
|
||||||
const status = await api.gitStatus(project.id, workspace.id);
|
const status = await api.gitStatus(project.id, workspace.id, selectedMachineId(this.getState()));
|
||||||
this.setState({ gitStatus: status, gitStale: false, error: "" });
|
this.setState({ gitStatus: status, gitStale: false, error: "" });
|
||||||
const selectedDiffPath = this.getState().selectedDiffPath;
|
const selectedDiffPath = this.getState().selectedDiffPath;
|
||||||
if (selectedDiffPath !== undefined) {
|
if (selectedDiffPath !== undefined) {
|
||||||
@@ -52,8 +52,8 @@ export class GitController {
|
|||||||
if (project === undefined || workspace === undefined) return;
|
if (project === undefined || workspace === undefined) return;
|
||||||
try {
|
try {
|
||||||
const [selectedDiff, selectedStagedDiff] = await Promise.all([
|
const [selectedDiff, selectedStagedDiff] = await Promise.all([
|
||||||
api.gitDiff(project.id, workspace.id, { path }),
|
api.gitDiff(project.id, workspace.id, { path }, selectedMachineId(this.getState())),
|
||||||
api.gitDiff(project.id, workspace.id, { path, staged: true }),
|
api.gitDiff(project.id, workspace.id, { path, staged: true }, selectedMachineId(this.getState())),
|
||||||
]);
|
]);
|
||||||
this.setState({ selectedDiff, selectedStagedDiff, error: "" });
|
this.setState({ selectedDiff, selectedStagedDiff, error: "" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { api, type Machine, type MachineHealth } from "../api";
|
||||||
|
import { initialAppState, type AppState } from "../appState";
|
||||||
|
import { MachineController } from "./machineController";
|
||||||
|
|
||||||
|
const localMachine: Machine = {
|
||||||
|
id: "local",
|
||||||
|
name: "Local",
|
||||||
|
kind: "local",
|
||||||
|
createdAt: "1970-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "1970-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const remoteMachine: Machine = {
|
||||||
|
id: "remote-1",
|
||||||
|
name: "Remote",
|
||||||
|
kind: "remote",
|
||||||
|
baseUrl: "http://remote.example.test:8504",
|
||||||
|
createdAt: "2026-05-26T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const offlineHealth: MachineHealth = {
|
||||||
|
machineId: remoteMachine.id,
|
||||||
|
ok: false,
|
||||||
|
checkedAt: "2026-05-26T00:00:01.000Z",
|
||||||
|
status: "offline",
|
||||||
|
error: "Remote machine request timed out",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("MachineController", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the local machine when the routed remote machine is offline", async () => {
|
||||||
|
let state: AppState = initialAppState();
|
||||||
|
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||||
|
const updateUrl = vi.fn();
|
||||||
|
const projects = { loadProjects: vi.fn() };
|
||||||
|
|
||||||
|
vi.spyOn(api, "machines").mockResolvedValue([localMachine, remoteMachine]);
|
||||||
|
vi.spyOn(api, "health").mockImplementation((machineId: string) => Promise.resolve(
|
||||||
|
machineId === remoteMachine.id
|
||||||
|
? offlineHealth
|
||||||
|
: { machineId: "local", ok: true, checkedAt: "2026-05-26T00:00:01.000Z", status: "online" },
|
||||||
|
));
|
||||||
|
|
||||||
|
const controller = new MachineController(() => state, setState, updateUrl, projects);
|
||||||
|
|
||||||
|
await controller.loadMachines(remoteMachine.id);
|
||||||
|
|
||||||
|
expect(state.selectedMachine).toEqual(localMachine);
|
||||||
|
expect(state.machineStatuses[remoteMachine.id]).toEqual(offlineHealth);
|
||||||
|
expect(state.error).toContain("Remote is offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records offline health when the routed remote health request rejects", async () => {
|
||||||
|
let state: AppState = initialAppState();
|
||||||
|
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||||
|
const updateUrl = vi.fn();
|
||||||
|
const projects = { loadProjects: vi.fn() };
|
||||||
|
|
||||||
|
vi.spyOn(api, "machines").mockResolvedValue([localMachine, remoteMachine]);
|
||||||
|
vi.spyOn(api, "health").mockRejectedValue(new Error("Internal Server Error"));
|
||||||
|
|
||||||
|
const controller = new MachineController(() => state, setState, updateUrl, projects);
|
||||||
|
|
||||||
|
await controller.loadMachines(remoteMachine.id);
|
||||||
|
|
||||||
|
expect(state.selectedMachine).toEqual(localMachine);
|
||||||
|
expect(state.machineStatuses[remoteMachine.id]).toMatchObject({ machineId: remoteMachine.id, ok: false, status: "offline", error: "Internal Server Error" });
|
||||||
|
expect(state.error).toContain("Remote is offline");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { api, type Machine, type MachineHealth } from "../api";
|
||||||
|
import { resetWorkspaceScopedState } from "../appState";
|
||||||
|
import type { GetState, SetState, UpdateUrl } from "./types";
|
||||||
|
import type { ProjectController } from "./projectController";
|
||||||
|
|
||||||
|
export class MachineController {
|
||||||
|
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl, private readonly projects: Pick<ProjectController, "loadProjects">) {}
|
||||||
|
|
||||||
|
async loadMachines(routeMachineId?: string): Promise<void> {
|
||||||
|
this.setState({ error: "", isLoadingMachines: true });
|
||||||
|
try {
|
||||||
|
const machines = await api.machines();
|
||||||
|
const selectedMachine = await this.selectInitialMachine(machines, routeMachineId);
|
||||||
|
this.setState({ machines, selectedMachine });
|
||||||
|
void this.refreshMachineHealthFor(machines);
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({ error: String(error) });
|
||||||
|
} finally {
|
||||||
|
this.setState({ isLoadingMachines: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectMachine(machine: Machine, options: { updateUrl?: boolean | undefined } = {}): Promise<void> {
|
||||||
|
if (this.getState().selectedMachine?.id === machine.id) return;
|
||||||
|
this.setState({
|
||||||
|
selectedMachine: machine,
|
||||||
|
projects: [],
|
||||||
|
workspaces: [],
|
||||||
|
selectedProject: undefined,
|
||||||
|
selectedWorkspace: undefined,
|
||||||
|
selectedSession: undefined,
|
||||||
|
messages: [],
|
||||||
|
messagePageStart: 0,
|
||||||
|
messagePageTotal: 0,
|
||||||
|
status: undefined,
|
||||||
|
activity: undefined,
|
||||||
|
sessionStatuses: {},
|
||||||
|
sessionActivities: {},
|
||||||
|
workspaceActivities: {},
|
||||||
|
workspacesByProjectId: {},
|
||||||
|
workspaceDeletionRuns: {},
|
||||||
|
activeTerminalCount: 0,
|
||||||
|
...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 selectInitialMachine(machines: Machine[], routeMachineId?: string): Promise<Machine | undefined> {
|
||||||
|
const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local"));
|
||||||
|
if (requestedMachine?.kind !== "remote") return requestedMachine ?? this.localMachine(machines);
|
||||||
|
|
||||||
|
const health = await this.safeRemoteHealth(requestedMachine);
|
||||||
|
if (health.ok) return requestedMachine;
|
||||||
|
|
||||||
|
const local = this.localMachine(machines);
|
||||||
|
this.setState({
|
||||||
|
error: `${requestedMachine.name} is offline; showing ${local?.name ?? "another machine"} instead.`,
|
||||||
|
machineStatuses: { ...this.getState().machineStatuses, [health.machineId]: health },
|
||||||
|
});
|
||||||
|
return local ?? requestedMachine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safeRemoteHealth(machine: Machine): Promise<MachineHealth> {
|
||||||
|
try {
|
||||||
|
return await api.health(machine.id);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
machineId: machine.id,
|
||||||
|
ok: false,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
status: "offline",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private localMachine(machines: Machine[]): Machine | undefined {
|
||||||
|
return machines.find((machine) => machine.id === "local") ?? machines[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { GetState, SetState } from "./types";
|
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||||
import type { WorkspaceController } from "./workspaceController";
|
import type { WorkspaceController } from "./workspaceController";
|
||||||
|
|
||||||
export class ProjectController {
|
export class ProjectController {
|
||||||
@@ -8,7 +8,7 @@ export class ProjectController {
|
|||||||
async loadProjects() {
|
async loadProjects() {
|
||||||
this.setState({ error: "", isLoadingProjects: true });
|
this.setState({ error: "", isLoadingProjects: true });
|
||||||
try {
|
try {
|
||||||
const projects = await api.projects();
|
const projects = await api.projects(selectedMachineId(this.getState()));
|
||||||
const projectIds = new Set(projects.map((project) => project.id));
|
const projectIds = new Set(projects.map((project) => project.id));
|
||||||
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId)));
|
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId)));
|
||||||
this.setState({ projects, workspacesByProjectId });
|
this.setState({ projects, workspacesByProjectId });
|
||||||
@@ -22,7 +22,7 @@ export class ProjectController {
|
|||||||
async addProject(path: string, create?: boolean) {
|
async addProject(path: string, create?: boolean) {
|
||||||
if (path.trim() === "") return;
|
if (path.trim() === "") return;
|
||||||
try {
|
try {
|
||||||
const project = await api.addProject(path.trim(), undefined, create);
|
const project = await api.addProject(path.trim(), undefined, create, selectedMachineId(this.getState()));
|
||||||
const projects = this.getState().projects;
|
const projects = this.getState().projects;
|
||||||
this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project], projectDialogOpen: false });
|
this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project], projectDialogOpen: false });
|
||||||
await this.workspaces.selectProject(project);
|
await this.workspaces.selectProject(project);
|
||||||
@@ -33,7 +33,7 @@ export class ProjectController {
|
|||||||
|
|
||||||
async closeProject(projectId: string) {
|
async closeProject(projectId: string) {
|
||||||
try {
|
try {
|
||||||
await api.closeProject(projectId);
|
await api.closeProject(projectId, selectedMachineId(this.getState()));
|
||||||
this.workspaces.forgetProject(projectId);
|
this.workspaces.forgetProject(projectId);
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
this.setState({ projects: state.projects.filter((p) => p.id !== projectId) });
|
this.setState({ projects: state.projects.filter((p) => p.id !== projectId) });
|
||||||
|
|||||||
@@ -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 { api as defaultApi, type MessagePage, type SessionActivity, type SessionInfo, type SessionStatus, type Workspace } from "../api";
|
||||||
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
|
import { machineSessionKey } from "../machineKeys";
|
||||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||||
import { SessionController, type SessionEventSocket } from "./sessionController";
|
import { SessionController, type SessionEventSocket } from "./sessionController";
|
||||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||||
@@ -170,7 +171,7 @@ describe("SessionController", () => {
|
|||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||||
rememberCachedNewSession(oldSession);
|
rememberCachedNewSession(oldSession);
|
||||||
saveDraft(oldSession.id, "draft text");
|
saveDraft(sessionKey(oldSession.id), "draft text");
|
||||||
|
|
||||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] };
|
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] };
|
||||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||||
@@ -197,8 +198,8 @@ describe("SessionController", () => {
|
|||||||
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
||||||
expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]);
|
expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]);
|
||||||
expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]);
|
expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]);
|
||||||
expect(loadDraft(oldSession.id)).toBe("");
|
expect(loadDraft(sessionKey(oldSession.id))).toBe("");
|
||||||
expect(loadDraft(replacementSession.id)).toBe("draft text");
|
expect(loadDraft(sessionKey(replacementSession.id))).toBe("draft text");
|
||||||
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]);
|
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]);
|
||||||
expect(urlUpdates).toEqual([{ replace: true }]);
|
expect(urlUpdates).toEqual([{ replace: true }]);
|
||||||
});
|
});
|
||||||
@@ -232,7 +233,7 @@ describe("SessionController", () => {
|
|||||||
await controller.respondToCommand("r1", "m1");
|
await controller.respondToCommand("r1", "m1");
|
||||||
|
|
||||||
expect(state.commandDialog).toBeUndefined();
|
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 () => {
|
it("forgets the selected active session when archiving leaves only archived sessions", async () => {
|
||||||
@@ -315,3 +316,7 @@ describe("SessionController", () => {
|
|||||||
expect(urlUpdates).toEqual([undefined]);
|
expect(urlUpdates).toEqual([undefined]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function sessionKey(sessionId: string): string {
|
||||||
|
return machineSessionKey("local", sessionId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ import { api as defaultApi, type CommandResult, type SessionActivity, type Sessi
|
|||||||
import type { AppState } from "../appState";
|
import type { AppState } from "../appState";
|
||||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||||
import { textMessage } from "../chatMessages";
|
import { textMessage } from "../chatMessages";
|
||||||
|
import { machineSessionKey } from "../machineKeys";
|
||||||
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
|
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
|
||||||
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||||
import { isShellInput } from "../inputModes";
|
import { isShellInput } from "../inputModes";
|
||||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
||||||
import { isSessionActive } from "../../../shared/activity";
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||||
|
|
||||||
const MESSAGE_PAGE_SIZE = 100;
|
const MESSAGE_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export interface SessionEventSocket {
|
export interface SessionEventSocket {
|
||||||
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void;
|
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
|
||||||
setHandler(onEvent: (event: SessionUiEvent) => void): void;
|
setHandler(onEvent: (event: SessionUiEvent) => void): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
@@ -67,7 +68,7 @@ export class SessionController {
|
|||||||
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
|
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const cwd = state.selectedSession?.cwd ?? state.selectedWorkspace?.path;
|
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();
|
this.clearActiveSession();
|
||||||
if (options?.updateUrl !== false) this.updateUrl();
|
if (options?.updateUrl !== false) this.updateUrl();
|
||||||
}
|
}
|
||||||
@@ -82,9 +83,10 @@ export class SessionController {
|
|||||||
const workspace = this.getState().selectedWorkspace;
|
const workspace = this.getState().selectedWorkspace;
|
||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
try {
|
try {
|
||||||
const session = await this.api.startSession(workspace.path);
|
const machineId = selectedMachineId(this.getState());
|
||||||
rememberCachedNewSession(session);
|
const session = await this.api.startSession(workspace.path, machineId);
|
||||||
const cachedSession = markCachedNewSessionInfo(session);
|
rememberCachedNewSession(session, machineId);
|
||||||
|
const cachedSession = markCachedNewSessionInfo(session, machineId);
|
||||||
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
|
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
|
||||||
await this.selectSession(cachedSession);
|
await this.selectSession(cachedSession);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -93,16 +95,17 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
preferredSession(cwd: string, sessions: SessionInfo[], targetSessionId: string | undefined): SessionInfo | undefined {
|
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 }) {
|
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;
|
const seq = ++this.selectionSeq;
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.catchupStreamSessionId = undefined;
|
this.catchupStreamSessionId = undefined;
|
||||||
this.clearPendingTranscriptEvents();
|
this.clearPendingTranscriptEvents();
|
||||||
const cached = this.transcripts.cachedView(session.id);
|
const transcriptKey = this.sessionCacheKey(session.id);
|
||||||
|
const cached = this.transcripts.cachedView(transcriptKey);
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedSession: session,
|
selectedSession: session,
|
||||||
...cached,
|
...cached,
|
||||||
@@ -113,9 +116,9 @@ export class SessionController {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (session.archived === true) {
|
if (session.archived === true) {
|
||||||
const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
|
const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
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 });
|
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||||
if (options?.updateUrl !== false) this.updateUrl();
|
if (options?.updateUrl !== false) this.updateUrl();
|
||||||
return;
|
return;
|
||||||
@@ -125,10 +128,11 @@ export class SessionController {
|
|||||||
session.id,
|
session.id,
|
||||||
(event) => buffered.push(event),
|
(event) => buffered.push(event),
|
||||||
() => { void this.refreshSelectedSession(session.id); },
|
() => { void this.refreshSelectedSession(session.id); },
|
||||||
|
selectedMachineId(this.getState()),
|
||||||
);
|
);
|
||||||
const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), this.api.status(session.id)]);
|
const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session.id, selectedMachineId(this.getState()))]);
|
||||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
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;
|
const isReceivingPartialStream = status.isStreaming;
|
||||||
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
|
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
|
||||||
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] });
|
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] });
|
||||||
@@ -152,9 +156,9 @@ export class SessionController {
|
|||||||
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
|
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
|
||||||
this.setState({ isLoadingEarlierMessages: true });
|
this.setState({ isLoadingEarlierMessages: true });
|
||||||
try {
|
try {
|
||||||
const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE });
|
const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||||
if (this.getState().selectedSession?.id !== session.id) return;
|
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);
|
this.setState(history);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -170,7 +174,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
await this.api.prompt(session.id, text, streamingBehavior);
|
await this.api.prompt(session.id, text, streamingBehavior, selectedMachineId(this.getState()));
|
||||||
this.markCachedNewSessionPersisted(session);
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -182,7 +186,7 @@ export class SessionController {
|
|||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
await this.api.shell(session.id, text);
|
await this.api.shell(session.id, text, selectedMachineId(this.getState()));
|
||||||
this.markCachedNewSessionPersisted(session);
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||||
@@ -194,7 +198,7 @@ export class SessionController {
|
|||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
this.applyCommandResult(await this.api.runCommand(session.id, text));
|
this.applyCommandResult(await this.api.runCommand(session.id, text, selectedMachineId(this.getState())));
|
||||||
this.markCachedNewSessionPersisted(session);
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||||
@@ -206,7 +210,7 @@ export class SessionController {
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.setState({ commandDialog: undefined });
|
this.setState({ commandDialog: undefined });
|
||||||
try {
|
try {
|
||||||
this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value));
|
this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -227,7 +231,7 @@ export class SessionController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.api.archive(session.id);
|
await this.api.archive(session.id, selectedMachineId(this.getState()));
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const sessions = markSessionArchived(state.sessions, session.id, new Date().toISOString());
|
const sessions = markSessionArchived(state.sessions, session.id, new Date().toISOString());
|
||||||
const selectionChange = selectionAfterArchivingSession(sessions, state.selectedSession?.id, session.id);
|
const selectionChange = selectionAfterArchivingSession(sessions, state.selectedSession?.id, session.id);
|
||||||
@@ -243,7 +247,7 @@ export class SessionController {
|
|||||||
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
|
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
|
||||||
if (!session || isCachedNewSessionInfo(session)) return;
|
if (!session || isCachedNewSessionInfo(session)) return;
|
||||||
try {
|
try {
|
||||||
const response = await this.api.archiveWithDescendants(session.id);
|
const response = await this.api.archiveWithDescendants(session.id, selectedMachineId(this.getState()));
|
||||||
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
|
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
|
const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
|
||||||
@@ -259,11 +263,11 @@ export class SessionController {
|
|||||||
|
|
||||||
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
||||||
if (!isCachedNewSessionInfo(session)) return;
|
if (!isCachedNewSessionInfo(session)) return;
|
||||||
void this.api.stop(session.id).catch(() => {
|
void this.api.stop(session.id, selectedMachineId(this.getState())).catch(() => {
|
||||||
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
|
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
|
||||||
});
|
});
|
||||||
forgetCachedNewSession(session.id);
|
forgetCachedNewSession(session.id, selectedMachineId(this.getState()));
|
||||||
clearDraft(session.id);
|
clearDraft(this.sessionCacheKey(session.id));
|
||||||
const sessions = this.getState().sessions.filter((candidate) => candidate.id !== session.id);
|
const sessions = this.getState().sessions.filter((candidate) => candidate.id !== session.id);
|
||||||
this.setState({ sessions });
|
this.setState({ sessions });
|
||||||
if (this.getState().selectedSession?.id !== session.id) return;
|
if (this.getState().selectedSession?.id !== session.id) return;
|
||||||
@@ -278,7 +282,7 @@ export class SessionController {
|
|||||||
async restoreSession(session = this.getState().selectedSession) {
|
async restoreSession(session = this.getState().selectedSession) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
await this.api.restore(session.id);
|
await this.api.restore(session.id, selectedMachineId(this.getState()));
|
||||||
const restored = { ...session };
|
const restored = { ...session };
|
||||||
delete restored.archived;
|
delete restored.archived;
|
||||||
delete restored.archivedAt;
|
delete restored.archivedAt;
|
||||||
@@ -292,7 +296,7 @@ export class SessionController {
|
|||||||
async detachParent(session = this.getState().selectedSession) {
|
async detachParent(session = this.getState().selectedSession) {
|
||||||
if (session?.parentSessionPath === undefined) return;
|
if (session?.parentSessionPath === undefined) return;
|
||||||
try {
|
try {
|
||||||
await this.api.detachParent(session.id);
|
await this.api.detachParent(session.id, selectedMachineId(this.getState()));
|
||||||
const detached = { ...session };
|
const detached = { ...session };
|
||||||
delete detached.parentSessionPath;
|
delete detached.parentSessionPath;
|
||||||
this.replaceSession(detached);
|
this.replaceSession(detached);
|
||||||
@@ -305,7 +309,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return [];
|
if (!session || session.archived === true) return [];
|
||||||
try {
|
try {
|
||||||
return (await this.api.models(session.id)).models;
|
return (await this.api.models(session.id, selectedMachineId(this.getState()))).models;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
return [];
|
return [];
|
||||||
@@ -316,7 +320,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.setModel(session.id, provider, modelId));
|
this.applyStatus(await this.api.setModel(session.id, provider, modelId, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -326,7 +330,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.cycleModel(session.id, direction));
|
this.applyStatus(await this.api.cycleModel(session.id, direction, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -336,7 +340,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return [];
|
if (!session || session.archived === true) return [];
|
||||||
try {
|
try {
|
||||||
return (await this.api.thinkingLevels(session.id)).levels;
|
return (await this.api.thinkingLevels(session.id, selectedMachineId(this.getState()))).levels;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
return [];
|
return [];
|
||||||
@@ -347,7 +351,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.setThinkingLevel(session.id, level));
|
this.applyStatus(await this.api.setThinkingLevel(session.id, level, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -357,7 +361,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.cycleThinkingLevel(session.id));
|
this.applyStatus(await this.api.cycleThinkingLevel(session.id, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -367,7 +371,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
await this.api.abort(session.id);
|
await this.api.abort(session.id, selectedMachineId(this.getState()));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -378,9 +382,9 @@ export class SessionController {
|
|||||||
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
|
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.flushPendingTranscriptEvents();
|
this.flushPendingTranscriptEvents();
|
||||||
const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), this.api.status(sessionId)]);
|
const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(sessionId, selectedMachineId(this.getState()))]);
|
||||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
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({
|
this.setState({
|
||||||
...history,
|
...history,
|
||||||
status,
|
status,
|
||||||
@@ -393,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) {
|
private replaceSession(session: SessionInfo) {
|
||||||
const current = this.getState().selectedSession;
|
const current = this.getState().selectedSession;
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -403,11 +415,12 @@ export class SessionController {
|
|||||||
|
|
||||||
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
|
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const replacement = await this.api.startSession(session.cwd);
|
const machineId = selectedMachineId(this.getState());
|
||||||
rememberCachedNewSession(replacement);
|
const replacement = await this.api.startSession(session.cwd, machineId);
|
||||||
moveDraft(session.id, replacement.id);
|
rememberCachedNewSession(replacement, machineId);
|
||||||
forgetCachedNewSession(session.id);
|
moveDraft(this.sessionCacheKey(session.id), this.sessionCacheKey(replacement.id));
|
||||||
const cachedReplacement = markCachedNewSessionInfo(replacement);
|
forgetCachedNewSession(session.id, machineId);
|
||||||
|
const cachedReplacement = markCachedNewSessionInfo(replacement, machineId);
|
||||||
this.setState({ sessions: [cachedReplacement, ...this.getState().sessions.filter((candidate) => candidate.id !== session.id)], error: "" });
|
this.setState({ sessions: [cachedReplacement, ...this.getState().sessions.filter((candidate) => candidate.id !== session.id)], error: "" });
|
||||||
await this.selectSession(cachedReplacement, { updateUrl: false });
|
await this.selectSession(cachedReplacement, { updateUrl: false });
|
||||||
this.updateUrl(options?.updateUrl === false ? { replace: true } : undefined);
|
this.updateUrl(options?.updateUrl === false ? { replace: true } : undefined);
|
||||||
@@ -430,7 +443,7 @@ export class SessionController {
|
|||||||
const message = result.type === "unsupported" ? result.message : result.message;
|
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 (message !== undefined && message !== "") this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] });
|
||||||
if (result.type === "done" && result.session) {
|
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 current = this.getState().selectedSession;
|
||||||
const sessions = [result.session, ...this.getState().sessions.filter((session) => session.id !== result.session?.id)];
|
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 });
|
this.setState({ sessions, selectedSession: current?.id === result.session.id ? result.session : current });
|
||||||
@@ -535,9 +548,9 @@ export class SessionController {
|
|||||||
|
|
||||||
private async refreshMessages(sessionId: string) {
|
private async refreshMessages(sessionId: string) {
|
||||||
try {
|
try {
|
||||||
const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
|
const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
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) {
|
} catch (error) {
|
||||||
if (this.getState().selectedSession?.id === sessionId) this.setState({ error: String(error) });
|
if (this.getState().selectedSession?.id === sessionId) this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { AppState } from "../appState";
|
import type { AppState } from "../appState";
|
||||||
|
import { LOCAL_MACHINE_ID } from "../machineKeys";
|
||||||
|
|
||||||
|
export function selectedMachineId(state: Pick<AppState, "selectedMachine">): string {
|
||||||
|
return state.selectedMachine?.id ?? LOCAL_MACHINE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
export type GetState = () => AppState;
|
export type GetState = () => AppState;
|
||||||
export type SetState = (patch: Partial<AppState>) => void;
|
export type SetState = (patch: Partial<AppState>) => void;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { api as defaultApi, type Project, type Workspace } from "../api";
|
import { api as defaultApi, type Project, type Workspace } from "../api";
|
||||||
import { resetWorkspaceScopedState } from "../appState";
|
import { resetWorkspaceScopedState } from "../appState";
|
||||||
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
||||||
import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types";
|
import { machineProjectKey } from "../machineKeys";
|
||||||
|
import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types";
|
||||||
import type { SessionController } from "./sessionController";
|
import type { SessionController } from "./sessionController";
|
||||||
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ export class WorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
forgetProject(projectId: string): void {
|
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));
|
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([candidate]) => candidate !== projectId));
|
||||||
this.setState({ workspacesByProjectId });
|
this.setState({ workspacesByProjectId });
|
||||||
}
|
}
|
||||||
@@ -39,9 +40,10 @@ export class WorkspaceController {
|
|||||||
this.sessions.clearActiveSession();
|
this.sessions.clearActiveSession();
|
||||||
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
|
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
|
||||||
try {
|
try {
|
||||||
const workspaces = await this.api.workspaces(project.id);
|
const machineId = selectedMachineId(this.getState());
|
||||||
|
const workspaces = await this.api.workspaces(project.id, machineId);
|
||||||
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false });
|
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 });
|
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
|
||||||
else if (target?.updateUrl !== false) this.updateUrl();
|
else if (target?.updateUrl !== false) this.updateUrl();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -50,11 +52,12 @@ export class WorkspaceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) {
|
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.sessions.clearActiveSession();
|
||||||
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
|
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
|
||||||
try {
|
try {
|
||||||
const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path));
|
const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId);
|
||||||
this.setState({ sessions });
|
this.setState({ sessions });
|
||||||
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
||||||
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
||||||
@@ -67,7 +70,7 @@ export class WorkspaceController {
|
|||||||
async refreshProjectWorkspaces(projectId: string): Promise<Workspace[]> {
|
async refreshProjectWorkspaces(projectId: string): Promise<Workspace[]> {
|
||||||
const project = this.getState().projects.find((candidate) => candidate.id === projectId);
|
const project = this.getState().projects.find((candidate) => candidate.id === projectId);
|
||||||
if (project === undefined) throw new Error("Project not found");
|
if (project === undefined) throw new Error("Project not found");
|
||||||
const workspaces = await this.api.workspaces(project.id);
|
const workspaces = await this.api.workspaces(project.id, selectedMachineId(this.getState()));
|
||||||
this.applyProjectWorkspaces(project.id, workspaces);
|
this.applyProjectWorkspaces(project.id, workspaces);
|
||||||
return workspaces;
|
return workspaces;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -22,6 +22,36 @@ export function createCoreActions(): PluginAction[] {
|
|||||||
enabled: (context) => context.state.selectedSession !== undefined,
|
enabled: (context) => context.state.selectedSession !== undefined,
|
||||||
run: (context) => { context.focusPrompt(); },
|
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",
|
id: "project.add",
|
||||||
title: "Add Project",
|
title: "Add Project",
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
|
|||||||
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
|
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt });
|
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.state.selectedMachine?.id ?? "local" });
|
||||||
return html`
|
return html`
|
||||||
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
|
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
|
||||||
<div class="image-preview">
|
<div class="image-preview">
|
||||||
@@ -97,7 +97,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
|
|||||||
|
|
||||||
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
||||||
loadTerminalPanel();
|
loadTerminalPanel();
|
||||||
return html`<terminal-panel .workspace=${context.workspace} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
|
return html`<terminal-panel .workspace=${context.workspace} .machineId=${context.state.selectedMachine?.id ?? "local"} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGit(context: WorkspacePanelContext): TemplateResult {
|
function renderGit(context: WorkspacePanelContext): TemplateResult {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
|||||||
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
|
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
|
||||||
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
|
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
|
||||||
addProject: vi.fn(() => { calls.push("addProject"); }),
|
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"); }),
|
configureAuth: vi.fn(() => { calls.push("configureAuth"); }),
|
||||||
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
|
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
|
||||||
openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }),
|
openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }),
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export interface PluginRuntimeContext {
|
|||||||
openActionPalette: () => void;
|
openActionPalette: () => void;
|
||||||
focusPrompt: () => void;
|
focusPrompt: () => void;
|
||||||
addProject: () => void | Promise<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>;
|
configureAuth: () => void | Promise<void>;
|
||||||
logoutAuth: () => void | Promise<void>;
|
logoutAuth: () => void | Promise<void>;
|
||||||
openThemePicker: () => void;
|
openThemePicker: () => void;
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ function installWindow(href: string): { pushed: string[] } {
|
|||||||
|
|
||||||
describe("route helpers", () => {
|
describe("route helpers", () => {
|
||||||
it("reads only supported route fields from the current URL", () => {
|
it("reads only supported route fields from the current URL", () => {
|
||||||
installWindow("http://localhost/app?project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
|
installWindow("http://localhost/app?machine=remote&project=p1&workspace=w1&session=s1&tool=git&view=files&core.workspace.files--file=src%2Fmain.ts&core.workspace.git--diff=README.md");
|
||||||
|
|
||||||
expect(readRoute()).toEqual({
|
expect(readRoute()).toEqual({
|
||||||
|
machineId: "remote",
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
workspaceId: "w1",
|
workspaceId: "w1",
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
@@ -53,6 +54,7 @@ describe("route helpers", () => {
|
|||||||
it("writes compact URLs and preserves path/hash", () => {
|
it("writes compact URLs and preserves path/hash", () => {
|
||||||
const { pushed } = installWindow("http://localhost/app?old=1#section");
|
const { pushed } = installWindow("http://localhost/app?old=1#section");
|
||||||
const route: AppRoute = {
|
const route: AppRoute = {
|
||||||
|
machineId: "remote",
|
||||||
projectId: "project/id",
|
projectId: "project/id",
|
||||||
workspaceId: "workspace id",
|
workspaceId: "workspace id",
|
||||||
sessionId: "",
|
sessionId: "",
|
||||||
@@ -62,13 +64,13 @@ describe("route helpers", () => {
|
|||||||
|
|
||||||
writeRoute(route);
|
writeRoute(route);
|
||||||
|
|
||||||
expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
|
expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not push history when the route is unchanged", () => {
|
it("does not push history when the route is unchanged", () => {
|
||||||
const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
|
const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
|
||||||
|
|
||||||
writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
|
writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
|
||||||
|
|
||||||
expect(pushed).toEqual([]);
|
expect(pushed).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { QualifiedContributionId } from "./plugins/types";
|
import type { QualifiedContributionId } from "./plugins/types";
|
||||||
|
|
||||||
export interface AppRoute {
|
export interface AppRoute {
|
||||||
|
machineId: string | undefined;
|
||||||
projectId: string | undefined;
|
projectId: string | undefined;
|
||||||
workspaceId: string | undefined;
|
workspaceId: string | undefined;
|
||||||
sessionId: string | undefined;
|
sessionId: string | undefined;
|
||||||
@@ -11,6 +12,7 @@ export interface AppRoute {
|
|||||||
export function readRoute(): AppRoute {
|
export function readRoute(): AppRoute {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
return {
|
return {
|
||||||
|
machineId: params.get("machine") ?? undefined,
|
||||||
projectId: params.get("project") ?? undefined,
|
projectId: params.get("project") ?? undefined,
|
||||||
workspaceId: params.get("workspace") ?? undefined,
|
workspaceId: params.get("workspace") ?? undefined,
|
||||||
sessionId: params.get("session") ?? undefined,
|
sessionId: params.get("session") ?? undefined,
|
||||||
@@ -21,11 +23,13 @@ export function readRoute(): AppRoute {
|
|||||||
|
|
||||||
export function writeRoute(route: AppRoute, options?: { replace?: boolean | undefined }): void {
|
export function writeRoute(route: AppRoute, options?: { replace?: boolean | undefined }): void {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("machine");
|
||||||
url.searchParams.delete("project");
|
url.searchParams.delete("project");
|
||||||
url.searchParams.delete("workspace");
|
url.searchParams.delete("workspace");
|
||||||
url.searchParams.delete("session");
|
url.searchParams.delete("session");
|
||||||
url.searchParams.delete("tool");
|
url.searchParams.delete("tool");
|
||||||
url.searchParams.delete("view");
|
url.searchParams.delete("view");
|
||||||
|
if (route.machineId !== undefined && route.machineId !== "" && route.machineId !== "local") url.searchParams.set("machine", route.machineId);
|
||||||
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
|
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
|
||||||
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
|
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
|
||||||
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
|
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ export class SessionSocket {
|
|||||||
private shouldReconnect = false;
|
private shouldReconnect = false;
|
||||||
private hasOpened = false;
|
private hasOpened = false;
|
||||||
private onReconnect: (() => void) | undefined;
|
private onReconnect: (() => void) | undefined;
|
||||||
|
private machineId = "local";
|
||||||
|
|
||||||
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void {
|
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void {
|
||||||
this.close();
|
this.close();
|
||||||
|
this.machineId = machineId;
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
this.onEvent = onEvent;
|
this.onEvent = onEvent;
|
||||||
this.onReconnect = onReconnect;
|
this.onReconnect = onReconnect;
|
||||||
@@ -35,11 +37,12 @@ export class SessionSocket {
|
|||||||
this.onEvent = undefined;
|
this.onEvent = undefined;
|
||||||
this.onReconnect = undefined;
|
this.onReconnect = undefined;
|
||||||
this.hasOpened = false;
|
this.hasOpened = false;
|
||||||
|
this.machineId = "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
private open(): void {
|
private open(): void {
|
||||||
if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
|
if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
|
||||||
const socket = sessionEvents(this.sessionId);
|
const socket = sessionEvents(this.sessionId, this.machineId);
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
this.reconnectDelay = 500;
|
this.reconnectDelay = 500;
|
||||||
@@ -75,9 +78,11 @@ export class RealtimeSocket {
|
|||||||
private reconnectTimer?: number;
|
private reconnectTimer?: number;
|
||||||
private reconnectDelay = 500;
|
private reconnectDelay = 500;
|
||||||
private shouldReconnect = false;
|
private shouldReconnect = false;
|
||||||
|
private machineId = "local";
|
||||||
|
|
||||||
connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void): void {
|
connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void, machineId = "local"): void {
|
||||||
this.close();
|
this.close();
|
||||||
|
this.machineId = machineId;
|
||||||
this.onEvent = onEvent;
|
this.onEvent = onEvent;
|
||||||
this.onOpen = onOpen;
|
this.onOpen = onOpen;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
@@ -91,11 +96,12 @@ export class RealtimeSocket {
|
|||||||
this.socket = undefined;
|
this.socket = undefined;
|
||||||
this.onEvent = undefined;
|
this.onEvent = undefined;
|
||||||
this.onOpen = undefined;
|
this.onOpen = undefined;
|
||||||
|
this.machineId = "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
private open(): void {
|
private open(): void {
|
||||||
if (!this.shouldReconnect) return;
|
if (!this.shouldReconnect) return;
|
||||||
const socket = realtimeEvents();
|
const socket = realtimeEvents(this.machineId);
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
this.reconnectDelay = 500;
|
this.reconnectDelay = 500;
|
||||||
@@ -129,9 +135,11 @@ export class GlobalSessionSocket {
|
|||||||
private reconnectTimer?: number;
|
private reconnectTimer?: number;
|
||||||
private reconnectDelay = 500;
|
private reconnectDelay = 500;
|
||||||
private shouldReconnect = false;
|
private shouldReconnect = false;
|
||||||
|
private machineId = "local";
|
||||||
|
|
||||||
connect(onEvent: (event: GlobalSessionEvent) => void): void {
|
connect(onEvent: (event: GlobalSessionEvent) => void, machineId = "local"): void {
|
||||||
this.close();
|
this.close();
|
||||||
|
this.machineId = machineId;
|
||||||
this.onEvent = onEvent;
|
this.onEvent = onEvent;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
this.open();
|
this.open();
|
||||||
@@ -143,11 +151,12 @@ export class GlobalSessionSocket {
|
|||||||
closeSocketQuietly(this.socket);
|
closeSocketQuietly(this.socket);
|
||||||
this.socket = undefined;
|
this.socket = undefined;
|
||||||
this.onEvent = undefined;
|
this.onEvent = undefined;
|
||||||
|
this.machineId = "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
private open(): void {
|
private open(): void {
|
||||||
if (!this.shouldReconnect) return;
|
if (!this.shouldReconnect) return;
|
||||||
const socket = globalSessionEvents();
|
const socket = globalSessionEvents(this.machineId);
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
this.reconnectDelay = 500;
|
this.reconnectDelay = 500;
|
||||||
|
|||||||
+255
-1
@@ -1,25 +1,53 @@
|
|||||||
import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
|
import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import type { FastifyInstance } from "fastify";
|
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 { buildApp } from "./app.js";
|
||||||
import { ProjectService } from "./projects/projectService.js";
|
import { ProjectService } from "./projects/projectService.js";
|
||||||
import { ProjectStore } from "./storage/projectStore.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";
|
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
|
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
|
||||||
import type { Project, Workspace } from "./types.js";
|
import type { Project, Workspace } from "./types.js";
|
||||||
|
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let projectDir: string;
|
let projectDir: string;
|
||||||
|
let remoteClient: MachineClient | undefined;
|
||||||
|
let sessionDaemonRequests: CapturedSessionDaemonRequest[];
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
|
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
|
||||||
projectDir = join(tempDir, "project");
|
projectDir = join(tempDir, "project");
|
||||||
|
remoteClient = undefined;
|
||||||
|
sessionDaemonRequests = [];
|
||||||
app = await buildApp({
|
app = await buildApp({
|
||||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||||
workspaces: new WorkspaceService(),
|
workspaces: new WorkspaceService(),
|
||||||
|
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: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
sessionDaemon: fakeSessionDaemon(),
|
||||||
piWebPlugins: {
|
piWebPlugins: {
|
||||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
||||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }),
|
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", enabled: true }] }),
|
||||||
@@ -36,6 +64,132 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildApp", () => {
|
describe("buildApp", () => {
|
||||||
|
it("lists synthesized local machine through the HTTP contract", async () => {
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/machines" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds remote machines without exposing tokens", async () => {
|
||||||
|
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } });
|
||||||
|
|
||||||
|
expect(addResponse.statusCode).toBe(200);
|
||||||
|
expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" });
|
||||||
|
expect(addResponse.json()).not.toHaveProperty("token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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("preserves remote file preview security headers while proxying safe response metadata", 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": "image/svg+xml",
|
||||||
|
"content-security-policy": "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'",
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"set-cookie": "session=secret",
|
||||||
|
},
|
||||||
|
body: Readable.from(["<svg xmlns=\"http://www.w3.org/2000/svg\" />"]),
|
||||||
|
}));
|
||||||
|
remoteClient = fakeRemoteClient({ request });
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file/preview?path=${encodeURIComponent("diagram.svg")}` });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers["content-type"]).toContain("image/svg+xml");
|
||||||
|
expect(response.headers["content-security-policy"]).toContain("sandbox");
|
||||||
|
expect(response.headers["x-content-type-options"]).toBe("nosniff");
|
||||||
|
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||||
|
expect(response.body).toBe("<svg xmlns=\"http://www.w3.org/2000/svg\" />");
|
||||||
|
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proxies remote terminal command-run and continue routes", 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((method: string, path: string) => Promise.resolve({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: Readable.from([JSON.stringify({ method, path })]),
|
||||||
|
}));
|
||||||
|
remoteClient = fakeRemoteClient({ request });
|
||||||
|
|
||||||
|
const createBody = { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } };
|
||||||
|
const createResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminal-command-runs`, payload: createBody });
|
||||||
|
const listResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs?projectId=p1&statuses=running` });
|
||||||
|
const getResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs/run1` });
|
||||||
|
const cancelResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/terminal-command-runs/run1/cancel` });
|
||||||
|
const continueResponse = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals/t1/continue` });
|
||||||
|
|
||||||
|
expect(createResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminal-command-runs" });
|
||||||
|
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs?projectId=p1&statuses=running" });
|
||||||
|
expect(getResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs/run1" });
|
||||||
|
expect(cancelResponse.json()).toEqual({ method: "POST", path: "/api/terminal-command-runs/run1/cancel" });
|
||||||
|
expect(continueResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminals/t1/continue" });
|
||||||
|
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("adds, lists, and closes projects through the HTTP contract", async () => {
|
||||||
const addResponse = await app.inject({
|
const addResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -60,6 +214,76 @@ describe("buildApp", () => {
|
|||||||
expect(emptyListResponse.json<Project[]>()).toEqual([]);
|
expect(emptyListResponse.json<Project[]>()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves local session and terminal proxy routes through machine-scoped aliases", async () => {
|
||||||
|
const sessionsResponse = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` });
|
||||||
|
|
||||||
|
expect(sessionsResponse.statusCode).toBe(200);
|
||||||
|
expect(sessionsResponse.json()).toEqual({ method: "GET", path: `/sessions?cwd=${encodeURIComponent(projectDir)}` });
|
||||||
|
expect(sessionDaemonRequests).toEqual([{ method: "GET", path: `/sessions?cwd=${encodeURIComponent(projectDir)}` }]);
|
||||||
|
|
||||||
|
const addResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/machines/local/projects",
|
||||||
|
payload: { name: "Machine Local", path: projectDir, create: true },
|
||||||
|
});
|
||||||
|
const project = addResponse.json<Project>();
|
||||||
|
const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
|
||||||
|
const workspace = workspacesResponse.json<Workspace[]>()[0];
|
||||||
|
if (workspace === undefined) throw new Error("Expected workspace");
|
||||||
|
|
||||||
|
const terminalResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminal-command-runs`,
|
||||||
|
payload: { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(terminalResponse.statusCode).toBe(200);
|
||||||
|
expect(terminalResponse.json()).toEqual({
|
||||||
|
method: "POST",
|
||||||
|
path: "/terminal-command-runs",
|
||||||
|
body: {
|
||||||
|
origin: "core",
|
||||||
|
projectId: project.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
cwd: projectDir,
|
||||||
|
title: "Build",
|
||||||
|
command: "npm test",
|
||||||
|
metadata: { "pi.operation": "test" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(sessionDaemonRequests[1]).toEqual({
|
||||||
|
method: "POST",
|
||||||
|
path: "/terminal-command-runs",
|
||||||
|
body: {
|
||||||
|
origin: "core",
|
||||||
|
projectId: project.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
cwd: projectDir,
|
||||||
|
title: "Build",
|
||||||
|
command: "npm test",
|
||||||
|
metadata: { "pi.operation": "test" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves local projects and workspaces through machine-scoped aliases", async () => {
|
||||||
|
const addResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/machines/local/projects",
|
||||||
|
payload: { name: "Machine Local", path: projectDir, create: true },
|
||||||
|
});
|
||||||
|
expect(addResponse.statusCode).toBe(200);
|
||||||
|
const project = addResponse.json<Project>();
|
||||||
|
|
||||||
|
const listResponse = await app.inject({ method: "GET", url: "/api/machines/local/projects" });
|
||||||
|
expect(listResponse.statusCode).toBe(200);
|
||||||
|
expect(listResponse.json<Project[]>()).toEqual([project]);
|
||||||
|
|
||||||
|
const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
|
||||||
|
expect(workspacesResponse.statusCode).toBe(200);
|
||||||
|
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
|
||||||
|
});
|
||||||
|
|
||||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||||
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
||||||
expect(manifestResponse.statusCode).toBe(200);
|
expect(manifestResponse.statusCode).toBe(200);
|
||||||
@@ -151,3 +375,33 @@ describe("buildApp", () => {
|
|||||||
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
|
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface CapturedSessionDaemonRequest {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSessionDaemon(): SessionProxyDaemon {
|
||||||
|
return {
|
||||||
|
request: (method, path, body) => {
|
||||||
|
const captured = { method, path, ...(body === undefined ? {} : { body }) } satisfies CapturedSessionDaemonRequest;
|
||||||
|
sessionDaemonRequests.push(captured);
|
||||||
|
return Promise.resolve({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(captured),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+72
-46
@@ -9,23 +9,79 @@ import { ProjectService } from "./projects/projectService.js";
|
|||||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||||
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||||
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||||
|
import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||||
import { registerGitRoutes } from "./gitRoutes.js";
|
import { registerGitRoutes } from "./gitRoutes.js";
|
||||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||||
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||||
|
import { MachineService } from "./machines/machineService.js";
|
||||||
|
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||||
|
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||||
|
|
||||||
export interface AppDependencies {
|
export interface AppDependencies {
|
||||||
projects?: ProjectService;
|
projects?: ProjectService;
|
||||||
workspaces?: WorkspaceService;
|
workspaces?: WorkspaceService;
|
||||||
|
machines?: MachineService;
|
||||||
|
sessionDaemon?: SessionProxyDaemon;
|
||||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||||
config?: PiWebConfigService;
|
config?: PiWebConfigService;
|
||||||
clientDist?: string | false;
|
clientDist?: string | false;
|
||||||
logger?: FastifyServerOptions["logger"];
|
logger?: FastifyServerOptions["logger"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void {
|
||||||
|
app.get(`${prefix}/projects`, async () => projects.list());
|
||||||
|
|
||||||
|
app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await projects.add(request.body);
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await projects.close(request.params.projectId);
|
||||||
|
return { closed: true };
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { q?: string } }>(`${prefix}/project-directories`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await listDirectorySuggestions(request.query.q ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const project = await projects.requireProject(request.params.projectId);
|
||||||
|
return await workspaces.list(project);
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
|
||||||
|
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => {
|
||||||
|
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||||
|
try {
|
||||||
|
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
|
||||||
|
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
|
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
|
||||||
const app = Fastify({ logger: deps.logger ?? true });
|
const app = Fastify({ logger: deps.logger ?? true });
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
@@ -33,6 +89,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
|||||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
|
||||||
|
const machines = deps.machines ?? new MachineService();
|
||||||
|
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||||
|
|
||||||
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
|
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
|
||||||
|
|
||||||
@@ -47,56 +105,24 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
|||||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||||
registerConfigRoutes(app, deps.config);
|
registerConfigRoutes(app, deps.config);
|
||||||
|
|
||||||
app.get("/api/projects", async () => projects.list());
|
registerMachineRoutes(app, machines);
|
||||||
|
|
||||||
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
|
registerLocalProjectRoutes(app, projects, workspaces, "/api");
|
||||||
try {
|
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
return await projects.add(request.body);
|
|
||||||
} catch (error) {
|
|
||||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete<{ Params: { projectId: string } }>("/api/projects/:projectId", async (request, reply) => {
|
registerSessionProxyRoutes(app, sessionDaemon);
|
||||||
try {
|
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
|
||||||
await projects.close(request.params.projectId);
|
|
||||||
return { closed: true };
|
|
||||||
} catch (error) {
|
|
||||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get<{ Querystring: { q?: string } }>("/api/project-directories", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
return await listDirectorySuggestions(request.query.q ?? "");
|
|
||||||
} catch (error) {
|
|
||||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const project = await projects.requireProject(request.params.projectId);
|
|
||||||
return await workspaces.list(project);
|
|
||||||
} catch (error) {
|
|
||||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerSessionProxyRoutes(app);
|
|
||||||
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
||||||
|
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
registerGitRoutes(app, projects, workspaces);
|
registerGitRoutes(app, projects, workspaces);
|
||||||
registerTerminalProxyRoutes(app, projects, workspaces);
|
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
|
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon);
|
||||||
|
registerTerminalProxyRoutes(app, projects, workspaces, sessionDaemon, "/api/machines/local");
|
||||||
|
|
||||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>("/api/files", async (request, reply) => {
|
registerLocalFileSuggestionRoutes(app, "/api");
|
||||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
|
||||||
try {
|
|
||||||
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
|
registerMachineProxyRoutes(app, machines);
|
||||||
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
|
|
||||||
} catch (error) {
|
|
||||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client");
|
const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client");
|
||||||
const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client"));
|
const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client"));
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sanitizedGitEnv } from "./gitEnv.js";
|
||||||
|
|
||||||
|
describe("sanitizedGitEnv", () => {
|
||||||
|
it("removes repository-local Git variables inherited from hooks", () => {
|
||||||
|
const env = sanitizedGitEnv({
|
||||||
|
PATH: "/bin",
|
||||||
|
GIT_DIR: "/repo/.git",
|
||||||
|
GIT_WORK_TREE: "/repo",
|
||||||
|
GIT_INDEX_FILE: "/repo/.git/index.lock",
|
||||||
|
GIT_PREFIX: "src/",
|
||||||
|
GIT_COMMON_DIR: "/repo/.git",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env).toEqual({ PATH: "/bin" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const GIT_LOCAL_ENV_VARS = [
|
||||||
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||||
|
"GIT_COMMON_DIR",
|
||||||
|
"GIT_DIR",
|
||||||
|
"GIT_INDEX_FILE",
|
||||||
|
"GIT_OBJECT_DIRECTORY",
|
||||||
|
"GIT_PREFIX",
|
||||||
|
"GIT_QUARANTINE_PATH",
|
||||||
|
"GIT_WORK_TREE",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function sanitizedGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||||
|
const blocked = new Set<string>(GIT_LOCAL_ENV_VARS);
|
||||||
|
return Object.fromEntries(Object.entries(env).filter(([key]) => !blocked.has(key)));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js";
|
import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js";
|
||||||
import { normalizeRelativePath } from "../workspaces/pathSafety.js";
|
import { normalizeRelativePath } from "../workspaces/pathSafety.js";
|
||||||
|
import { sanitizedGitEnv } from "./gitEnv.js";
|
||||||
|
|
||||||
const MAX_OUTPUT = 2 * 1024 * 1024;
|
const MAX_OUTPUT = 2 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ function hash(value: string): string {
|
|||||||
|
|
||||||
async function runGit(cwd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string; truncated: boolean }> {
|
async function runGit(cwd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string; truncated: boolean }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
const child = spawn("git", args, { cwd, env: sanitizedGitEnv(), stdio: ["ignore", "pipe", "pipe"] });
|
||||||
const timer = setTimeout(() => { child.kill("SIGKILL"); }, 10000);
|
const timer = setTimeout(() => { child.kill("SIGKILL"); }, 10000);
|
||||||
let stdout = Buffer.alloc(0);
|
let stdout = Buffer.alloc(0);
|
||||||
let stderr = Buffer.alloc(0);
|
let stderr = Buffer.alloc(0);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
|||||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||||
import { gitDiff, gitStatus } from "./git/gitService.js";
|
import { gitDiff, gitStatus } from "./git/gitService.js";
|
||||||
|
|
||||||
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
|
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/status", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/status`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await gitStatus(context.root);
|
return await gitStatus(context.root);
|
||||||
@@ -14,7 +14,7 @@ export function registerGitRoutes(app: FastifyInstance, projects: ProjectService
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/diff", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/diff`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" });
|
return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" });
|
||||||
|
|||||||
@@ -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,101 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
|
import type { WebSocket } from "ws";
|
||||||
|
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
|
||||||
|
import { bridgeSockets } from "../webSocketBridge.js";
|
||||||
|
import { RemoteMachineRequestError } from "./machineClient.js";
|
||||||
|
import { MachineService } from "./machineService.js";
|
||||||
|
|
||||||
|
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
|
||||||
|
export const REMOTE_WEBSOCKET_ROUTES = FEDERATED_WEBSOCKET_ROUTES;
|
||||||
|
|
||||||
|
const SAFE_RESPONSE_HEADERS = new Set([
|
||||||
|
"content-type",
|
||||||
|
"content-length",
|
||||||
|
"cache-control",
|
||||||
|
"last-modified",
|
||||||
|
"etag",
|
||||||
|
"content-security-policy",
|
||||||
|
"x-content-type-options",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { MachineService, type CreateMachineInput, type UpdateMachineInput } from "./machineService.js";
|
||||||
|
|
||||||
|
export function registerMachineRoutes(app: FastifyInstance, machines = new MachineService()): void {
|
||||||
|
app.get("/api/machines", async () => ({ machines: await machines.list() }));
|
||||||
|
|
||||||
|
app.post<{ Body: CreateMachineInput }>("/api/machines", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await machines.add(request.body);
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/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" });
|
||||||
|
return machine;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch<{ Params: { machineId: string }; Body: UpdateMachineInput }>("/api/machines/:machineId", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const machine = await machines.update(request.params.machineId, request.body);
|
||||||
|
if (machine === undefined) return await reply.code(404).send({ error: "Machine not found" });
|
||||||
|
return machine;
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { machineId: string } }>("/api/machines/:machineId", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const removed = await machines.remove(request.params.machineId);
|
||||||
|
if (!removed) return await reply.code(404).send({ error: "Machine not found" });
|
||||||
|
return { deleted: true };
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { MachineService } from "./machineService.js";
|
||||||
|
import { MachineStore, machineStorePath } from "./machineStore.js";
|
||||||
|
|
||||||
|
let tempDir: string;
|
||||||
|
let storePath: string;
|
||||||
|
let service: MachineService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "pi-web-machines-test-"));
|
||||||
|
storePath = join(tempDir, "machines.json");
|
||||||
|
service = new MachineService(new MachineStore(storePath));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MachineService", () => {
|
||||||
|
it("synthesizes local machine without persisting it", async () => {
|
||||||
|
expect(await service.list()).toEqual([
|
||||||
|
{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds remote machines and omits secrets from public responses", async () => {
|
||||||
|
const machine = await service.add({ name: " Dev Box ", baseUrl: "https://devbox.example.test/", token: "secret" });
|
||||||
|
|
||||||
|
expect(machine).toMatchObject({ name: "Dev Box", kind: "remote", baseUrl: "https://devbox.example.test" });
|
||||||
|
expect(machine).not.toHaveProperty("token");
|
||||||
|
expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" }), machine]);
|
||||||
|
|
||||||
|
const raw: unknown = JSON.parse(await readFile(storePath, "utf8"));
|
||||||
|
expect(raw).toMatchObject({ machines: [expect.objectContaining({ kind: "remote", token: "secret" })] });
|
||||||
|
await expectOwnerOnlyMachineStore(storePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tightens permissions after reading an existing machine store", async () => {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
await writeFile(storePath, `${JSON.stringify({
|
||||||
|
machines: [{
|
||||||
|
id: "remote-1",
|
||||||
|
name: "Remote",
|
||||||
|
kind: "remote",
|
||||||
|
baseUrl: "https://remote.example.test",
|
||||||
|
token: "secret",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
}],
|
||||||
|
}, null, 2)}\n`, { encoding: "utf8", mode: 0o644 });
|
||||||
|
await chmod(storePath, 0o644);
|
||||||
|
|
||||||
|
await expect(service.list()).resolves.toEqual([expect.objectContaining({ id: "local" }), expect.objectContaining({ id: "remote-1" })]);
|
||||||
|
|
||||||
|
await expectOwnerOnlyMachineStore(storePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid remote base URLs", async () => {
|
||||||
|
await expect(service.add({ name: "Bad", baseUrl: "ftp://example.test" })).rejects.toThrow("http or https");
|
||||||
|
await expect(service.add({ name: "Bad", baseUrl: "https://[email protected]" })).rejects.toThrow("credentials");
|
||||||
|
await expect(service.add({ name: "Bad", baseUrl: "https://example.test/path?q=1" })).rejects.toThrow("query or hash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports PI_WEB_MACHINES_FILE path overrides", () => {
|
||||||
|
const env: NodeJS.ProcessEnv = { PI_WEB_MACHINES_FILE: "data/machines.json" };
|
||||||
|
expect(machineStorePath(env, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "data/machines.json"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function expectOwnerOnlyMachineStore(path: string): Promise<void> {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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 {
|
||||||
|
name?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<Machine | undefined> {
|
||||||
|
if (id === "local") return localMachine();
|
||||||
|
const machine = (await this.store.list()).find((stored) => stored.id === id);
|
||||||
|
return machine === undefined ? undefined : publicMachine(machine);
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(input: CreateMachineInput): Promise<Machine> {
|
||||||
|
const name = validateName(input.name);
|
||||||
|
const baseUrl = validateBaseUrl(input.baseUrl);
|
||||||
|
const stored = await this.store.add({ name, baseUrl, ...optionalSecrets(input) });
|
||||||
|
return publicMachine(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, input: UpdateMachineInput): Promise<Machine | undefined> {
|
||||||
|
if (id === "local") throw new Error("Local machine cannot be changed");
|
||||||
|
const patch: Partial<Pick<StoredMachine, "name" | "baseUrl" | "token" | "headers">> = {};
|
||||||
|
if (input.name !== undefined) patch.name = validateName(input.name);
|
||||||
|
if (input.baseUrl !== undefined) patch.baseUrl = validateBaseUrl(input.baseUrl);
|
||||||
|
if (input.token !== undefined) patch.token = input.token;
|
||||||
|
if (input.headers !== undefined) patch.headers = validateHeaders(input.headers);
|
||||||
|
const stored = await this.store.update(id, patch);
|
||||||
|
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");
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localMachine(): Machine {
|
||||||
|
return { id: "local", name: "Local", kind: "local", createdAt: LOCAL_MACHINE_TIMESTAMP, updatedAt: LOCAL_MACHINE_TIMESTAMP };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicMachine(machine: StoredMachine): Machine {
|
||||||
|
return { id: machine.id, name: machine.name, kind: "remote", baseUrl: machine.baseUrl, createdAt: machine.createdAt, updatedAt: machine.updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateName(value: string | undefined): string {
|
||||||
|
const name = value?.trim();
|
||||||
|
if (name === undefined || name === "") throw new Error("Machine name is required");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBaseUrl(value: string | undefined): string {
|
||||||
|
const raw = value?.trim();
|
||||||
|
if (raw === undefined || raw === "") throw new Error("Machine baseUrl is required");
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Machine baseUrl must be a valid URL");
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Machine baseUrl must use http or https");
|
||||||
|
if (url.username !== "" || url.password !== "") throw new Error("Machine baseUrl must not include credentials");
|
||||||
|
if (url.search !== "" || url.hash !== "") throw new Error("Machine baseUrl must not include query or hash");
|
||||||
|
return url.href.replace(/\/$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalSecrets(input: CreateMachineInput): { token?: string; headers?: Record<string, string> } {
|
||||||
|
return {
|
||||||
|
...(input.token === undefined ? {} : { token: input.token }),
|
||||||
|
...(input.headers === undefined ? {} : { headers: validateHeaders(input.headers) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHeaders(value: Record<string, string>): Record<string, string> {
|
||||||
|
return 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { piWebDataDir } from "../../config.js";
|
||||||
|
|
||||||
|
export interface StoredMachine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: "remote";
|
||||||
|
baseUrl: string;
|
||||||
|
token?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MachineFile {
|
||||||
|
machines: StoredMachine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MACHINE_STORE_FILE_MODE = 0o600;
|
||||||
|
|
||||||
|
export function defaultMachineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
|
||||||
|
return join(piWebDataDir(env, cwd), "machines.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function machineStorePath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
|
||||||
|
const configured = env["PI_WEB_MACHINES_FILE"];
|
||||||
|
if (configured === undefined || configured === "") return defaultMachineStorePath(env, cwd);
|
||||||
|
return resolve(cwd, configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MachineStore {
|
||||||
|
constructor(private readonly filePath = machineStorePath()) {}
|
||||||
|
|
||||||
|
async list(): Promise<StoredMachine[]> {
|
||||||
|
return (await this.read()).machines;
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(input: { name: string; baseUrl: string; token?: string; headers?: Record<string, string> }): Promise<StoredMachine> {
|
||||||
|
const data = await this.read();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const machine: StoredMachine = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: input.name,
|
||||||
|
kind: "remote",
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
...(input.token === undefined ? {} : { token: input.token }),
|
||||||
|
...(input.headers === undefined ? {} : { headers: input.headers }),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
data.machines.push(machine);
|
||||||
|
await this.write(data);
|
||||||
|
return machine;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, patch: Partial<Pick<StoredMachine, "name" | "baseUrl" | "token" | "headers">>): Promise<StoredMachine | undefined> {
|
||||||
|
const data = await this.read();
|
||||||
|
const index = data.machines.findIndex((machine) => machine.id === id);
|
||||||
|
if (index < 0) return undefined;
|
||||||
|
const current = data.machines[index];
|
||||||
|
if (current === undefined) return undefined;
|
||||||
|
const next: StoredMachine = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
||||||
|
data.machines[index] = next;
|
||||||
|
await this.write(data);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<boolean> {
|
||||||
|
const data = await this.read();
|
||||||
|
const machines = data.machines.filter((machine) => machine.id !== id);
|
||||||
|
if (machines.length === data.machines.length) return false;
|
||||||
|
await this.write({ machines });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async read(): Promise<MachineFile> {
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
|
||||||
|
const parsed = parseMachineFile(value);
|
||||||
|
await restrictMachineStorePermissions(this.filePath);
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorWithCode(error, "ENOENT")) return { machines: [] };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async write(data: MachineFile): Promise<void> {
|
||||||
|
await mkdir(dirname(this.filePath), { recursive: true });
|
||||||
|
await writeFile(this.filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: MACHINE_STORE_FILE_MODE });
|
||||||
|
await restrictMachineStorePermissions(this.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMachineFile(value: unknown): MachineFile {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value["machines"])) throw new Error("Invalid machine file");
|
||||||
|
return { machines: value["machines"].map(parseStoredMachine) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredMachine(value: unknown): StoredMachine {
|
||||||
|
if (!isRecord(value)) throw new Error("Invalid machine");
|
||||||
|
const id = value["id"];
|
||||||
|
const name = value["name"];
|
||||||
|
const kind = value["kind"];
|
||||||
|
const baseUrl = value["baseUrl"];
|
||||||
|
const createdAt = value["createdAt"];
|
||||||
|
const updatedAt = value["updatedAt"];
|
||||||
|
if (typeof id !== "string" || typeof name !== "string" || kind !== "remote" || typeof baseUrl !== "string" || typeof createdAt !== "string" || typeof updatedAt !== "string") throw new Error("Invalid machine");
|
||||||
|
const token = optionalString(value["token"], "token");
|
||||||
|
const headers = optionalStringRecord(value["headers"], "headers");
|
||||||
|
return { id, name, kind, baseUrl, createdAt, updatedAt, ...(token === undefined ? {} : { token }), ...(headers === undefined ? {} : { headers }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown, key: string): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== "string") throw new Error(`Invalid machine ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalStringRecord(value: unknown, key: string): Record<string, string> | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!isRecord(value)) throw new Error(`Invalid machine ${key}`);
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([header, headerValue]) => {
|
||||||
|
if (typeof headerValue !== "string") throw new Error(`Invalid machine ${key}`);
|
||||||
|
return [header, headerValue];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restrictMachineStorePermissions(path: string): Promise<void> {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
await chmod(path, MACHINE_STORE_FILE_MODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||||
|
return error instanceof Error && "code" in error && error.code === code;
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ describe("PiWebPluginService", () => {
|
|||||||
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Dev', activate: () => ({ contributions: {} }) };" },
|
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Dev', activate: () => ({ contributions: {} }) };" },
|
||||||
});
|
});
|
||||||
await mkdir(join(tempDir, "plugins"), { recursive: true });
|
await mkdir(join(tempDir, "plugins"), { recursive: true });
|
||||||
await symlink(pluginDir, join(tempDir, "plugins", "dev"), "dir");
|
await symlink(pluginDir, join(tempDir, "plugins", "dev"), process.platform === "win32" ? "junction" : "dir");
|
||||||
|
|
||||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import fastifyWebsocket from "@fastify/websocket";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { registerSessionProxyRoutes } from "./sessionProxyRoutes";
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let daemon: FakeSessionDaemon;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyWebsocket);
|
||||||
|
daemon = new FakeSessionDaemon();
|
||||||
|
registerSessionProxyRoutes(app, daemon, "/api/machines/local");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("machine-scoped session proxy routes", () => {
|
||||||
|
it("strips the machine prefix before forwarding session requests", async () => {
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions?cwd=/repo" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ ok: true });
|
||||||
|
expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips the machine prefix before forwarding auth requests", async () => {
|
||||||
|
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ ok: true });
|
||||||
|
expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeSessionDaemon {
|
||||||
|
readonly requests: { method: string; path: string; body: unknown }[] = [];
|
||||||
|
|
||||||
|
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
|
||||||
|
this.requests.push({ method, path, body });
|
||||||
|
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWebSocket(): never {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,15 @@ import type { FastifyInstance, FastifyReply } from "fastify";
|
|||||||
import { WebSocket, type RawData } from "ws";
|
import { WebSocket, type RawData } from "ws";
|
||||||
import { SessionDaemonClient } from "../../sessiond/sessionDaemonClient.js";
|
import { SessionDaemonClient } from "../../sessiond/sessionDaemonClient.js";
|
||||||
|
|
||||||
export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void {
|
export interface SessionProxyDaemon {
|
||||||
|
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
|
||||||
|
connectWebSocket(path: string): WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSessionProxyRoutes(app: FastifyInstance, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void {
|
||||||
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
|
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
|
||||||
try {
|
try {
|
||||||
const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body);
|
const upstream = await daemon.request(request.method, stripPrefix(request.url, prefix), request.body);
|
||||||
reply.code(upstream.statusCode);
|
reply.code(upstream.statusCode);
|
||||||
const contentType = upstream.headers["content-type"];
|
const contentType = upstream.headers["content-type"];
|
||||||
if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
|
if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
|
||||||
@@ -16,29 +21,31 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
app.get("/api/sessiond/health", (_request, reply) => proxy({ method: "GET", url: "/api/health" }, reply));
|
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
|
||||||
|
|
||||||
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
|
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||||
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
|
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/sessions/events", { websocket: true }, (socket) => {
|
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
|
||||||
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events"));
|
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events"));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/events", { websocket: true }, (socket) => {
|
app.get(`${prefix}/events`, { websocket: true }, (socket) => {
|
||||||
bridgeSockets(socket, daemon.connectWebSocket("/events"));
|
bridgeSockets(socket, daemon.connectWebSocket("/events"));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.all("/api/activity", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/activity`, (request, reply) => proxy(request, reply));
|
||||||
app.all("/api/auth", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/auth`, (request, reply) => proxy(request, reply));
|
||||||
app.all("/api/auth/*", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/auth/*`, (request, reply) => proxy(request, reply));
|
||||||
app.all("/api/sessions", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/sessions`, (request, reply) => proxy(request, reply));
|
||||||
app.all("/api/sessions/*", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/sessions/*`, (request, reply) => proxy(request, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripApiPrefix(url: string): string {
|
function stripPrefix(url: string, prefix: string): string {
|
||||||
const stripped = url.startsWith("/api") ? url.slice(4) : url;
|
const path = url.split("?", 1)[0] ?? url;
|
||||||
|
const query = url.slice(path.length);
|
||||||
|
const stripped = path.startsWith(prefix) ? `${path.slice(prefix.length)}${query}` : url;
|
||||||
return stripped === "" ? "/" : stripped;
|
return stripped === "" ? "/" : stripped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { join } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { projectStorePath } from "./projectStore.js";
|
import { projectStorePath } from "./projectStore.js";
|
||||||
|
|
||||||
describe("projectStorePath", () => {
|
describe("projectStorePath", () => {
|
||||||
it("uses PI_WEB_DATA_DIR by default", () => {
|
it("uses PI_WEB_DATA_DIR by default", () => {
|
||||||
expect(projectStorePath({ PI_WEB_DATA_DIR: "demo-data" }, "/tmp/pi-web")).toBe(join("/tmp/pi-web", "demo-data", "projects.json"));
|
expect(projectStorePath({ PI_WEB_DATA_DIR: "demo-data" }, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "demo-data", "projects.json"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses PI_WEB_PROJECTS_FILE when configured", () => {
|
it("uses PI_WEB_PROJECTS_FILE when configured", () => {
|
||||||
expect(projectStorePath({ PI_WEB_PROJECTS_FILE: "demo/projects.json" }, "/tmp/pi-web")).toBe(join("/tmp/pi-web", "demo/projects.json"));
|
expect(projectStorePath({ PI_WEB_PROJECTS_FILE: "demo/projects.json" }, "/tmp/pi-web")).toBe(resolve("/tmp/pi-web", "demo/projects.json"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import type { ProjectService } from "./projects/projectService.js";
|
import type { ProjectService } from "./projects/projectService.js";
|
||||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||||
|
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
import { terminalSizeQuery } from "./terminals/terminalSize.js";
|
import { terminalSizeQuery } from "./terminals/terminalSize.js";
|
||||||
import { bridgeSockets } from "./webSocketBridge.js";
|
import { bridgeSockets } from "./webSocketBridge.js";
|
||||||
|
|
||||||
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient()): void {
|
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void {
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply);
|
return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply);
|
||||||
@@ -17,7 +18,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
|
app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply);
|
return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply);
|
||||||
@@ -27,7 +28,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue", async (request, reply) => {
|
app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply);
|
return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply);
|
||||||
@@ -37,7 +38,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId", async (request, reply) => {
|
app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply);
|
return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply);
|
||||||
@@ -47,7 +48,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>("/api/projects/:projectId/workspaces/:workspaceId/terminal-command-runs", async (request, reply) => {
|
app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminal-command-runs`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "POST", "/terminal-command-runs", {
|
return await proxyJson(daemon, "POST", "/terminal-command-runs", {
|
||||||
@@ -65,7 +66,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Querystring: TerminalCommandRunQuery }>("/api/terminal-command-runs", async (request, reply) => {
|
app.get<{ Querystring: TerminalCommandRunQuery }>(`${prefix}/terminal-command-runs`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply);
|
return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -74,7 +75,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId/cancel", async (request, reply) => {
|
app.post<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId/cancel`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply);
|
return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -83,7 +84,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId", async (request, reply) => {
|
app.get<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply);
|
return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -92,7 +93,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket", { websocket: true }, async (socket, request) => {
|
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket`, { websocket: true }, async (socket, request) => {
|
||||||
try {
|
try {
|
||||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
const sizeQuery = terminalSizeQuery(request.query.cols, request.query.rows);
|
const sizeQuery = terminalSizeQuery(request.query.cols, request.query.rows);
|
||||||
@@ -130,7 +131,7 @@ function terminalCommandRunQuery(filter: TerminalCommandRunQuery): string {
|
|||||||
return query === "" ? "" : `?${query}`;
|
return query === "" ? "" : `?${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function proxyJson(daemon: SessionDaemonClient, method: string, path: string, body: unknown, reply: FastifyReply): Promise<unknown> {
|
async function proxyJson(daemon: SessionProxyDaemon, method: string, path: string, body: unknown, reply: FastifyReply): Promise<unknown> {
|
||||||
const upstream = await daemon.request(method, path, body);
|
const upstream = await daemon.request(method, path, body);
|
||||||
reply.code(upstream.statusCode);
|
reply.code(upstream.statusCode);
|
||||||
const contentType = upstream.headers["content-type"];
|
const contentType = upstream.headers["content-type"];
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
|||||||
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||||
|
|
||||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
|
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await listWorkspaceTree(context.root, request.query.path);
|
return await listWorkspaceTree(context.root, request.query.path);
|
||||||
@@ -16,7 +16,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await readWorkspaceFile(context.root, request.query.path);
|
return await readWorkspaceFile(context.root, request.query.path);
|
||||||
@@ -25,7 +25,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file/preview", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
|
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
|
|||||||
import { readdir, stat } from "node:fs/promises";
|
import { readdir, stat } from "node:fs/promises";
|
||||||
import { basename, dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { sanitizedGitEnv } from "../git/gitEnv.js";
|
||||||
import type { ClientFileSuggestion } from "../types.js";
|
import type { ClientFileSuggestion } from "../types.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -11,6 +12,7 @@ const maxFilesystemFallbackPaths = 20_000;
|
|||||||
interface ExecFileOptions {
|
interface ExecFileOptions {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
maxBuffer: number;
|
maxBuffer: number;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileSuggestionScope = "tracked" | "all";
|
export type FileSuggestionScope = "tracked" | "all";
|
||||||
@@ -128,7 +130,7 @@ async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
|
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
|
||||||
const { stdout } = await exec("git", args, { cwd, maxBuffer: commandMaxBuffer });
|
const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer });
|
||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("listWorkspaceTree", () => {
|
|||||||
await mkdir(join(root, "node_modules"));
|
await mkdir(join(root, "node_modules"));
|
||||||
await writeFile(join(root, "b.txt"), "b");
|
await writeFile(join(root, "b.txt"), "b");
|
||||||
await writeFile(join(root, "a.txt"), "a");
|
await writeFile(join(root, "a.txt"), "a");
|
||||||
await symlink(join(root, "a.txt"), join(root, "link.txt"));
|
const createdSymlink = await trySymlink(join(root, "a.txt"), join(root, "link.txt"));
|
||||||
|
|
||||||
const tree = await listWorkspaceTree(root, undefined);
|
const tree = await listWorkspaceTree(root, undefined);
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ describe("listWorkspaceTree", () => {
|
|||||||
["z-dir", "directory"],
|
["z-dir", "directory"],
|
||||||
["a.txt", "file"],
|
["a.txt", "file"],
|
||||||
["b.txt", "file"],
|
["b.txt", "file"],
|
||||||
["link.txt", "symlink"],
|
...(createdSymlink ? [["link.txt", "symlink"]] : []),
|
||||||
]);
|
]);
|
||||||
expect(Date.parse(tree.scannedAt)).not.toBeNaN();
|
expect(Date.parse(tree.scannedAt)).not.toBeNaN();
|
||||||
});
|
});
|
||||||
@@ -75,3 +75,17 @@ describe("listWorkspaceTree", () => {
|
|||||||
expect(tree.truncated).toBe(true);
|
expect(tree.truncated).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function trySymlink(target: string, path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await symlink(target, path);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorWithCode(error, "EPERM")) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||||
|
return error instanceof Error && "code" in error && error.code === code;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { sanitizedGitEnv } from "../git/gitEnv.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ export interface GitWorktreeInfo {
|
|||||||
|
|
||||||
export async function isGitRepository(path: string): Promise<boolean> {
|
export async function isGitRepository(path: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"]);
|
const { stdout } = await execFileAsync("git", ["-C", path, "rev-parse", "--is-inside-work-tree"], { env: sanitizedGitEnv() });
|
||||||
return stdout.trim() === "true";
|
return stdout.trim() === "true";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -20,7 +21,7 @@ export async function isGitRepository(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
||||||
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"]);
|
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"], { env: sanitizedGitEnv() });
|
||||||
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
|
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
|
||||||
|
|
||||||
return chunks.map((chunk) => {
|
return chunks.map((chunk) => {
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
export type MachineKind = "local" | "remote";
|
||||||
|
export type MachineStatus = "unknown" | "online" | "offline" | "error";
|
||||||
|
|
||||||
|
export interface Machine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: MachineKind;
|
||||||
|
baseUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
status?: MachineStatus;
|
||||||
|
statusMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MachineHealth {
|
||||||
|
machineId: string;
|
||||||
|
ok: boolean;
|
||||||
|
checkedAt: string;
|
||||||
|
status?: MachineStatus;
|
||||||
|
web?: PiWebComponentStatus;
|
||||||
|
sessiond?: PiWebComponentStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type PiWebShortcutConfig = Record<string, string | null>;
|
export type PiWebShortcutConfig = Record<string, string | null>;
|
||||||
export type PiWebPluginSettings = Record<string, unknown>;
|
export type PiWebPluginSettings = Record<string, unknown>;
|
||||||
export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>;
|
export type PiWebPluginConfigMap = Record<string, PiWebPluginConfig>;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type FederatedHttpMethod = "GET" | "POST" | "DELETE";
|
||||||
|
|
||||||
|
export interface FederatedHttpRouteSpec {
|
||||||
|
method: FederatedHttpMethod;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEDERATED_HTTP_ROUTES = [
|
||||||
|
{ 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: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue" },
|
||||||
|
{ method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId" },
|
||||||
|
{ method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/terminal-command-runs" },
|
||||||
|
{ method: "GET", path: "/terminal-command-runs" },
|
||||||
|
{ method: "GET", path: "/terminal-command-runs/:runId" },
|
||||||
|
{ method: "POST", path: "/terminal-command-runs/:runId/cancel" },
|
||||||
|
{ 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" },
|
||||||
|
{ method: "POST", path: "/auth/oauth" },
|
||||||
|
{ method: "GET", path: "/auth/oauth/:flowId" },
|
||||||
|
{ method: "POST", path: "/auth/oauth/:flowId/respond" },
|
||||||
|
{ method: "POST", path: "/auth/oauth/:flowId/cancel" },
|
||||||
|
] as const satisfies readonly FederatedHttpRouteSpec[];
|
||||||
|
|
||||||
|
export const FEDERATED_WEBSOCKET_ROUTES = [
|
||||||
|
"/events",
|
||||||
|
"/sessions/events",
|
||||||
|
"/sessions/:sessionId/events",
|
||||||
|
"/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket",
|
||||||
|
] as const satisfies readonly string[];
|
||||||
Reference in New Issue
Block a user