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

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

`; } - const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt }); + const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.state.selectedMachine?.id ?? "local" }); return html`
${file.path}${metadata}
@@ -97,7 +97,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp function renderTerminal(context: WorkspacePanelContext): TemplateResult { loadTerminalPanel(); - return html``; + return html``; } function renderGit(context: WorkspacePanelContext): TemplateResult { diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index a221aa7..c07fc56 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -12,9 +12,11 @@ export class SessionSocket { private shouldReconnect = false; private hasOpened = false; private onReconnect: (() => void) | undefined; + private machineId = "local"; - connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void { + connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void { this.close(); + this.machineId = machineId; this.sessionId = sessionId; this.onEvent = onEvent; this.onReconnect = onReconnect; @@ -35,11 +37,12 @@ export class SessionSocket { this.onEvent = undefined; this.onReconnect = undefined; this.hasOpened = false; + this.machineId = "local"; } private open(): void { if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return; - const socket = sessionEvents(this.sessionId); + const socket = sessionEvents(this.sessionId, this.machineId); this.socket = socket; socket.onopen = () => { this.reconnectDelay = 500; @@ -75,9 +78,11 @@ export class RealtimeSocket { private reconnectTimer?: number; private reconnectDelay = 500; private shouldReconnect = false; + private machineId = "local"; - connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void): void { + connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void, machineId = "local"): void { this.close(); + this.machineId = machineId; this.onEvent = onEvent; this.onOpen = onOpen; this.shouldReconnect = true; @@ -91,11 +96,12 @@ export class RealtimeSocket { this.socket = undefined; this.onEvent = undefined; this.onOpen = undefined; + this.machineId = "local"; } private open(): void { if (!this.shouldReconnect) return; - const socket = realtimeEvents(); + const socket = realtimeEvents(this.machineId); this.socket = socket; socket.onopen = () => { this.reconnectDelay = 500; @@ -129,9 +135,11 @@ export class GlobalSessionSocket { private reconnectTimer?: number; private reconnectDelay = 500; private shouldReconnect = false; + private machineId = "local"; - connect(onEvent: (event: GlobalSessionEvent) => void): void { + connect(onEvent: (event: GlobalSessionEvent) => void, machineId = "local"): void { this.close(); + this.machineId = machineId; this.onEvent = onEvent; this.shouldReconnect = true; this.open(); @@ -143,11 +151,12 @@ export class GlobalSessionSocket { closeSocketQuietly(this.socket); this.socket = undefined; this.onEvent = undefined; + this.machineId = "local"; } private open(): void { if (!this.shouldReconnect) return; - const socket = globalSessionEvents(); + const socket = globalSessionEvents(this.machineId); this.socket = socket; socket.onopen = () => { this.reconnectDelay = 500; diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 865659c..f56c554 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -77,6 +77,31 @@ describe("buildApp", () => { expect(emptyListResponse.json()).toEqual([]); }); + it("serves local session proxy routes through machine-scoped aliases", async () => { + const response = await app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(projectDir)}` }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toHaveProperty("error"); + }); + + it("serves local projects and workspaces through machine-scoped aliases", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/machines/local/projects", + payload: { name: "Machine Local", path: projectDir, create: true }, + }); + expect(addResponse.statusCode).toBe(200); + const project = addResponse.json(); + + const listResponse = await app.inject({ method: "GET", url: "/api/machines/local/projects" }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toEqual([project]); + + const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` }); + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]); + }); + it("serves the PI WEB plugin manifest and plugin assets", async () => { const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); expect(manifestResponse.statusCode).toBe(200); diff --git a/src/server/app.ts b/src/server/app.ts index fd516b6..e02fc9c 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -27,6 +27,56 @@ export interface AppDependencies { logger?: FastifyServerOptions["logger"]; } +function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void { + app.get(`${prefix}/projects`, async () => projects.list()); + + app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => { + try { + return await projects.add(request.body); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.delete<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId`, async (request, reply) => { + try { + await projects.close(request.params.projectId); + return { closed: true }; + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.get<{ Querystring: { q?: string } }>(`${prefix}/project-directories`, async (request, reply) => { + try { + return await listDirectorySuggestions(request.query.q ?? ""); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => { + try { + const project = await projects.requireProject(request.params.projectId); + return await workspaces.list(project); + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); +} + +function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void { + app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>(`${prefix}/files`, async (request, reply) => { + if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); + try { + if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? ""); + return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); +} + export async function buildApp(deps: AppDependencies = {}): Promise { const app = Fastify({ logger: deps.logger ?? true }); await app.register(fastifyWebsocket); @@ -48,56 +98,20 @@ export async function buildApp(deps: AppDependencies = {}): Promise projects.list()); - - app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => { - try { - return await projects.add(request.body); - } catch (error) { - return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); - - app.delete<{ Params: { projectId: string } }>("/api/projects/:projectId", async (request, reply) => { - try { - await projects.close(request.params.projectId); - return { closed: true }; - } catch (error) { - return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); - - app.get<{ Querystring: { q?: string } }>("/api/project-directories", async (request, reply) => { - try { - return await listDirectorySuggestions(request.query.q ?? ""); - } catch (error) { - return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); - - app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces", async (request, reply) => { - try { - const project = await projects.requireProject(request.params.projectId); - return await workspaces.list(project); - } catch (error) { - return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); + registerLocalProjectRoutes(app, projects, workspaces, "/api"); + registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local"); registerSessionProxyRoutes(app); + registerSessionProxyRoutes(app, undefined, "/api/machines/local"); registerWorkspaceExplorerRoutes(app, projects, workspaces); + registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local"); registerGitRoutes(app, projects, workspaces); + registerGitRoutes(app, projects, workspaces, "/api/machines/local"); registerTerminalProxyRoutes(app, projects, workspaces); + registerTerminalProxyRoutes(app, projects, workspaces, undefined, "/api/machines/local"); - app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => { - if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); - try { - if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? ""); - return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); - } catch (error) { - return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); - } - }); + registerLocalFileSuggestionRoutes(app, "/api"); + registerLocalFileSuggestionRoutes(app, "/api/machines/local"); const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client"); const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client")); diff --git a/src/server/gitRoutes.ts b/src/server/gitRoutes.ts index 0246265..beb7e0a 100644 --- a/src/server/gitRoutes.ts +++ b/src/server/gitRoutes.ts @@ -4,8 +4,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js"; import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; import { gitDiff, gitStatus } from "./git/gitService.js"; -export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void { - app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/status", async (request, reply) => { +export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void { + app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/status`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await gitStatus(context.root); @@ -14,7 +14,7 @@ export function registerGitRoutes(app: FastifyInstance, projects: ProjectService } }); - app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/diff", async (request, reply) => { + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/diff`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" }); diff --git a/src/server/sessiond/sessionProxyRoutes.test.ts b/src/server/sessiond/sessionProxyRoutes.test.ts new file mode 100644 index 0000000..7911741 --- /dev/null +++ b/src/server/sessiond/sessionProxyRoutes.test.ts @@ -0,0 +1,49 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import fastifyWebsocket from "@fastify/websocket"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { registerSessionProxyRoutes } from "./sessionProxyRoutes"; + +let app: FastifyInstance; +let daemon: FakeSessionDaemon; + +beforeEach(async () => { + app = Fastify({ logger: false }); + await app.register(fastifyWebsocket); + daemon = new FakeSessionDaemon(); + registerSessionProxyRoutes(app, daemon, "/api/machines/local"); +}); + +afterEach(async () => { + await app.close(); +}); + +describe("machine-scoped session proxy routes", () => { + it("strips the machine prefix before forwarding session requests", async () => { + const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions?cwd=/repo" }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]); + }); + + it("strips the machine prefix before forwarding auth requests", async () => { + const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]); + }); +}); + +class FakeSessionDaemon { + readonly requests: { method: string; path: string; body: unknown }[] = []; + + request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }> { + this.requests.push({ method, path, body }); + return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }); + } + + connectWebSocket(): never { + throw new Error("not implemented"); + } +} diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index 37789d7..7a64630 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -2,10 +2,15 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import { WebSocket, type RawData } from "ws"; import { SessionDaemonClient } from "./sessionDaemonClient.js"; -export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void { +export interface SessionProxyDaemon { + request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; + connectWebSocket(path: string): WebSocket; +} + +export function registerSessionProxyRoutes(app: FastifyInstance, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void { const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => { try { - const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body); + const upstream = await daemon.request(request.method, stripPrefix(request.url, prefix), request.body); reply.code(upstream.statusCode); const contentType = upstream.headers["content-type"]; if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType); @@ -16,29 +21,31 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se } }; - app.get("/api/sessiond/health", (_request, reply) => proxy({ method: "GET", url: "/api/health" }, reply)); + app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply)); - app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => { + app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => { bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`)); }); - app.get("/api/sessions/events", { websocket: true }, (socket) => { + app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => { bridgeSockets(socket, daemon.connectWebSocket("/sessions/events")); }); - app.get("/api/events", { websocket: true }, (socket) => { + app.get(`${prefix}/events`, { websocket: true }, (socket) => { bridgeSockets(socket, daemon.connectWebSocket("/events")); }); - app.all("/api/activity", (request, reply) => proxy(request, reply)); - app.all("/api/auth", (request, reply) => proxy(request, reply)); - app.all("/api/auth/*", (request, reply) => proxy(request, reply)); - app.all("/api/sessions", (request, reply) => proxy(request, reply)); - app.all("/api/sessions/*", (request, reply) => proxy(request, reply)); + app.all(`${prefix}/activity`, (request, reply) => proxy(request, reply)); + app.all(`${prefix}/auth`, (request, reply) => proxy(request, reply)); + app.all(`${prefix}/auth/*`, (request, reply) => proxy(request, reply)); + app.all(`${prefix}/sessions`, (request, reply) => proxy(request, reply)); + app.all(`${prefix}/sessions/*`, (request, reply) => proxy(request, reply)); } -function stripApiPrefix(url: string): string { - const stripped = url.startsWith("/api") ? url.slice(4) : url; +function stripPrefix(url: string, prefix: string): string { + const path = url.split("?", 1)[0] ?? url; + const query = url.slice(path.length); + const stripped = path.startsWith(prefix) ? `${path.slice(prefix.length)}${query}` : url; return stripped === "" ? "/" : stripped; } diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts index d5e2dee..58c21bd 100644 --- a/src/server/terminalProxyRoutes.ts +++ b/src/server/terminalProxyRoutes.ts @@ -6,8 +6,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js"; import { terminalSizeQuery } from "./terminals/terminalSize.js"; import { bridgeSockets } from "./webSocketBridge.js"; -export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient()): void { - app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => { +export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient(), prefix = "/api"): void { + app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply); @@ -17,7 +17,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => { + app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply); @@ -27,7 +27,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue", async (request, reply) => { + app.post<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/continue`, async (request, reply) => { try { await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply); @@ -37,7 +37,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId", async (request, reply) => { + app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId`, async (request, reply) => { try { await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply); @@ -47,7 +47,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>("/api/projects/:projectId/workspaces/:workspaceId/terminal-command-runs", async (request, reply) => { + app.post<{ Params: { projectId: string; workspaceId: string }; Body: TerminalCommandRunRequest }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminal-command-runs`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await proxyJson(daemon, "POST", "/terminal-command-runs", { @@ -65,7 +65,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.get<{ Querystring: TerminalCommandRunQuery }>("/api/terminal-command-runs", async (request, reply) => { + app.get<{ Querystring: TerminalCommandRunQuery }>(`${prefix}/terminal-command-runs`, async (request, reply) => { try { return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply); } catch (error) { @@ -74,7 +74,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.post<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId/cancel", async (request, reply) => { + app.post<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId/cancel`, async (request, reply) => { try { return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply); } catch (error) { @@ -83,7 +83,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.get<{ Params: { runId: string } }>("/api/terminal-command-runs/:runId", async (request, reply) => { + app.get<{ Params: { runId: string } }>(`${prefix}/terminal-command-runs/:runId`, async (request, reply) => { try { return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply); } catch (error) { @@ -92,7 +92,7 @@ export function registerTerminalProxyRoutes(app: FastifyInstance, projects: Proj } }); - app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket", { websocket: true }, async (socket, request) => { + app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string }; Querystring: { cols?: string; rows?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket`, { websocket: true }, async (socket, request) => { try { await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); const sizeQuery = terminalSizeQuery(request.query.cols, request.query.rows); diff --git a/src/server/workspaceExplorerRoutes.ts b/src/server/workspaceExplorerRoutes.ts index 9a793cc..1c76e50 100644 --- a/src/server/workspaceExplorerRoutes.ts +++ b/src/server/workspaceExplorerRoutes.ts @@ -6,8 +6,8 @@ import { listWorkspaceTree } from "./workspaces/fileTreeService.js"; import { readWorkspaceFile } from "./workspaces/fileContentService.js"; import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js"; -export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void { - app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => { +export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void { + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await listWorkspaceTree(context.root, request.query.path); @@ -16,7 +16,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: } }); - app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file", async (request, reply) => { + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); return await readWorkspaceFile(context.root, request.query.path); @@ -25,7 +25,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: } }); - app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file/preview", async (request, reply) => { + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); const preview = await readWorkspaceImagePreview(context.root, request.query.path);