diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 15:28:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 15:28:13 +0900 |
| commit | 3647acfb7f078b2f035dd325f6959980c5b46c9a (patch) | |
| tree | 59d78d8c503a590f4c49481b0454163b921b39da | |
| parent | eb6adc405fab9b55590af6b235106dcabab5946e (diff) | |
| download | dispatch-3647acfb7f078b2f035dd325f6959980c5b46c9a.tar.gz dispatch-3647acfb7f078b2f035dd325f6959980c5b46c9a.zip | |
feat(ssh): wave 4 — computer HTTP/WS endpoints + chat computerId threading
Wave 4 of transparent SSH support (3 parallel owner-agents on disjoint packages).
- transport-http: computer routes — GET /computers, GET /computers/:alias,
GET /computers/:alias/status, POST /computers/:alias/test (all delegate to a
new ComputerService seam, graceful []/disconnected when ssh not loaded);
GET/PUT/DELETE /conversations/:id/computer; PUT /workspaces/:id/default-computer
(mirror the cwd/default-cwd routes); /chat threads computerId into the
orchestrator. Defines ComputerService interface + computerServiceHandle
(defineService<ComputerService>('ssh')) in seam.ts — the seam the ssh package
provides via host.provideService in wave 5.
- transport-ws: chat.send + chat.queue thread computerId onto the route result
(mirrors cwd/workspaceId), forwarded to the orchestrator input.
- mcp: CR-1 fix — filterMcpTools now preserves computerId on the returned
ToolAssembly (mirrors cwd preservation), so the filter chain stays consistent.
- orchestrator: added @dispatch/wire dep to transport-http (build/config, my lane)
so its seam.ts Computer/ComputerEntry import resolves.
Verified: tsc -b EXIT 0, biome clean, 1641 vitest pass (was 1620, +21).
Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
| -rw-r--r-- | bun.lock | 1 | ||||
| -rw-r--r-- | packages/mcp/src/extension.test.ts | 22 | ||||
| -rw-r--r-- | packages/mcp/src/extension.ts | 1 | ||||
| -rw-r--r-- | packages/transport-http/package.json | 1 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 473 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 206 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 20 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 14 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 40 | ||||
| -rw-r--r-- | packages/transport-ws/src/extension.ts | 1 | ||||
| -rw-r--r-- | packages/transport-ws/src/router.test.ts | 33 | ||||
| -rw-r--r-- | packages/transport-ws/src/router.ts | 10 | ||||
| -rw-r--r-- | tasks.md | 6 |
13 files changed, 821 insertions, 7 deletions
@@ -310,6 +310,7 @@ "@dispatch/system-prompt": "workspace:*", "@dispatch/throughput-store": "workspace:*", "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*", "hono": "^4.0.0", }, }, diff --git a/packages/mcp/src/extension.test.ts b/packages/mcp/src/extension.test.ts index e2d2eab..75515fb 100644 --- a/packages/mcp/src/extension.test.ts +++ b/packages/mcp/src/extension.test.ts @@ -49,6 +49,28 @@ describe("filterMcpTools (pure)", () => { expect(result.tools).toHaveLength(0); expect(result.conversationId).toBe("c"); expect(result.cwd).toBeUndefined(); + expect(result.computerId).toBeUndefined(); + }); + + it("preserves computerId when set (mirrors cwd/conversationId preservation)", () => { + const toolToServer = new Map<string, string>([["a__x", "a"]]); + const connected = new Set<string>(["a"]); + + const result = filterMcpTools( + { + tools: [stubTool("a__x"), stubTool("other")], + cwd: "/p", + computerId: "ssh-host", + conversationId: "c", + }, + toolToServer, + connected, + ); + + expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]); + expect(result.computerId).toBe("ssh-host"); + expect(result.cwd).toBe("/p"); + expect(result.conversationId).toBe("c"); }); }); diff --git a/packages/mcp/src/extension.ts b/packages/mcp/src/extension.ts index 9adb879..e1c4d52 100644 --- a/packages/mcp/src/extension.ts +++ b/packages/mcp/src/extension.ts @@ -49,6 +49,7 @@ export function filterMcpTools( return { tools: filtered, ...(assembly.cwd !== undefined && { cwd: assembly.cwd }), + ...(assembly.computerId !== undefined && { computerId: assembly.computerId }), conversationId: assembly.conversationId, }; } diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index d95436b..e7eb85c 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -14,6 +14,7 @@ "@dispatch/session-orchestrator": "workspace:*", "@dispatch/throughput-store": "workspace:*", "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*", "hono": "^4.0.0", "@dispatch/system-prompt": "workspace:*" } diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index c7b7d31..4f64ece 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -21,11 +21,12 @@ import type { WorkspaceListResponse, WorkspaceResponse, } from "@dispatch/transport-contract"; -import type { Workspace } from "@dispatch/wire"; +import type { Computer, ComputerEntry, Workspace } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { createApp } from "./app.js"; import { extractLastAssistantText } from "./logic.js"; import type { + ComputerService, ConversationStore, CredentialStore, LspService, @@ -100,7 +101,16 @@ function createFakeConversationStore( cwdStore: Map<string, string> = new Map(), reasoningEffortStore: Map<string, ReasoningEffort> = new Map(), modelStore: Map<string, string> = new Map(), + computerStore: Map<string, string> = new Map(), ): ConversationStore { + const sampleWorkspace = { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; return { async append() {}, async load() { @@ -133,6 +143,19 @@ function createFakeConversationStore( async clearCwd(conversationId) { cwdStore.delete(conversationId); }, + async getComputerId(conversationId) { + return computerStore.get(conversationId) ?? null; + }, + async setComputerId(conversationId, alias) { + if (alias === null) { + computerStore.delete(conversationId); + } else { + computerStore.set(conversationId, alias); + } + }, + async clearComputerId(conversationId) { + computerStore.delete(conversationId); + }, async getReasoningEffort(conversationId) { return reasoningEffortStore.get(conversationId) ?? null; }, @@ -171,13 +194,16 @@ function createFakeConversationStore( return null; }, async ensureWorkspace() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return sampleWorkspace; }, async setWorkspaceTitle() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return sampleWorkspace; }, async setWorkspaceDefaultCwd() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return sampleWorkspace; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...sampleWorkspace, id, defaultComputerId }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -192,6 +218,9 @@ function createFakeConversationStore( async getEffectiveCwd(conversationId) { return cwdStore.get(conversationId) ?? null; }, + async getEffectiveComputer(conversationId) { + return computerStore.get(conversationId) ?? null; + }, }; } @@ -466,6 +495,27 @@ function createFakeSystemPromptService( }; } +function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService { + const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c])); + return { + async listComputers() { + return computers; + }, + async getComputer(alias) { + return byAlias.get(alias) ?? null; + }, + async getStatus(alias) { + const known = byAlias.has(alias); + return { alias, state: "disconnected", knownHost: known }; + }, + async test(alias) { + return byAlias.has(alias) + ? { alias, ok: true } + : { alias, ok: false, error: "Computer not found" }; + }, + }; +} + const noopLogger = createFakeLogger(); describe("GET /health", () => { @@ -1065,6 +1115,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1074,6 +1125,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1083,6 +1135,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1184,6 +1237,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1193,6 +1247,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1202,6 +1257,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1372,6 +1428,7 @@ describe("GET /conversations/:id/metrics", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1381,6 +1438,7 @@ describe("GET /conversations/:id/metrics", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1390,6 +1448,7 @@ describe("GET /conversations/:id/metrics", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -2807,6 +2866,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -2816,6 +2876,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -2825,6 +2886,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -3480,6 +3542,7 @@ describe("Workspaces", () => { id: "proj", title: "proj", defaultCwd: null, + defaultComputerId: null, createdAt: 1000, lastActivityAt: 2000, }; @@ -3865,3 +3928,405 @@ describe("GET /system-prompt/variables", () => { expect(fileEntry?.dynamic).toBe(true); }); }); + +// ─── Computers (mirrors the cwd / workspace routes) ───────────────────────── + +const sampleComputer: Computer = { + alias: "myserver", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/user/.ssh/id_ed25519", + knownHost: true, +}; + +describe("GET /computers", () => { + it("returns [] when no ComputerService is wired (graceful degrade)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers"); + expect(res.status).toBe(200); + const body = (await res.json()) as { computers: readonly ComputerEntry[] }; + expect(body.computers).toEqual([]); + }); + + it("delegates to the ComputerService when wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]), + logger: noopLogger, + }); + const res = await app.request("/computers"); + expect(res.status).toBe(200); + const body = (await res.json()) as { computers: readonly ComputerEntry[] }; + expect(body.computers).toHaveLength(1); + expect(body.computers[0]?.alias).toBe("myserver"); + expect(body.computers[0]?.usageCount).toBe(2); + }); +}); + +describe("GET /computers/:alias", () => { + it("returns the computer when the alias is configured", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver"); + expect(res.status).toBe(200); + const body = (await res.json()) as Computer; + expect(body.alias).toBe("myserver"); + expect(body.hostName).toBe("10.0.0.5"); + }); + + it("returns 404 when the alias is not in the config", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([]), + logger: noopLogger, + }); + const res = await app.request("/computers/unknown"); + expect(res.status).toBe(404); + }); + + it("returns 404 when no ComputerService is wired (no ssh)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver"); + expect(res.status).toBe(404); + }); +}); + +describe("GET /computers/:alias/status", () => { + it("returns disconnected + knownHost:false when no ComputerService is wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver/status"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + alias: string; + state: string; + knownHost: boolean; + }; + expect(body.alias).toBe("myserver"); + expect(body.state).toBe("disconnected"); + expect(body.knownHost).toBe(false); + }); +}); + +describe("POST /computers/:alias/test", () => { + it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver/test", { method: "POST" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { alias: string; ok: boolean; error?: string }; + expect(body.alias).toBe("myserver"); + expect(body.ok).toBe(false); + expect(body.error).toBe("SSH not configured"); + }); +}); + +describe("GET then PUT then GET /conversations/:id/computer", () => { + it("round-trips the value", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const get0 = await app.request("/conversations/conv1/computer"); + expect(get0.status).toBe(200); + const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null }; + expect(get0Body.conversationId).toBe("conv1"); + expect(get0Body.computerId).toBeNull(); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + const putBody = (await putRes.json()) as { conversationId: string; computerId: string }; + expect(putBody.conversationId).toBe("conv1"); + expect(putBody.computerId).toBe("myserver"); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null }; + expect(getBody.computerId).toBe("myserver"); + }); +}); + +describe("PUT /conversations/:id/computer with null clears (→ DELETE parity)", () => { + it("PUT null clears a previously-set computer", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + + const clearRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: null }), + }); + expect(clearRes.status).toBe(200); + const clearBody = (await clearRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(clearBody.computerId).toBeNull(); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { computerId: string | null }; + expect(getBody.computerId).toBeNull(); + }); +}); + +describe("DELETE /conversations/:id/computer", () => { + it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.computerId).toBeNull(); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { computerId: string | null }; + expect(getBody.computerId).toBeNull(); + }); + + it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(deleteBody.computerId).toBeNull(); + }); + + it("does NOT affect other conversations' computers (isolation)", async () => { + const computerStore = new Map<string, string>([ + ["conv1", "myserver"], + ["conv2", "otherbox"], + ]); + const store = createFakeConversationStore( + new Map(), + new Map(), + new Map(), + new Map(), + new Map(), + computerStore, + ); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + + const get1 = await app.request("/conversations/conv1/computer"); + expect(get1.status).toBe(200); + expect((await get1.json()).computerId).toBeNull(); + + const get2 = await app.request("/conversations/conv2/computer"); + expect(get2.status).toBe(200); + expect((await get2.json()).computerId).toBe("otherbox"); + }); +}); + +describe("PUT /conversations/:id/computer validation", () => { + it("with missing computerId returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("computerId"); + }); + + it("with empty-string computerId returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "" }), + }); + expect(res.status).toBe(400); + }); +}); + +describe("PUT /workspaces/:id/default-computer", () => { + const wsSample: Workspace = { + id: "proj", + title: "proj", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 2000, + }; + + it("sets the default computer", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...wsSample, id, defaultComputerId }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultComputerId).toBe("myserver"); + }); + + it("clears the default computer with null", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...wsSample, id, defaultComputerId }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: null }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultComputerId).toBeNull(); + }); +}); + +describe("POST /chat threads computerId", () => { + it("forwards computerId into the orchestrator input when present", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + computerId: "myserver", + }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.conversationId).toBe("conv1"); + expect(cap.received?.computerId).toBe("myserver"); + }); + + it("omits computerId when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.computerId).toBeUndefined(); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 1d87383..2e81c46 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -4,6 +4,10 @@ import type { CloseConversationResponse, CompactPercentResponse, CompactResponse, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConversationComputerResponse, ConversationHistoryResponse, ConversationListResponse, ConversationMetricsResponse, @@ -21,9 +25,12 @@ import type { QueueResponse, ReasoningEffortResponse, SetCompactPercentRequest, + SetConversationComputerRequest, SetSystemPromptTemplateRequest, + SetWorkspaceDefaultComputerRequest, SystemPromptTemplateResponse, SystemPromptVariablesResponse, + TestComputerResponse, ThroughputResponse, TitleResponse, WarmResponse, @@ -53,6 +60,7 @@ import { } from "./logic.js"; import { type CompactionService, + type ComputerService, type ConversationStore, type CredentialStore, conversationOpened, @@ -78,6 +86,14 @@ export interface CreateServerOptions { readonly mcpService?: McpService; /** Optional — system prompt builder service (GET/PUT template). */ readonly systemPromptService?: SystemPromptService; + /** + * Optional — computer discovery + live connection service (provided by the + * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes + * degrade: list returns `[]`, status returns "disconnected", test returns + * a not-configured result. The per-conversation / workspace-default computer + * endpoints work regardless (they only touch the conversation store). + */ + readonly computerService?: ComputerService; /** Optional — defaults to a no-op store (recording disabled, empty reports). */ readonly throughputStore?: ThroughputStore; readonly logger?: Logger; @@ -282,6 +298,77 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // ─── Computers (discovery + live state) ─────────────────────────────────── + // Read-only discovery + connection state is delegated to the ComputerService + // (provided by the `ssh` extension). When ssh is NOT loaded the routes + // degrade: list → empty, status → "disconnected", test → not-configured. + + app.get("/computers", async (c) => { + if (opts.computerService === undefined) { + // Graceful: no ssh configured → no computers discovered. + const body: ComputerListResponse = { computers: [] }; + return c.json(body, 200); + } + try { + const computers = await opts.computerService.listComputers(); + log.info("computers: list", { count: computers.length }); + const body: ComputerListResponse = { computers }; + return c.json(body, 200); + } catch (err) { + log.error("computers: list failure", { err }); + return c.json({ error: "Failed to list computers" }, 500); + } + }); + + app.get("/computers/:alias", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + // No ssh configured → no computer resolves this alias. + return c.json({ error: "Computer not found" }, 404); + } + try { + const computer = await opts.computerService.getComputer(alias); + if (computer === null) { + return c.json({ error: "Computer not found" }, 404); + } + const body: ComputerResponse = computer; + return c.json(body, 200); + } catch (err) { + log.error("computers: get failure", { err, alias }); + return c.json({ error: "Failed to read computer" }, 500); + } + }); + + app.get("/computers/:alias/status", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false }; + return c.json(body, 200); + } + try { + const body = await opts.computerService.getStatus(alias); + return c.json(body, 200); + } catch (err) { + log.error("computers: status failure", { err, alias }); + return c.json({ error: "Failed to read computer status" }, 500); + } + }); + + app.post("/computers/:alias/test", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" }; + return c.json(body, 200); + } + try { + const body = await opts.computerService.test(alias); + return c.json(body, 200); + } catch (err) { + log.error("computers: test failure", { err, alias }); + return c.json({ error: "Failed to test computer" }, 500); + } + }); + app.post("/chat", async (c) => { let body: unknown; try { @@ -297,11 +384,13 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json({ error: result.error }, 400); } - const { conversationId, message, model, cwd, reasoningEffort, workspaceId } = result; + const { conversationId, message, model, cwd, computerId, reasoningEffort, workspaceId } = + result; log.info("chat: request accepted", { conversationId, hasModel: model !== undefined, hasCwd: cwd !== undefined, + hasComputerId: computerId !== undefined, hasReasoningEffort: reasoningEffort !== undefined, hasWorkspaceId: workspaceId !== undefined, }); @@ -351,6 +440,7 @@ export function createApp(opts: CreateServerOptions): Hono { }, ...(model !== undefined ? { modelName: model } : {}), ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), ...(workspaceId !== undefined ? { workspaceId } : {}), }; @@ -578,6 +668,91 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ────────── + + app.get("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + try { + const computerId = await opts.conversationStore.getComputerId(conversationId); + log.info("conversations: computer read", { + conversationId, + hasComputerId: computerId !== null, + }); + const body: ConversationComputerResponse = { conversationId, computerId }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: computer read failure", { err }); + return c.json({ error: "Failed to read conversation computer" }, 500); + } + }); + + app.put("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/computer: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + // `computerId` must be a string (the SSH alias) or null (clear → inherit + // the workspace defaultComputerId → local). An empty string is rejected + // (unlike cwd, an alias is never "empty"); null is the explicit clear. + if ( + obj.computerId !== null && + (typeof obj.computerId !== "string" || obj.computerId.length === 0) + ) { + return c.json( + { error: "Field 'computerId' is required and must be a non-empty string or null" }, + 400, + ); + } + const { computerId } = obj as unknown as SetConversationComputerRequest; + + // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided, + // assign the conversation to that workspace BEFORE persisting the + // computer, so a subsequent effective-computer resolution reads the + // workspace's defaultComputerId. Omit for unchanged workspace assignment. + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { + return c.json({ error: "Invalid workspaceId" }, 400); + } + } + + try { + if (typeof obj.workspaceId === "string") { + await opts.conversationStore.ensureWorkspace(obj.workspaceId); + await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); + } + // null → clear (inherit/local); string → persist the alias. + await opts.conversationStore.setComputerId(conversationId, computerId); + log.info("conversations: computer set", { conversationId }); + const response: ConversationComputerResponse = { conversationId, computerId }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: computer set failure", { err }); + return c.json({ error: "Failed to set conversation computer" }, 500); + } + }); + + app.delete("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + try { + await opts.conversationStore.clearComputerId(conversationId); + log.info("conversations: computer cleared", { conversationId }); + const response: ConversationComputerResponse = { conversationId, computerId: null }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: computer clear failure", { err }); + return c.json({ error: "Failed to clear conversation computer" }, 500); + } + }); + app.get("/conversations/:id/reasoning-effort", async (c) => { const conversationId = c.req.param("id"); try { @@ -1119,6 +1294,35 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog). + app.put("/workspaces/:id/default-computer", async (c) => { + const workspaceId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + body = {}; + } + const obj = body as Record<string, unknown>; + // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias; + // anything else (null/absent/non-string) → clear (local). + const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] = + typeof obj.computerId === "string" ? obj.computerId : null; + + try { + const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId( + workspaceId, + defaultComputerId, + ); + log.info("workspaces: default-computer set", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: default-computer set failure", { err }); + return c.json({ error: "Failed to set workspace default computer" }, 500); + } + }); + app.delete("/workspaces/:id", async (c) => { const workspaceId = c.req.param("id"); if (workspaceId === "default") { diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 0f46e6b..4ab43ce 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,8 +1,10 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { + type ComputerService, cacheWarmHandle, compactionHandle, + computerServiceHandle, conversationStoreHandle, credentialStoreHandle, lspServiceHandle, @@ -31,11 +33,16 @@ export const manifest: Manifest = { routes: [ "/chat", "/chat/warm", + "/computers", + "/computers/:alias", + "/computers/:alias/status", + "/computers/:alias/test", "/conversations", "/conversations/:id", "/conversations/:id/close", "/conversations/:id/compact", "/conversations/:id/compact-percent", + "/conversations/:id/computer", "/conversations/:id/cwd", "/conversations/:id/last", "/conversations/:id/lsp", @@ -55,6 +62,7 @@ export const manifest: Manifest = { "/workspaces/:id", "/workspaces/:id/title", "/workspaces/:id/default-cwd", + "/workspaces/:id/default-computer", ], }, activation: "eager", @@ -80,6 +88,17 @@ export function createTransportHttpExtension(): Extension & { const lspService = host.getService(lspServiceHandle); const mcpService = host.getService(mcpServiceHandle); const systemPromptService = host.getService(systemPromptHandle); + // Optional: the `ssh` extension provides ComputerService. It is NOT in + // dependsOn (ssh may be absent), so resolve defensively — when no + // provider registered the handle, the computer routes degrade to + // empty/disconnected (see app.ts). Wrapped because getService throws + // for an unregistered handle. + let computerService: ComputerService | undefined; + try { + computerService = host.getService(computerServiceHandle); + } catch { + computerService = undefined; + } const logger = host.logger; const app = createApp({ @@ -92,6 +111,7 @@ export function createTransportHttpExtension(): Extension & { lspService, mcpService, systemPromptService, + ...(computerService !== undefined ? { computerService } : {}), logger, emit: host.emit.bind(host), ...(process.env.DISPATCH_WEB_DIR !== undefined diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 948afb8..4e099c4 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -46,6 +46,13 @@ export interface ChatCommand { readonly message: string; readonly model?: string; readonly cwd?: string; + /** + * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded + * to the orchestrator verbatim and never part of the model prompt. When + * absent, the orchestrator resolves the per-conversation → workspace + * default → local chain. + */ + readonly computerId?: string; readonly reasoningEffort?: ReasoningEffort; readonly workspaceId?: string; } @@ -91,6 +98,13 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes (result as { cwd?: string }).cwd = obj.cwd; } + if (obj.computerId !== undefined) { + if (typeof obj.computerId !== "string") { + return { error: "Field 'computerId' must be a string" }; + } + (result as { computerId?: string }).computerId = obj.computerId; + } + if (obj.reasoningEffort !== undefined) { if (!isValidReasoningEffort(obj.reasoningEffort)) { return { diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index e9dc4ce..ef28a09 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -1,3 +1,7 @@ +import { defineService, type ServiceHandle } from "@dispatch/kernel"; +import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract"; +import type { Computer, ComputerEntry } from "@dispatch/wire"; + export type { ConversationStore } from "@dispatch/conversation-store"; export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store"; export type { CredentialStore } from "@dispatch/credential-store"; @@ -21,3 +25,39 @@ export type { SystemPromptService } from "@dispatch/system-prompt"; export { systemPromptHandle } from "@dispatch/system-prompt"; export type { ThroughputStore } from "@dispatch/throughput-store"; export { ThroughputQueryError, throughputStoreHandle } from "@dispatch/throughput-store"; + +// ─── ComputerService seam ───────────────────────────────────────────────────── +// +// The read-only computer discovery + live connection surface. The `ssh` +// extension provides the real implementation (parses `~/.ssh/config`, pools +// `ssh2` connections) and registers it via `host.provideService`. Until ssh is +// loaded, the routes that delegate here DEGRADE: the list route returns an empty +// `[]` (no computers configured), and the status/test routes return their +// "disconnected" / not-configured sentinels. The interface + handle are defined +// HERE (not in `@dispatch/ssh`, which does not exist yet) so the routes can be +// wired against a typed seam today; when the `ssh` package lands it imports +// `ComputerService` + `computerServiceHandle` from here (mirroring how a +// provider implements a contract owned by its consumer seam). + +/** + * Read-only computer discovery + per-alias live state + one-shot probe. The + * transport routes delegate to this; it never throws for "no ssh configured" + * — an ABSENT service (ssh extension not loaded) is the graceful-degrade path. + */ +export interface ComputerService { + /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */ + readonly listComputers: () => Promise<readonly ComputerEntry[]>; + /** One computer by alias, or `null` when the alias isn't in the config. */ + readonly getComputer: (alias: string) => Promise<Computer | null>; + /** Live connection state for a computer alias. */ + readonly getStatus: (alias: string) => Promise<ComputerStatusResponse>; + /** One-shot connectivity probe (open, run a trivial command, close). */ + readonly test: (alias: string) => Promise<TestComputerResponse>; +} + +/** + * Typed service handle the `ssh` extension provides and the transport routes + * consume. Mirrors `lspServiceHandle` / `mcpServiceHandle`. + */ +export const computerServiceHandle: ServiceHandle<ComputerService> = + defineService<ComputerService>("ssh"); diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index 1e3da27..56bd8e2 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -290,6 +290,7 @@ export function createTransportWsExtension(): Extension { ? { reasoningEffort: result.reasoningEffort } : {}), ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), + ...(result.computerId !== undefined ? { computerId: result.computerId } : {}), }); if (!startResult.started) { send(ws, { diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts index 66e84cf..6d01823 100644 --- a/packages/transport-ws/src/router.test.ts +++ b/packages/transport-ws/src/router.test.ts @@ -379,6 +379,39 @@ describe("routeClientMessage", () => { expect(result).not.toHaveProperty("workspaceId"); }); + it("chat.send threads computerId", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + conversationId: "conv-cid", + message: "hello computer", + computerId: "dev-box", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.computerId).toBe("dev-box"); + }); + + it("chat.send omits computerId (absent/undefined) when not sent — backward compatible", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello no computer", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + // computerId is absent (undefined) — the orchestrator receives no + // computerId and resolves the inherited chain (conversation → + // workspace defaultComputerId → local). Mirrors workspaceId. + expect(result).not.toHaveProperty("computerId"); + }); + it("rejects a malformed chat.send (empty message)", () => { const registry = fakeRegistry([]); const connSubs = new Set<string>(); diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts index d43894d..7e9ba77 100644 --- a/packages/transport-ws/src/router.ts +++ b/packages/transport-ws/src/router.ts @@ -49,6 +49,15 @@ export interface ChatRouteResult { readonly cwd: string | undefined; readonly reasoningEffort?: ReasoningEffort; readonly workspaceId?: string; + /** + * The computer (SSH config alias) to run this turn's tools on — forwarded + * verbatim to the orchestrator's `startTurn` (which resolves it via + * `getEffectiveComputer`). Mirrors `cwd`/`workspaceId`: an opaque per-turn + * override, unvalidated here (validation happens at SSH connect time). + * Absent when the client omits it (the orchestrator then inherits the + * conversation → workspace → local chain). + */ + readonly computerId?: string; } /** A malformed chat.send that should yield a chat.error reply. */ @@ -173,6 +182,7 @@ function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError cwd: msg.cwd, ...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}), ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}), + ...(msg.computerId !== undefined ? { computerId: msg.computerId } : {}), }; } @@ -31,8 +31,10 @@ owner-agents on disjoint packages). API types). `tsc -b` EXIT 0, biome clean, **1620 vitest** (was 1599). CR-1 (non-blocking): MCP filter doesn't preserve `computerId` on ToolAssembly — fix folded into wave 4. -- [ ] **Wave 4** (parallel): `transport-http` + `transport-ws` (computer - endpoints + chat threading) + `mcp` (CR-1: preserve computerId in filter). +- [x] **Wave 4** (parallel): `transport-http` (computer endpoints + `/chat` + threading + the `ComputerService` seam the ssh package will provide) + + `transport-ws` (computerId through chat.send/queue) + `mcp` (CR-1: preserve + computerId in filter). `tsc -b` EXIT 0, biome clean, **1641 vitest** (was 1620). - [ ] **Wave 5**: `host-bin` wiring + `ssh` package (SshConnectionPool, SshExecBackend, ~/.ssh/config reader via ssh-config, known_hosts pinning). - [ ] **DEFERRED — cache-warming**: computerId threading intentionally NOT done |
