Archived
feat: add machine-scoped local API aliases
This commit is contained in:
@@ -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.
|
||||||
+286
-26
@@ -654,56 +654,316 @@ Acceptance:
|
|||||||
|
|
||||||
### Phase 2: Machine-scoped local aliases
|
### 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.
|
- Prefer extracting existing route registration functions to accept a path prefix when this is low-risk.
|
||||||
- Add `/api/machines/local/sessions...` proxy wrappers to local sessiond.
|
- If extraction would be broad, add small wrapper route modules first and refactor later.
|
||||||
- Add `/api/machines/local/events` WebSocket wrappers.
|
- Keep every existing non-machine-scoped route as a compatibility alias for `local`.
|
||||||
- Update client API/controllers to use machine-scoped endpoints.
|
- Migrate client calls in route-family slices so regressions are easy to isolate:
|
||||||
- Keep compatibility aliases.
|
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:
|
Acceptance:
|
||||||
|
|
||||||
- Browser uses `/api/machines/local/...` for normal operation.
|
- Browser network panel shows `/api/machines/local/...` for local project/workspace/session activity.
|
||||||
- Compatibility aliases still pass tests.
|
- 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
|
### 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`.
|
```text
|
||||||
- Add `GET /api/machines/:id/health`.
|
GET /api/machines/:id/projects
|
||||||
- Proxy machine-scoped HTTP routes for remote machines to remote compatibility routes.
|
POST /api/machines/:id/projects
|
||||||
- Add token/header support for gateway-to-remote authentication.
|
DELETE /api/machines/:id/projects/:projectId
|
||||||
- Keep OAuth provider login/logout flows remote-direct unless callback origin behavior is explicitly implemented.
|
GET /api/machines/:id/project-directories?q=...
|
||||||
- Add UI for add/remove remote machines.
|
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/<compat-path>?query
|
||||||
|
-> <machine.baseUrl>/api/<compat-path>?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 <token>` 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 `<baseUrl>/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:
|
Acceptance:
|
||||||
|
|
||||||
- Register another running Pi Web by URL.
|
- Register another running Pi Web by URL.
|
||||||
- List remote projects/workspaces/sessions.
|
- Health shows online/offline without blocking the UI.
|
||||||
- Start session and send prompt via proxied HTTP.
|
- 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
|
### 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.
|
```text
|
||||||
- Proxy global events/activity WebSocket for selected machine.
|
WS /api/machines/:id/events
|
||||||
- Proxy terminal socket WebSockets.
|
-> <remote ws base>/api/events
|
||||||
- Make `SessionSocket`, `RealtimeSocket`, and `terminalSocket` machine-scoped.
|
WS /api/machines/:id/sessions/events
|
||||||
|
-> <remote ws base>/api/sessions/events
|
||||||
|
WS /api/machines/:id/sessions/:sessionId/events
|
||||||
|
-> <remote ws base>/api/sessions/:sessionId/events
|
||||||
|
WS /api/machines/:id/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket?cols=...&rows=...
|
||||||
|
-> <remote ws base>/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:
|
Acceptance:
|
||||||
|
|
||||||
- Remote assistant streaming appears live.
|
- Remote assistant streaming appears live.
|
||||||
- Remote status/activity updates appear.
|
- 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
|
### Phase 5: UX polish and docs
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ import {
|
|||||||
parseWorkspace,
|
parseWorkspace,
|
||||||
parseWorkspaceActivityResponse,
|
parseWorkspaceActivityResponse,
|
||||||
} from "./parsers";
|
} from "./parsers";
|
||||||
import { gitDiffUrl, messageUrl } from "./urls";
|
import { machineGitDiffUrl, messageUrl } from "./urls";
|
||||||
|
|
||||||
|
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||||
|
|
||||||
export const piWebApi = {
|
export const piWebApi = {
|
||||||
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
||||||
@@ -45,64 +47,64 @@ export const machinesApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const activityApi = {
|
export const activityApi = {
|
||||||
workspaceActivity: () => request("/api/activity", parseWorkspaceActivityResponse),
|
workspaceActivity: (machineId = "local") => request(`${machinePrefix(machineId)}/activity`, parseWorkspaceActivityResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const projectsApi = {
|
export const projectsApi = {
|
||||||
projects: () => request("/api/projects", arrayOf(parseProject)),
|
projects: (machineId = "local") => request(`${machinePrefix(machineId)}/projects`, arrayOf(parseProject)),
|
||||||
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
|
addProject: (path: string, name?: string, create?: boolean, machineId = "local") => request(`${machinePrefix(machineId)}/projects`, parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
|
||||||
closeProject: (projectId: string) => request(`/api/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }),
|
closeProject: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}`, parseClosed, { method: "DELETE" }),
|
||||||
projectDirectories: (query: string) => request(`/api/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)),
|
projectDirectories: (query: string, machineId = "local") => request(`${machinePrefix(machineId)}/project-directories?q=${encodeURIComponent(query)}`, arrayOf(parseFileSuggestion)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const workspacesApi = {
|
export const workspacesApi = {
|
||||||
workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||||
workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||||
workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sessionsApi = {
|
export const sessionsApi = {
|
||||||
sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||||
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||||
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
messages: (sessionId: string, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(sessionId, options, machineId), parseMessagePage),
|
||||||
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
status: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/status`, parseSessionStatus),
|
||||||
models: (sessionId: string) => request(`/api/sessions/${sessionId}/models`, parseModelSelectionResponse),
|
models: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/models`, parseModelSelectionResponse),
|
||||||
setModel: (sessionId: string, provider: string, modelId: string) => request(`/api/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
|
setModel: (sessionId: string, provider: string, modelId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
|
||||||
cycleModel: (sessionId: string, direction: "forward" | "backward") => request(`/api/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
|
cycleModel: (sessionId: string, direction: "forward" | "backward", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
|
||||||
thinkingLevels: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
|
thinkingLevels: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
|
||||||
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh") => request(`/api/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
|
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
|
||||||
cycleThinkingLevel: (sessionId: string) => request(`/api/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
|
cycleThinkingLevel: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
|
||||||
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
commands: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
||||||
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
||||||
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
shell: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
runCommand: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
respondToCommand: (sessionId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||||
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
abort: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
||||||
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
stop: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
||||||
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
archive: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
||||||
archiveWithDescendants: (sessionId: string) => request(`/api/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
|
archiveWithDescendants: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
|
||||||
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
restore: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||||
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
|
detachParent: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
|
||||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => {
|
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.mode !== undefined) params.set("mode", options.mode);
|
if (options?.mode !== undefined) params.set("mode", options.mode);
|
||||||
if (options?.authType !== undefined) params.set("authType", options.authType);
|
if (options?.authType !== undefined) params.set("authType", options.authType);
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return request(`/api/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
|
return request(`${machinePrefix(options?.machineId)}/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
|
||||||
},
|
},
|
||||||
saveApiKey: (providerId: string, key: string) => request("/api/auth/api-key", parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
|
saveApiKey: (providerId: string, key: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/api-key`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
|
||||||
logoutProvider: (providerId: string) => request("/api/auth/logout", parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
|
logoutProvider: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/logout`, parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
|
||||||
startOAuthLogin: (providerId: string) => request("/api/auth/oauth", parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
|
startOAuthLogin: (providerId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
|
||||||
oauthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
|
oauthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
|
||||||
respondOAuthFlow: (flowId: string, requestId: string, value: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
respondOAuthFlow: (flowId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||||
cancelOAuthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }),
|
cancelOAuthFlow: (flowId: string, machineId = "local") => request(`${machinePrefix(machineId)}/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const terminalsApi = {
|
export const terminalsApi = {
|
||||||
terminals: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
|
terminals: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
|
||||||
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
||||||
closeTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
closeTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
||||||
continueTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
|
continueTerminal: (projectId: string, workspaceId: string, terminalId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/continue`, parseTerminalInfo, { method: "POST" }),
|
||||||
runTerminalCommand: (origin: string, input: RunTerminalCommandInput) => request(`/api/projects/${encodeURIComponent(input.workspace.projectId)}/workspaces/${encodeURIComponent(input.workspace.id)}/terminal-command-runs`, parseTerminalCommandRun, { method: "POST", body: JSON.stringify({ origin, title: input.title, command: input.command, metadata: input.metadata ?? {} }) }),
|
runTerminalCommand: (origin: string, input: RunTerminalCommandInput) => 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)),
|
listCommandRuns: (filter?: TerminalCommandRunFilter) => request(`/api/terminal-command-runs${terminalCommandRunFilterQuery(filter)}`, arrayOf(parseTerminalCommandRun)),
|
||||||
getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId),
|
getCommandRun: (runId: string) => getOptionalTerminalCommandRun(runId),
|
||||||
@@ -142,12 +144,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
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 = {
|
export const gitApi = {
|
||||||
gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
||||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse),
|
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffUrl(machineId, projectId, workspaceId, options), parseGitDiffResponse),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||||
|
|
||||||
|
const webSocketUrls: string[] = [];
|
||||||
|
|
||||||
|
function FakeWebSocket(url: string): void {
|
||||||
|
webSocketUrls.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
webSocketUrls.length = 0;
|
||||||
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||||
|
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("machine-scoped socket urls", () => {
|
||||||
|
it("defaults session sockets to the local machine scope", () => {
|
||||||
|
sessionEvents("s1");
|
||||||
|
globalSessionEvents();
|
||||||
|
realtimeEvents();
|
||||||
|
|
||||||
|
expect(webSocketUrls).toEqual([
|
||||||
|
"wss://pi.example.test/api/machines/local/sessions/s1/events",
|
||||||
|
"wss://pi.example.test/api/machines/local/sessions/events",
|
||||||
|
"wss://pi.example.test/api/machines/local/events",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the requested machine scope for terminal sockets", () => {
|
||||||
|
terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a");
|
||||||
|
|
||||||
|
expect(webSocketUrls).toEqual([
|
||||||
|
"wss://pi.example.test/api/machines/remote-a/projects/p%201/workspaces/w%2F1/terminals/t%3F1/socket?cols=120&rows=40",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
export function sessionEvents(sessionId: string): WebSocket {
|
export function sessionEvents(sessionId: string, machineId = "local"): WebSocket {
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`);
|
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${sessionId}/events`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function globalSessionEvents(): WebSocket {
|
export function globalSessionEvents(machineId = "local"): WebSocket {
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
|
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/events`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }): WebSocket {
|
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket {
|
||||||
const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`;
|
const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`;
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
|
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function realtimeEvents(): WebSocket {
|
export function realtimeEvents(machineId = "local"): WebSocket {
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/events`);
|
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/events`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function machinePrefix(machineId: string): string {
|
||||||
|
return `/api/machines/${encodeURIComponent(machineId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webSocketBaseUrl(): string {
|
function webSocketBaseUrl(): string {
|
||||||
|
|||||||
@@ -6,17 +6,26 @@ export function gitDiffUrl(projectId: string, workspaceId: string, options?: { p
|
|||||||
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
|
export function machineGitDiffUrl(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.path !== undefined) params.set("path", options.path);
|
||||||
|
if (options?.staged === true) params.set("staged", "true");
|
||||||
|
const query = params.toString();
|
||||||
|
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }, machineId = "local"): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string }): string {
|
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("path", path);
|
params.set("path", path);
|
||||||
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
|
if (options?.modifiedAt !== undefined) params.set("v", options.modifiedAt);
|
||||||
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
|
const prefix = `/api/machines/${encodeURIComponent(options?.machineId ?? "local")}`;
|
||||||
|
return `${prefix}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file/preview?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { SessionController } from "../controllers/sessionController";
|
|||||||
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
|
||||||
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
|
import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection";
|
||||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
|
import { selectedMachineId } from "../controllers/types";
|
||||||
import { RealtimeSocket } from "../sessionSocket";
|
import { RealtimeSocket } from "../sessionSocket";
|
||||||
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
|
import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
|
||||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||||
@@ -474,6 +475,7 @@ export class PiWebApp extends LitElement {
|
|||||||
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
|
if (workspace !== undefined) void this.refreshActiveTerminals(workspace);
|
||||||
void this.refreshWorkspaceActivity();
|
void this.refreshWorkspaceActivity();
|
||||||
},
|
},
|
||||||
|
selectedMachineId(this.state),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +503,7 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
|
private async refreshActiveTerminals(workspace: Workspace): Promise<void> {
|
||||||
try {
|
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;
|
if (this.state.selectedWorkspace?.id !== workspace.id) return;
|
||||||
this.activeTerminalIds.clear();
|
this.activeTerminalIds.clear();
|
||||||
for (const terminal of terminals) {
|
for (const terminal of terminals) {
|
||||||
@@ -1241,7 +1243,7 @@ export class PiWebApp extends LitElement {
|
|||||||
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
||||||
${state.selectedSession ? html`
|
${state.selectedSession ? html`
|
||||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
|
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
|
||||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||||
@@ -1251,7 +1253,7 @@ export class PiWebApp extends LitElement {
|
|||||||
</main>
|
</main>
|
||||||
${this.renderWorkspacePanel()}
|
${this.renderWorkspacePanel()}
|
||||||
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||||
${state.projectDialogOpen ? html`<project-dialog .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||||
${this.renderRefreshMenu()}
|
${this.renderRefreshMenu()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { css } from "lit";
|
|||||||
export class ProjectDialog extends LitElement {
|
export class ProjectDialog extends LitElement {
|
||||||
@property({ attribute: false }) onSubmit?: (path: string, create: boolean) => void;
|
@property({ attribute: false }) onSubmit?: (path: string, create: boolean) => void;
|
||||||
@property({ attribute: false }) onCancel?: () => void;
|
@property({ attribute: false }) onCancel?: () => void;
|
||||||
|
@property() machineId = "local";
|
||||||
@state() private path = "";
|
@state() private path = "";
|
||||||
@state() private createMissing = true;
|
@state() private createMissing = true;
|
||||||
@state() private suggestions: FileSuggestion[] = [];
|
@state() private suggestions: FileSuggestion[] = [];
|
||||||
@@ -29,7 +30,7 @@ export class ProjectDialog extends LitElement {
|
|||||||
const requestId = ++this.requestId;
|
const requestId = ++this.requestId;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const suggestions = await api.projectDirectories(this.path);
|
const suggestions = await api.projectDirectories(this.path, this.machineId);
|
||||||
if (requestId !== this.requestId) return;
|
if (requestId !== this.requestId) return;
|
||||||
this.suggestions = suggestions;
|
this.suggestions = suggestions;
|
||||||
this.selected = Math.min(this.selected, Math.max(0, suggestions.length - 1));
|
this.selected = Math.min(this.selected, Math.max(0, suggestions.length - 1));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class PromptEditor extends LitElement {
|
|||||||
@property({ type: Boolean }) disabled = false;
|
@property({ type: Boolean }) disabled = false;
|
||||||
@property() sessionId?: string;
|
@property() sessionId?: string;
|
||||||
@property() cwd?: string;
|
@property() cwd?: string;
|
||||||
|
@property() machineId = "local";
|
||||||
@property({ type: Boolean }) canSteer = false;
|
@property({ type: Boolean }) canSteer = false;
|
||||||
@property({ type: Boolean }) isCompacting = false;
|
@property({ type: Boolean }) isCompacting = false;
|
||||||
@property({ type: Boolean }) canStop = false;
|
@property({ type: Boolean }) canStop = false;
|
||||||
@@ -168,7 +169,7 @@ export class PromptEditor extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
|
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
|
||||||
const commands = await api.commands(this.sessionId).catch(emptySlashCommands);
|
const commands = await api.commands(this.sessionId, this.machineId).catch(emptySlashCommands);
|
||||||
if (version !== this.requestVersion) return;
|
if (version !== this.requestVersion) return;
|
||||||
this.completions = commands
|
this.completions = commands
|
||||||
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
|
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
|
||||||
@@ -182,7 +183,7 @@ export class PromptEditor extends LitElement {
|
|||||||
...(command.description === undefined ? {} : { description: command.description }),
|
...(command.description === undefined ? {} : { description: command.description }),
|
||||||
}));
|
}));
|
||||||
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
||||||
const files = await api.files(this.cwd, trigger.query, 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;
|
if (version !== this.requestVersion) return;
|
||||||
this.completions = files
|
this.completions = files
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const COMMAND_RUN_POLL_INTERVAL_MS = 1000;
|
|||||||
@customElement("terminal-panel")
|
@customElement("terminal-panel")
|
||||||
export class TerminalPanel extends LitElement {
|
export class TerminalPanel extends LitElement {
|
||||||
@property({ attribute: false }) workspace: Workspace | undefined;
|
@property({ attribute: false }) workspace: Workspace | undefined;
|
||||||
|
@property() machineId = "local";
|
||||||
@property({ attribute: false }) selectedTerminalId: string | undefined;
|
@property({ attribute: false }) selectedTerminalId: string | undefined;
|
||||||
@property({ type: Boolean }) autoStart = false;
|
@property({ type: Boolean }) autoStart = false;
|
||||||
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
|
@property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined;
|
||||||
@@ -116,7 +117,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
if (workspace === undefined) return;
|
if (workspace === undefined) return;
|
||||||
const shouldAutoStart = this.consumeAutoStart();
|
const shouldAutoStart = this.consumeAutoStart();
|
||||||
const [terminals, commandRuns] = await Promise.all([
|
const [terminals, commandRuns] = await Promise.all([
|
||||||
terminalsApi.terminals(workspace.projectId, workspace.id),
|
terminalsApi.terminals(workspace.projectId, workspace.id, this.machineId),
|
||||||
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }),
|
terminalsApi.listCommandRuns({ projectId: workspace.projectId, workspaceId: workspace.id }),
|
||||||
]);
|
]);
|
||||||
this.terminals = terminals;
|
this.terminals = terminals;
|
||||||
@@ -173,7 +174,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
this.error = undefined;
|
this.error = undefined;
|
||||||
try {
|
try {
|
||||||
const size = this.measureTerminalSize() ?? DEFAULT_TERMINAL_SIZE;
|
const size = this.measureTerminalSize() ?? DEFAULT_TERMINAL_SIZE;
|
||||||
const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, size);
|
const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, size, this.machineId);
|
||||||
this.terminals = [...this.terminals, terminal];
|
this.terminals = [...this.terminals, terminal];
|
||||||
this.selectTerminal(terminal.id);
|
this.selectTerminal(terminal.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,7 +186,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
try {
|
try {
|
||||||
if (this.workspace === undefined) return;
|
if (this.workspace === undefined) return;
|
||||||
await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id);
|
await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id, this.machineId);
|
||||||
const next = this.terminals.filter((terminal) => terminal.id !== id);
|
const next = this.terminals.filter((terminal) => terminal.id !== id);
|
||||||
this.terminals = next;
|
this.terminals = next;
|
||||||
if (this.selectedId === id || this.selectedTerminalId === id) {
|
if (this.selectedId === id || this.selectedTerminalId === id) {
|
||||||
@@ -296,7 +297,7 @@ export class TerminalPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private connectSocket(projectId: string, workspaceId: string, terminalId: string, terminal: Terminal, initialSize: TerminalSize | undefined): void {
|
private connectSocket(projectId: string, workspaceId: string, terminalId: string, terminal: Terminal, initialSize: TerminalSize | undefined): void {
|
||||||
const socket = terminalSocket(projectId, workspaceId, terminalId, initialSize);
|
const socket = terminalSocket(projectId, workspaceId, terminalId, initialSize, this.machineId);
|
||||||
socket.binaryType = "arraybuffer";
|
socket.binaryType = "arraybuffer";
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.addEventListener("open", () => { this.fitAndNotify(); });
|
socket.addEventListener("open", () => { this.fitAndNotify(); });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
|
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
|
||||||
import { isWorkspaceActivityActive } from "../../../shared/activity";
|
import { isWorkspaceActivityActive } from "../../../shared/activity";
|
||||||
import type { GetState, SetState } from "./types";
|
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||||
|
|
||||||
export interface ActivityControllerDependencies {
|
export interface ActivityControllerDependencies {
|
||||||
api?: Pick<typeof defaultApi, "workspaceActivity">;
|
api?: Pick<typeof defaultApi, "workspaceActivity">;
|
||||||
@@ -14,7 +14,7 @@ export class ActivityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
const snapshot = await this.api.workspaceActivity();
|
const snapshot = await this.api.workspaceActivity(selectedMachineId(this.getState()));
|
||||||
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
|
this.setState({ workspaceActivities: indexWorkspaceActivities(snapshot) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
|
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
|
||||||
import type { GetState, SetState } from "./types";
|
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||||
|
|
||||||
export interface AuthControllerDependencies {
|
export interface AuthControllerDependencies {
|
||||||
api?: typeof defaultApi;
|
api?: typeof defaultApi;
|
||||||
@@ -43,7 +43,7 @@ export class AuthController {
|
|||||||
|
|
||||||
async chooseLoginMethod(authType: AuthType): Promise<void> {
|
async chooseLoginMethod(authType: AuthType): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { providers } = await this.api.authProviders({ mode: "login", authType });
|
const { providers } = await this.api.authProviders({ mode: "login", authType, machineId: selectedMachineId(this.getState()) });
|
||||||
this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } });
|
this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -79,7 +79,7 @@ export class AuthController {
|
|||||||
delete clean.error;
|
delete clean.error;
|
||||||
this.setState({ authDialog: { ...clean, saving: true } });
|
this.setState({ authDialog: { ...clean, saving: true } });
|
||||||
try {
|
try {
|
||||||
await this.api.saveApiKey(dialog.provider.id, key);
|
await this.api.saveApiKey(dialog.provider.id, key, selectedMachineId(this.getState()));
|
||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
void this.refreshStatus();
|
void this.refreshStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -89,7 +89,7 @@ export class AuthController {
|
|||||||
|
|
||||||
async openLogout(providerId?: string): Promise<void> {
|
async openLogout(providerId?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { providers } = await this.api.authProviders({ mode: "logout" });
|
const { providers } = await this.api.authProviders({ mode: "logout", machineId: selectedMachineId(this.getState()) });
|
||||||
if (providerId !== undefined && providerId !== "") {
|
if (providerId !== undefined && providerId !== "") {
|
||||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||||
if (provider !== undefined) await this.logoutProvider(provider.id);
|
if (provider !== undefined) await this.logoutProvider(provider.id);
|
||||||
@@ -104,7 +104,7 @@ export class AuthController {
|
|||||||
|
|
||||||
async logoutProvider(providerId: string): Promise<void> {
|
async logoutProvider(providerId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.api.logoutProvider(providerId);
|
await this.api.logoutProvider(providerId, selectedMachineId(this.getState()));
|
||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
void this.refreshStatus();
|
void this.refreshStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -130,7 +130,7 @@ export class AuthController {
|
|||||||
delete clean.error;
|
delete clean.error;
|
||||||
this.setState({ authDialog: { ...clean, responding: true } });
|
this.setState({ authDialog: { ...clean, responding: true } });
|
||||||
try {
|
try {
|
||||||
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue);
|
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue, selectedMachineId(this.getState()));
|
||||||
this.updateOAuthFlow(flow);
|
this.updateOAuthFlow(flow);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
|
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
|
||||||
@@ -145,7 +145,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
try {
|
try {
|
||||||
await this.api.cancelOAuthFlow(dialog.flow.flowId);
|
await this.api.cancelOAuthFlow(dialog.flow.flowId, selectedMachineId(this.getState()));
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cancel. The dialog closes either way.
|
// Best-effort cancel. The dialog closes either way.
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ export class AuthController {
|
|||||||
|
|
||||||
private async openLoginProvider(providerId: string): Promise<void> {
|
private async openLoginProvider(providerId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { providers } = await this.api.authProviders({ mode: "login" });
|
const { providers } = await this.api.authProviders({ mode: "login", machineId: selectedMachineId(this.getState()) });
|
||||||
const exact = providers.filter((provider) => provider.id === providerId);
|
const exact = providers.filter((provider) => provider.id === providerId);
|
||||||
if (exact.length === 0) {
|
if (exact.length === 0) {
|
||||||
this.setState({ error: `Auth provider not found: ${providerId}` });
|
this.setState({ error: `Auth provider not found: ${providerId}` });
|
||||||
@@ -180,7 +180,7 @@ export class AuthController {
|
|||||||
|
|
||||||
private async startOAuth(provider: AuthProviderOption): Promise<void> {
|
private async startOAuth(provider: AuthProviderOption): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const flow = await this.api.startOAuthLogin(provider.id);
|
const flow = await this.api.startOAuthLogin(provider.id, selectedMachineId(this.getState()));
|
||||||
this.updateOAuthFlow(flow);
|
this.updateOAuthFlow(flow);
|
||||||
this.startPolling(flow.flowId);
|
this.startPolling(flow.flowId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -224,7 +224,7 @@ export class AuthController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.updateOAuthFlow(await this.api.oauthFlow(flowId));
|
this.updateOAuthFlow(await this.api.oauthFlow(flowId, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
this.setState({ authDialog: { ...dialog, error: String(error) } });
|
this.setState({ authDialog: { ...dialog, error: String(error) } });
|
||||||
@@ -235,7 +235,7 @@ export class AuthController {
|
|||||||
const sessionId = this.sessionId();
|
const sessionId = this.sessionId();
|
||||||
if (sessionId === undefined) return;
|
if (sessionId === undefined) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.status(sessionId));
|
this.applyStatus(await this.api.status(sessionId, selectedMachineId(this.getState())));
|
||||||
} catch {
|
} catch {
|
||||||
// Status refresh is opportunistic after login completes.
|
// Status refresh is opportunistic after login completes.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||||
|
|
||||||
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
|
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
|
||||||
|
|
||||||
@@ -12,9 +12,10 @@ export class FileExplorerController {
|
|||||||
const workspace = this.getState().selectedWorkspace;
|
const workspace = this.getState().selectedWorkspace;
|
||||||
if (project === undefined || workspace === undefined) return;
|
if (project === undefined || workspace === undefined) return;
|
||||||
try {
|
try {
|
||||||
const root = await api.workspaceTree(project.id, workspace.id);
|
const machineId = selectedMachineId(this.getState());
|
||||||
|
const root = await api.workspaceTree(project.id, workspace.id, "", machineId);
|
||||||
const expanded = { ...this.getState().expandedDirs };
|
const expanded = { ...this.getState().expandedDirs };
|
||||||
await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path)).entries; }));
|
await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path, machineId)).entries; }));
|
||||||
this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" });
|
this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -30,7 +31,7 @@ export class FileExplorerController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await api.workspaceTree(project.id, workspace.id, path);
|
const response = await api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState()));
|
||||||
this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" });
|
this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -50,7 +51,7 @@ export class FileExplorerController {
|
|||||||
if (project === undefined || workspace === undefined) return;
|
if (project === undefined || workspace === undefined) return;
|
||||||
this.setState({ selectedFilePath: path, selectedFileContent: undefined });
|
this.setState({ selectedFilePath: path, selectedFileContent: undefined });
|
||||||
try {
|
try {
|
||||||
const content = await api.workspaceFile(project.id, workspace.id, path);
|
const content = await api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState()));
|
||||||
if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" });
|
if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.getState().selectedFilePath !== path) return;
|
if (this.getState().selectedFilePath !== path) return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||||
|
|
||||||
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
|
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export class GitController {
|
|||||||
const workspace = this.getState().selectedWorkspace;
|
const workspace = this.getState().selectedWorkspace;
|
||||||
if (project === undefined || workspace === undefined) return;
|
if (project === undefined || workspace === undefined) return;
|
||||||
try {
|
try {
|
||||||
const status = await api.gitStatus(project.id, workspace.id);
|
const status = await api.gitStatus(project.id, workspace.id, selectedMachineId(this.getState()));
|
||||||
this.setState({ gitStatus: status, gitStale: false, error: "" });
|
this.setState({ gitStatus: status, gitStale: false, error: "" });
|
||||||
const selectedDiffPath = this.getState().selectedDiffPath;
|
const selectedDiffPath = this.getState().selectedDiffPath;
|
||||||
if (selectedDiffPath !== undefined) {
|
if (selectedDiffPath !== undefined) {
|
||||||
@@ -52,8 +52,8 @@ export class GitController {
|
|||||||
if (project === undefined || workspace === undefined) return;
|
if (project === undefined || workspace === undefined) return;
|
||||||
try {
|
try {
|
||||||
const [selectedDiff, selectedStagedDiff] = await Promise.all([
|
const [selectedDiff, selectedStagedDiff] = await Promise.all([
|
||||||
api.gitDiff(project.id, workspace.id, { path }),
|
api.gitDiff(project.id, workspace.id, { path }, selectedMachineId(this.getState())),
|
||||||
api.gitDiff(project.id, workspace.id, { path, staged: true }),
|
api.gitDiff(project.id, workspace.id, { path, staged: true }, selectedMachineId(this.getState())),
|
||||||
]);
|
]);
|
||||||
this.setState({ selectedDiff, selectedStagedDiff, error: "" });
|
this.setState({ selectedDiff, selectedStagedDiff, error: "" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { GetState, SetState } from "./types";
|
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||||
import type { WorkspaceController } from "./workspaceController";
|
import type { WorkspaceController } from "./workspaceController";
|
||||||
|
|
||||||
export class ProjectController {
|
export class ProjectController {
|
||||||
@@ -13,7 +13,7 @@ export class ProjectController {
|
|||||||
}
|
}
|
||||||
this.setState({ error: "", isLoadingProjects: true });
|
this.setState({ error: "", isLoadingProjects: true });
|
||||||
try {
|
try {
|
||||||
const projects = await api.projects();
|
const projects = await api.projects(selectedMachineId(this.getState()));
|
||||||
const projectIds = new Set(projects.map((project) => project.id));
|
const projectIds = new Set(projects.map((project) => project.id));
|
||||||
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId)));
|
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId)));
|
||||||
this.setState({ projects, workspacesByProjectId });
|
this.setState({ projects, workspacesByProjectId });
|
||||||
@@ -31,7 +31,7 @@ export class ProjectController {
|
|||||||
}
|
}
|
||||||
if (path.trim() === "") return;
|
if (path.trim() === "") return;
|
||||||
try {
|
try {
|
||||||
const project = await api.addProject(path.trim(), undefined, create);
|
const project = await api.addProject(path.trim(), undefined, create, selectedMachineId(this.getState()));
|
||||||
const projects = this.getState().projects;
|
const projects = this.getState().projects;
|
||||||
this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project], projectDialogOpen: false });
|
this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project], projectDialogOpen: false });
|
||||||
await this.workspaces.selectProject(project);
|
await this.workspaces.selectProject(project);
|
||||||
@@ -42,7 +42,7 @@ export class ProjectController {
|
|||||||
|
|
||||||
async closeProject(projectId: string) {
|
async closeProject(projectId: string) {
|
||||||
try {
|
try {
|
||||||
await api.closeProject(projectId);
|
await api.closeProject(projectId, selectedMachineId(this.getState()));
|
||||||
this.workspaces.forgetProject(projectId);
|
this.workspaces.forgetProject(projectId);
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
this.setState({ projects: state.projects.filter((p) => p.id !== projectId) });
|
this.setState({ projects: state.projects.filter((p) => p.id !== projectId) });
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import { isShellInput } from "../inputModes";
|
|||||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
||||||
import { isSessionActive } from "../../../shared/activity";
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
||||||
import type { GetState, SetState, UpdateUrl } from "./types";
|
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||||
|
|
||||||
const MESSAGE_PAGE_SIZE = 100;
|
const MESSAGE_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export interface SessionEventSocket {
|
export interface SessionEventSocket {
|
||||||
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void;
|
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
|
||||||
setHandler(onEvent: (event: SessionUiEvent) => void): void;
|
setHandler(onEvent: (event: SessionUiEvent) => void): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ export class SessionController {
|
|||||||
const workspace = this.getState().selectedWorkspace;
|
const workspace = this.getState().selectedWorkspace;
|
||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
try {
|
try {
|
||||||
const session = await this.api.startSession(workspace.path);
|
const session = await this.api.startSession(workspace.path, selectedMachineId(this.getState()));
|
||||||
rememberCachedNewSession(session);
|
rememberCachedNewSession(session);
|
||||||
const cachedSession = markCachedNewSessionInfo(session);
|
const cachedSession = markCachedNewSessionInfo(session);
|
||||||
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
|
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
|
||||||
@@ -113,7 +113,7 @@ export class SessionController {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (session.archived === true) {
|
if (session.archived === true) {
|
||||||
const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
|
const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||||
const history = this.transcripts.mergeHistory(session.id, page);
|
const history = this.transcripts.mergeHistory(session.id, page);
|
||||||
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||||
@@ -125,8 +125,9 @@ export class SessionController {
|
|||||||
session.id,
|
session.id,
|
||||||
(event) => buffered.push(event),
|
(event) => buffered.push(event),
|
||||||
() => { void this.refreshSelectedSession(session.id); },
|
() => { void this.refreshSelectedSession(session.id); },
|
||||||
|
selectedMachineId(this.getState()),
|
||||||
);
|
);
|
||||||
const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), this.api.status(session.id)]);
|
const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session.id, selectedMachineId(this.getState()))]);
|
||||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||||
const history = this.transcripts.mergeHistory(session.id, page);
|
const history = this.transcripts.mergeHistory(session.id, page);
|
||||||
const isReceivingPartialStream = status.isStreaming;
|
const isReceivingPartialStream = status.isStreaming;
|
||||||
@@ -152,7 +153,7 @@ export class SessionController {
|
|||||||
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
|
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
|
||||||
this.setState({ isLoadingEarlierMessages: true });
|
this.setState({ isLoadingEarlierMessages: true });
|
||||||
try {
|
try {
|
||||||
const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE });
|
const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||||
if (this.getState().selectedSession?.id !== session.id) return;
|
if (this.getState().selectedSession?.id !== session.id) return;
|
||||||
const history = this.transcripts.mergeHistory(session.id, page);
|
const history = this.transcripts.mergeHistory(session.id, page);
|
||||||
this.setState(history);
|
this.setState(history);
|
||||||
@@ -170,7 +171,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
await this.api.prompt(session.id, text, streamingBehavior);
|
await this.api.prompt(session.id, text, streamingBehavior, selectedMachineId(this.getState()));
|
||||||
this.markCachedNewSessionPersisted(session);
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
@@ -182,7 +183,7 @@ export class SessionController {
|
|||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
await this.api.shell(session.id, text);
|
await this.api.shell(session.id, text, selectedMachineId(this.getState()));
|
||||||
this.markCachedNewSessionPersisted(session);
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||||
@@ -194,7 +195,7 @@ export class SessionController {
|
|||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||||
try {
|
try {
|
||||||
this.applyCommandResult(await this.api.runCommand(session.id, text));
|
this.applyCommandResult(await this.api.runCommand(session.id, text, selectedMachineId(this.getState())));
|
||||||
this.markCachedNewSessionPersisted(session);
|
this.markCachedNewSessionPersisted(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
|
||||||
@@ -206,7 +207,7 @@ export class SessionController {
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.setState({ commandDialog: undefined });
|
this.setState({ commandDialog: undefined });
|
||||||
try {
|
try {
|
||||||
this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value));
|
this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -227,7 +228,7 @@ export class SessionController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.api.archive(session.id);
|
await this.api.archive(session.id, selectedMachineId(this.getState()));
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const sessions = markSessionArchived(state.sessions, session.id, new Date().toISOString());
|
const sessions = markSessionArchived(state.sessions, session.id, new Date().toISOString());
|
||||||
const selectionChange = selectionAfterArchivingSession(sessions, state.selectedSession?.id, session.id);
|
const selectionChange = selectionAfterArchivingSession(sessions, state.selectedSession?.id, session.id);
|
||||||
@@ -243,7 +244,7 @@ export class SessionController {
|
|||||||
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
|
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
|
||||||
if (!session || isCachedNewSessionInfo(session)) return;
|
if (!session || isCachedNewSessionInfo(session)) return;
|
||||||
try {
|
try {
|
||||||
const response = await this.api.archiveWithDescendants(session.id);
|
const response = await this.api.archiveWithDescendants(session.id, selectedMachineId(this.getState()));
|
||||||
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
|
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
|
const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
|
||||||
@@ -259,7 +260,7 @@ export class SessionController {
|
|||||||
|
|
||||||
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
||||||
if (!isCachedNewSessionInfo(session)) return;
|
if (!isCachedNewSessionInfo(session)) return;
|
||||||
void this.api.stop(session.id).catch(() => {
|
void this.api.stop(session.id, selectedMachineId(this.getState())).catch(() => {
|
||||||
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
|
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
|
||||||
});
|
});
|
||||||
forgetCachedNewSession(session.id);
|
forgetCachedNewSession(session.id);
|
||||||
@@ -278,7 +279,7 @@ export class SessionController {
|
|||||||
async restoreSession(session = this.getState().selectedSession) {
|
async restoreSession(session = this.getState().selectedSession) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
await this.api.restore(session.id);
|
await this.api.restore(session.id, selectedMachineId(this.getState()));
|
||||||
const restored = { ...session };
|
const restored = { ...session };
|
||||||
delete restored.archived;
|
delete restored.archived;
|
||||||
delete restored.archivedAt;
|
delete restored.archivedAt;
|
||||||
@@ -292,7 +293,7 @@ export class SessionController {
|
|||||||
async detachParent(session = this.getState().selectedSession) {
|
async detachParent(session = this.getState().selectedSession) {
|
||||||
if (session?.parentSessionPath === undefined) return;
|
if (session?.parentSessionPath === undefined) return;
|
||||||
try {
|
try {
|
||||||
await this.api.detachParent(session.id);
|
await this.api.detachParent(session.id, selectedMachineId(this.getState()));
|
||||||
const detached = { ...session };
|
const detached = { ...session };
|
||||||
delete detached.parentSessionPath;
|
delete detached.parentSessionPath;
|
||||||
this.replaceSession(detached);
|
this.replaceSession(detached);
|
||||||
@@ -305,7 +306,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return [];
|
if (!session || session.archived === true) return [];
|
||||||
try {
|
try {
|
||||||
return (await this.api.models(session.id)).models;
|
return (await this.api.models(session.id, selectedMachineId(this.getState()))).models;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
return [];
|
return [];
|
||||||
@@ -316,7 +317,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.setModel(session.id, provider, modelId));
|
this.applyStatus(await this.api.setModel(session.id, provider, modelId, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -326,7 +327,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.cycleModel(session.id, direction));
|
this.applyStatus(await this.api.cycleModel(session.id, direction, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -336,7 +337,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return [];
|
if (!session || session.archived === true) return [];
|
||||||
try {
|
try {
|
||||||
return (await this.api.thinkingLevels(session.id)).levels;
|
return (await this.api.thinkingLevels(session.id, selectedMachineId(this.getState()))).levels;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
return [];
|
return [];
|
||||||
@@ -347,7 +348,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.setThinkingLevel(session.id, level));
|
this.applyStatus(await this.api.setThinkingLevel(session.id, level, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -357,7 +358,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session || session.archived === true) return;
|
if (!session || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.applyStatus(await this.api.cycleThinkingLevel(session.id));
|
this.applyStatus(await this.api.cycleThinkingLevel(session.id, selectedMachineId(this.getState())));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -367,7 +368,7 @@ export class SessionController {
|
|||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
await this.api.abort(session.id);
|
await this.api.abort(session.id, selectedMachineId(this.getState()));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error: String(error) });
|
this.setState({ error: String(error) });
|
||||||
}
|
}
|
||||||
@@ -378,7 +379,7 @@ export class SessionController {
|
|||||||
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
|
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
|
||||||
try {
|
try {
|
||||||
this.flushPendingTranscriptEvents();
|
this.flushPendingTranscriptEvents();
|
||||||
const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), this.api.status(sessionId)]);
|
const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(sessionId, selectedMachineId(this.getState()))]);
|
||||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||||
const history = this.transcripts.mergeHistory(sessionId, page);
|
const history = this.transcripts.mergeHistory(sessionId, page);
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -403,7 +404,7 @@ export class SessionController {
|
|||||||
|
|
||||||
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
|
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const replacement = await this.api.startSession(session.cwd);
|
const replacement = await this.api.startSession(session.cwd, selectedMachineId(this.getState()));
|
||||||
rememberCachedNewSession(replacement);
|
rememberCachedNewSession(replacement);
|
||||||
moveDraft(session.id, replacement.id);
|
moveDraft(session.id, replacement.id);
|
||||||
forgetCachedNewSession(session.id);
|
forgetCachedNewSession(session.id);
|
||||||
@@ -535,7 +536,7 @@ export class SessionController {
|
|||||||
|
|
||||||
private async refreshMessages(sessionId: string) {
|
private async refreshMessages(sessionId: string) {
|
||||||
try {
|
try {
|
||||||
const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
|
const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||||
this.setState(this.transcripts.mergeHistory(sessionId, page));
|
this.setState(this.transcripts.mergeHistory(sessionId, page));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { AppState } from "../appState";
|
import type { AppState } from "../appState";
|
||||||
|
|
||||||
|
export function selectedMachineId(state: Pick<AppState, "selectedMachine">): string {
|
||||||
|
return state.selectedMachine?.id ?? "local";
|
||||||
|
}
|
||||||
|
|
||||||
export type GetState = () => AppState;
|
export type GetState = () => AppState;
|
||||||
export type SetState = (patch: Partial<AppState>) => void;
|
export type SetState = (patch: Partial<AppState>) => void;
|
||||||
export type UpdateUrl = (options?: { replace?: boolean | undefined }) => void;
|
export type UpdateUrl = (options?: { replace?: boolean | undefined }) => void;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { api as defaultApi, type Project, type Workspace } from "../api";
|
import { api as defaultApi, type Project, type Workspace } from "../api";
|
||||||
import { resetWorkspaceScopedState } from "../appState";
|
import { resetWorkspaceScopedState } from "../appState";
|
||||||
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
||||||
import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types";
|
import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types";
|
||||||
import type { SessionController } from "./sessionController";
|
import type { SessionController } from "./sessionController";
|
||||||
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export class WorkspaceController {
|
|||||||
this.sessions.clearActiveSession();
|
this.sessions.clearActiveSession();
|
||||||
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
|
this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() });
|
||||||
try {
|
try {
|
||||||
const workspaces = await this.api.workspaces(project.id);
|
const workspaces = await api.workspaces(project.id, selectedMachineId(this.getState()));
|
||||||
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false });
|
this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false });
|
||||||
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) });
|
const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(project.id) });
|
||||||
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
|
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
|
||||||
@@ -54,7 +54,7 @@ export class WorkspaceController {
|
|||||||
this.sessions.clearActiveSession();
|
this.sessions.clearActiveSession();
|
||||||
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
|
this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() });
|
||||||
try {
|
try {
|
||||||
const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path));
|
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path, selectedMachineId(this.getState())));
|
||||||
this.setState({ sessions });
|
this.setState({ sessions });
|
||||||
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
|
||||||
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
|
|||||||
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
|
<p class="muted">Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt });
|
const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.state.selectedMachine?.id ?? "local" });
|
||||||
return html`
|
return html`
|
||||||
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
|
<div class="viewer-header"><strong>${file.path}</strong><small>${metadata}</small></div>
|
||||||
<div class="image-preview">
|
<div class="image-preview">
|
||||||
@@ -97,7 +97,7 @@ function renderImageViewer(context: WorkspacePanelContext, file: FileContentResp
|
|||||||
|
|
||||||
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
||||||
loadTerminalPanel();
|
loadTerminalPanel();
|
||||||
return html`<terminal-panel .workspace=${context.workspace} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
|
return html`<terminal-panel .workspace=${context.workspace} .machineId=${context.state.selectedMachine?.id ?? "local"} .selectedTerminalId=${context.selectedTerminalId} .autoStart=${context.terminalAutoStart} .onSelectTerminal=${context.onSelectTerminal}></terminal-panel>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGit(context: WorkspacePanelContext): TemplateResult {
|
function renderGit(context: WorkspacePanelContext): TemplateResult {
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ export class SessionSocket {
|
|||||||
private shouldReconnect = false;
|
private shouldReconnect = false;
|
||||||
private hasOpened = false;
|
private hasOpened = false;
|
||||||
private onReconnect: (() => void) | undefined;
|
private onReconnect: (() => void) | undefined;
|
||||||
|
private machineId = "local";
|
||||||
|
|
||||||
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void): void {
|
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void {
|
||||||
this.close();
|
this.close();
|
||||||
|
this.machineId = machineId;
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
this.onEvent = onEvent;
|
this.onEvent = onEvent;
|
||||||
this.onReconnect = onReconnect;
|
this.onReconnect = onReconnect;
|
||||||
@@ -35,11 +37,12 @@ export class SessionSocket {
|
|||||||
this.onEvent = undefined;
|
this.onEvent = undefined;
|
||||||
this.onReconnect = undefined;
|
this.onReconnect = undefined;
|
||||||
this.hasOpened = false;
|
this.hasOpened = false;
|
||||||
|
this.machineId = "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
private open(): void {
|
private open(): void {
|
||||||
if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
|
if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
|
||||||
const socket = sessionEvents(this.sessionId);
|
const socket = sessionEvents(this.sessionId, this.machineId);
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
this.reconnectDelay = 500;
|
this.reconnectDelay = 500;
|
||||||
@@ -75,9 +78,11 @@ export class RealtimeSocket {
|
|||||||
private reconnectTimer?: number;
|
private reconnectTimer?: number;
|
||||||
private reconnectDelay = 500;
|
private reconnectDelay = 500;
|
||||||
private shouldReconnect = false;
|
private shouldReconnect = false;
|
||||||
|
private machineId = "local";
|
||||||
|
|
||||||
connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void): void {
|
connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void, machineId = "local"): void {
|
||||||
this.close();
|
this.close();
|
||||||
|
this.machineId = machineId;
|
||||||
this.onEvent = onEvent;
|
this.onEvent = onEvent;
|
||||||
this.onOpen = onOpen;
|
this.onOpen = onOpen;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
@@ -91,11 +96,12 @@ export class RealtimeSocket {
|
|||||||
this.socket = undefined;
|
this.socket = undefined;
|
||||||
this.onEvent = undefined;
|
this.onEvent = undefined;
|
||||||
this.onOpen = undefined;
|
this.onOpen = undefined;
|
||||||
|
this.machineId = "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
private open(): void {
|
private open(): void {
|
||||||
if (!this.shouldReconnect) return;
|
if (!this.shouldReconnect) return;
|
||||||
const socket = realtimeEvents();
|
const socket = realtimeEvents(this.machineId);
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
this.reconnectDelay = 500;
|
this.reconnectDelay = 500;
|
||||||
@@ -129,9 +135,11 @@ export class GlobalSessionSocket {
|
|||||||
private reconnectTimer?: number;
|
private reconnectTimer?: number;
|
||||||
private reconnectDelay = 500;
|
private reconnectDelay = 500;
|
||||||
private shouldReconnect = false;
|
private shouldReconnect = false;
|
||||||
|
private machineId = "local";
|
||||||
|
|
||||||
connect(onEvent: (event: GlobalSessionEvent) => void): void {
|
connect(onEvent: (event: GlobalSessionEvent) => void, machineId = "local"): void {
|
||||||
this.close();
|
this.close();
|
||||||
|
this.machineId = machineId;
|
||||||
this.onEvent = onEvent;
|
this.onEvent = onEvent;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
this.open();
|
this.open();
|
||||||
@@ -143,11 +151,12 @@ export class GlobalSessionSocket {
|
|||||||
closeSocketQuietly(this.socket);
|
closeSocketQuietly(this.socket);
|
||||||
this.socket = undefined;
|
this.socket = undefined;
|
||||||
this.onEvent = undefined;
|
this.onEvent = undefined;
|
||||||
|
this.machineId = "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
private open(): void {
|
private open(): void {
|
||||||
if (!this.shouldReconnect) return;
|
if (!this.shouldReconnect) return;
|
||||||
const socket = globalSessionEvents();
|
const socket = globalSessionEvents(this.machineId);
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
this.reconnectDelay = 500;
|
this.reconnectDelay = 500;
|
||||||
|
|||||||
@@ -77,6 +77,31 @@ describe("buildApp", () => {
|
|||||||
expect(emptyListResponse.json<Project[]>()).toEqual([]);
|
expect(emptyListResponse.json<Project[]>()).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<Project>();
|
||||||
|
|
||||||
|
const listResponse = await app.inject({ method: "GET", url: "/api/machines/local/projects" });
|
||||||
|
expect(listResponse.statusCode).toBe(200);
|
||||||
|
expect(listResponse.json<Project[]>()).toEqual([project]);
|
||||||
|
|
||||||
|
const workspacesResponse = await app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
|
||||||
|
expect(workspacesResponse.statusCode).toBe(200);
|
||||||
|
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: projectDir })]);
|
||||||
|
});
|
||||||
|
|
||||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||||
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
|
||||||
expect(manifestResponse.statusCode).toBe(200);
|
expect(manifestResponse.statusCode).toBe(200);
|
||||||
|
|||||||
+58
-44
@@ -27,6 +27,56 @@ export interface AppDependencies {
|
|||||||
logger?: FastifyServerOptions["logger"];
|
logger?: FastifyServerOptions["logger"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void {
|
||||||
|
app.get(`${prefix}/projects`, async () => projects.list());
|
||||||
|
|
||||||
|
app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await projects.add(request.body);
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await projects.close(request.params.projectId);
|
||||||
|
return { closed: true };
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { q?: string } }>(`${prefix}/project-directories`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await listDirectorySuggestions(request.query.q ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const project = await projects.requireProject(request.params.projectId);
|
||||||
|
return await workspaces.list(project);
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
|
||||||
|
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>(`${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<FastifyInstance> {
|
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
|
||||||
const app = Fastify({ logger: deps.logger ?? true });
|
const app = Fastify({ logger: deps.logger ?? true });
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
@@ -48,56 +98,20 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
registerMachineRoutes(app, machines);
|
registerMachineRoutes(app, machines);
|
||||||
|
|
||||||
app.get("/api/projects", async () => projects.list());
|
registerLocalProjectRoutes(app, projects, workspaces, "/api");
|
||||||
|
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
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) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerSessionProxyRoutes(app);
|
registerSessionProxyRoutes(app);
|
||||||
|
registerSessionProxyRoutes(app, undefined, "/api/machines/local");
|
||||||
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
||||||
|
registerWorkspaceExplorerRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
registerGitRoutes(app, projects, workspaces);
|
registerGitRoutes(app, projects, workspaces);
|
||||||
|
registerGitRoutes(app, projects, workspaces, "/api/machines/local");
|
||||||
registerTerminalProxyRoutes(app, projects, workspaces);
|
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) => {
|
registerLocalFileSuggestionRoutes(app, "/api");
|
||||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
registerLocalFileSuggestionRoutes(app, "/api/machines/local");
|
||||||
try {
|
|
||||||
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
|
|
||||||
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) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client");
|
const packagedClientDist = join(dirname(fileURLToPath(import.meta.url)), "..", "client");
|
||||||
const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client"));
|
const clientDist = deps.clientDist ?? (existsSync(packagedClientDist) ? packagedClientDist : join(process.cwd(), "dist", "client"));
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
|||||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||||
import { gitDiff, gitStatus } from "./git/gitService.js";
|
import { gitDiff, gitStatus } from "./git/gitService.js";
|
||||||
|
|
||||||
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
|
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/status", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/status`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await gitStatus(context.root);
|
return await gitStatus(context.root);
|
||||||
@@ -14,7 +14,7 @@ export function registerGitRoutes(app: FastifyInstance, projects: ProjectService
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/diff", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/git/diff`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" });
|
return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" });
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import fastifyWebsocket from "@fastify/websocket";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { registerSessionProxyRoutes } from "./sessionProxyRoutes";
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let daemon: FakeSessionDaemon;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyWebsocket);
|
||||||
|
daemon = new FakeSessionDaemon();
|
||||||
|
registerSessionProxyRoutes(app, daemon, "/api/machines/local");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("machine-scoped session proxy routes", () => {
|
||||||
|
it("strips the machine prefix before forwarding session requests", async () => {
|
||||||
|
const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions?cwd=/repo" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ ok: true });
|
||||||
|
expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips the machine prefix before forwarding auth requests", async () => {
|
||||||
|
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ ok: true });
|
||||||
|
expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeSessionDaemon {
|
||||||
|
readonly requests: { method: string; path: string; body: unknown }[] = [];
|
||||||
|
|
||||||
|
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
|
||||||
|
this.requests.push({ method, path, body });
|
||||||
|
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWebSocket(): never {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,15 @@ import type { FastifyInstance, FastifyReply } from "fastify";
|
|||||||
import { WebSocket, type RawData } from "ws";
|
import { WebSocket, type RawData } from "ws";
|
||||||
import { SessionDaemonClient } from "./sessionDaemonClient.js";
|
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<string, string>; body: string }>;
|
||||||
|
connectWebSocket(path: string): WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSessionProxyRoutes(app: FastifyInstance, daemon: SessionProxyDaemon = new SessionDaemonClient(), prefix = "/api"): void {
|
||||||
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
|
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
|
||||||
try {
|
try {
|
||||||
const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body);
|
const upstream = await daemon.request(request.method, stripPrefix(request.url, prefix), request.body);
|
||||||
reply.code(upstream.statusCode);
|
reply.code(upstream.statusCode);
|
||||||
const contentType = upstream.headers["content-type"];
|
const contentType = upstream.headers["content-type"];
|
||||||
if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
|
if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
|
||||||
@@ -16,29 +21,31 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
app.get("/api/sessiond/health", (_request, reply) => proxy({ method: "GET", url: "/api/health" }, reply));
|
app.get(`${prefix}/sessiond/health`, (_request, reply) => proxy({ method: "GET", url: `${prefix}/health` }, reply));
|
||||||
|
|
||||||
app.get<{ Params: { sessionId: string } }>("/api/sessions/:sessionId/events", { websocket: true }, (socket, request) => {
|
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||||
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
|
bridgeSockets(socket, daemon.connectWebSocket(`/sessions/${request.params.sessionId}/events`));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/sessions/events", { websocket: true }, (socket) => {
|
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
|
||||||
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events"));
|
bridgeSockets(socket, daemon.connectWebSocket("/sessions/events"));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/events", { websocket: true }, (socket) => {
|
app.get(`${prefix}/events`, { websocket: true }, (socket) => {
|
||||||
bridgeSockets(socket, daemon.connectWebSocket("/events"));
|
bridgeSockets(socket, daemon.connectWebSocket("/events"));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.all("/api/activity", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/activity`, (request, reply) => proxy(request, reply));
|
||||||
app.all("/api/auth", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/auth`, (request, reply) => proxy(request, reply));
|
||||||
app.all("/api/auth/*", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/auth/*`, (request, reply) => proxy(request, reply));
|
||||||
app.all("/api/sessions", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/sessions`, (request, reply) => proxy(request, reply));
|
||||||
app.all("/api/sessions/*", (request, reply) => proxy(request, reply));
|
app.all(`${prefix}/sessions/*`, (request, reply) => proxy(request, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripApiPrefix(url: string): string {
|
function stripPrefix(url: string, prefix: string): string {
|
||||||
const stripped = url.startsWith("/api") ? url.slice(4) : url;
|
const path = url.split("?", 1)[0] ?? url;
|
||||||
|
const query = url.slice(path.length);
|
||||||
|
const stripped = path.startsWith(prefix) ? `${path.slice(prefix.length)}${query}` : url;
|
||||||
return stripped === "" ? "/" : stripped;
|
return stripped === "" ? "/" : stripped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
|||||||
import { terminalSizeQuery } from "./terminals/terminalSize.js";
|
import { terminalSizeQuery } from "./terminals/terminalSize.js";
|
||||||
import { bridgeSockets } from "./webSocketBridge.js";
|
import { bridgeSockets } from "./webSocketBridge.js";
|
||||||
|
|
||||||
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient()): void {
|
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient(), prefix = "/api"): void {
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/terminals`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply);
|
return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply);
|
||||||
@@ -17,7 +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 {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply);
|
return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply);
|
||||||
@@ -27,7 +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 {
|
try {
|
||||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply);
|
return await proxyJson(daemon, "POST", `/terminals/${encodeURIComponent(request.params.terminalId)}/continue`, undefined, reply);
|
||||||
@@ -37,7 +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 {
|
try {
|
||||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply);
|
return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply);
|
||||||
@@ -47,7 +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 {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await proxyJson(daemon, "POST", "/terminal-command-runs", {
|
return await proxyJson(daemon, "POST", "/terminal-command-runs", {
|
||||||
@@ -65,7 +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 {
|
try {
|
||||||
return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply);
|
return await proxyJson(daemon, "GET", `/terminal-command-runs${terminalCommandRunQuery(request.query)}`, undefined, reply);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -74,7 +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 {
|
try {
|
||||||
return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply);
|
return await proxyJson(daemon, "POST", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}/cancel`, undefined, reply);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -83,7 +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 {
|
try {
|
||||||
return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply);
|
return await proxyJson(daemon, "GET", `/terminal-command-runs/${encodeURIComponent(request.params.runId)}`, undefined, reply);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -92,7 +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 {
|
try {
|
||||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
const sizeQuery = terminalSizeQuery(request.query.cols, request.query.rows);
|
const sizeQuery = terminalSizeQuery(request.query.cols, request.query.rows);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
|||||||
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||||
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js";
|
||||||
|
|
||||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
|
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void {
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await listWorkspaceTree(context.root, request.query.path);
|
return await listWorkspaceTree(context.root, request.query.path);
|
||||||
@@ -16,7 +16,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
return await readWorkspaceFile(context.root, request.query.path);
|
return await readWorkspaceFile(context.root, request.query.path);
|
||||||
@@ -25,7 +25,7 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file/preview", async (request, reply) => {
|
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||||
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
|
const preview = await readWorkspaceImagePreview(context.root, request.query.path);
|
||||||
|
|||||||
Reference in New Issue
Block a user