diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 14:43:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 14:43:29 +0900 |
| commit | eb6adc405fab9b55590af6b235106dcabab5946e (patch) | |
| tree | d158e92a01f7beb7dd97a866f78c3282264b361a | |
| parent | 1ff0eac44cd44751af979c51c746a1774c268e8a (diff) | |
| download | dispatch-eb6adc405fab9b55590af6b235106dcabab5946e.tar.gz dispatch-eb6adc405fab9b55590af6b235106dcabab5946e.zip | |
feat(ssh): wave 3 — session-orchestrator computerId threading + transport-contract API types
Wave 3 of transparent SSH support (2 parallel owner-agents on disjoint packages).
- session-orchestrator: thread computerId end-to-end through the turn, mirroring
cwd exactly — StartTurnInput/EnqueueInput/handleMessage/TurnLifecyclePayload
gain computerId; runTurnDetached resolves effectiveComputerId via
conversationStore.getEffectiveComputer(convId, override), persists the override,
threads into RunTurnInput + ToolAssembly. Register a remote-degradation
tools-filter (filterRemoteIncompatibleTools) that, when assembly.computerId is
set (REMOTE), drops the 'lsp' tool + any '__'-namespaced MCP tool (local
processes that can't see remote files); LOCAL (computerId undefined) is a
passthrough — byte-identical to today. +21 tests.
- transport-contract: + computerId on ChatRequest (flows to ChatSendMessage) +
computer endpoint API types (ComputerListResponse, ComputerResponse,
ComputerStatusResponse, SetConversationComputerRequest,
ConversationComputerResponse, SetWorkspaceDefaultComputerRequest,
TestComputerResponse) — mirrors the cwd/workspace endpoint types.
- CR-1 (non-blocking, folded into wave 4): MCP filter doesn't preserve computerId
on the returned ToolAssembly.
- cache-warming computerId threading intentionally DEFERRED (user request) —
noted as a known performance-only limitation in tasks.md.
Verified: tsc -b EXIT 0, biome clean, 1620 vitest pass (was 1599, +21).
Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
| -rw-r--r-- | packages/session-orchestrator/src/extension.ts | 14 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 288 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 67 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 48 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/tools-filter.test.ts | 75 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/tools-filter.ts | 43 | ||||
| -rw-r--r-- | packages/transport-contract/src/contract.types.test.ts | 160 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 91 | ||||
| -rw-r--r-- | tasks.md | 18 |
9 files changed, 774 insertions, 30 deletions
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index 4144827..1a57cc3 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -13,7 +13,7 @@ import { sessionOrchestratorHandle, } from "./orchestrator.js"; import { selectFirstProvider } from "./pure.js"; -import { toolsFilter } from "./tools-filter.js"; +import { filterRemoteIncompatibleTools, toolsFilter } from "./tools-filter.js"; export const manifest: Manifest = { id: "session-orchestrator", @@ -97,6 +97,18 @@ export function activate(host: HostAPI): void { host.provideService(sessionOrchestratorHandle, orchestrator); + // Remote-degradation rule (plan §6): when a turn is REMOTE + // (`assembly.computerId !== undefined`), drop tools that spawn local + // processes and cannot run over SFTP — the `lsp` tool (local LSP servers) + // and MCP-namespaced tools (`<serverId>__<toolName>`, local MCP servers). + // When LOCAL (`computerId === undefined`), the filter is a passthrough — + // byte-identical to today. Registered at default priority (0) with + // activation-order tie-breaking: session-orchestrator activates before + // MCP (which dependsOn it), so this runs FIRST in the chain — the drops + // happen before MCP's filter connects/registers servers. Mirrors how MCP + // adds its own filter via host.addFilter. + host.addFilter(toolsFilter, filterRemoteIncompatibleTools); + const warmService = createWarmService( { conversationStore, diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index e2d3b6b..8ff3f5e 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -33,6 +33,7 @@ function createInMemoryStore(): ConversationStore & { readonly data: Map<string, ChatMessage[]>; readonly metricsData: Map<string, TurnMetrics[]>; readonly cwdData: Map<string, string>; + readonly computerData: Map<string, string>; readonly effortData: Map<string, ReasoningEffort>; readonly modelData: Map<string, string>; readonly workspaceIdData: Map<string, string>; @@ -40,6 +41,7 @@ function createInMemoryStore(): ConversationStore & { const data = new Map<string, ChatMessage[]>(); const metricsData = new Map<string, TurnMetrics[]>(); const cwdData = new Map<string, string>(); + const computerData = new Map<string, string>(); const effortData = new Map<string, ReasoningEffort>(); const modelData = new Map<string, string>(); const workspaceIdData = new Map<string, string>(); @@ -53,6 +55,7 @@ function createInMemoryStore(): ConversationStore & { data, metricsData, cwdData, + computerData, effortData, modelData, workspaceIdData, @@ -91,6 +94,22 @@ function createInMemoryStore(): ConversationStore & { async setCwd(conversationId, cwd) { cwdData.set(conversationId, cwd); }, + async clearCwd(conversationId) { + cwdData.delete(conversationId); + }, + async getComputerId(conversationId) { + return computerData.get(conversationId) ?? null; + }, + async setComputerId(conversationId, alias) { + if (alias === null) { + computerData.delete(conversationId); + } else { + computerData.set(conversationId, alias); + } + }, + async clearComputerId(conversationId) { + computerData.delete(conversationId); + }, async getReasoningEffort(conversationId) { return effortData.get(conversationId) ?? null; }, @@ -149,13 +168,44 @@ function createInMemoryStore(): ConversationStore & { return null; }, async ensureWorkspace(id) { - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceTitle(id, title) { - return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceDefaultCwd(id, defaultCwd) { - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -173,6 +223,9 @@ function createInMemoryStore(): ConversationStore & { async getEffectiveCwd(conversationId, overrideCwd) { return overrideCwd ?? cwdData.get(conversationId) ?? null; }, + async getEffectiveComputer(conversationId, overrideAlias) { + return overrideAlias ?? computerData.get(conversationId) ?? null; + }, }; } @@ -489,6 +542,114 @@ describe("handleMessage model resolution", () => { expect(captured[1]?.cwd).toBeUndefined(); }); + it("computerId is forwarded to RunTurnInput.computerId and absent when not provided", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-computer", + text: "hi", + onEvent: () => {}, + computerId: "my-ssh-host", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.computerId).toBe("my-ssh-host"); + + await orchestrator.handleMessage({ + conversationId: "conv-no-computer", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.computerId).toBeUndefined(); + }); + + it("computerId override persists via setComputerId (mirrors setCwd-on-override)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-computer", + text: "hi", + onEvent: () => {}, + computerId: "persisted-host", + }); + + expect(store.computerData.get("conv-persist-computer")).toBe("persisted-host"); + }); + + it("computerId not provided → setComputerId NOT called (no override persisted)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-persist", + text: "hi", + onEvent: () => {}, + }); + + expect(store.computerData.get("conv-no-persist")).toBeUndefined(); + }); + + it("computerId threads into ToolAssembly passed to applyToolsFilter", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const capturedAssemblies: ToolAssembly[] = []; + const recordingApplyToolsFilter = (assembly: ToolAssembly): Promise<ToolAssembly> => { + capturedAssemblies.push(assembly); + return Promise.resolve(assembly); + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: recordingApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-assembly", + text: "hi", + onEvent: () => {}, + computerId: "remote-host", + }); + + expect(capturedAssemblies).toHaveLength(1); + expect(capturedAssemblies[0]?.computerId).toBe("remote-host"); + }); + it("forwards an injected now into the RunTurnInput passed to runTurn", async () => { const store = createInMemoryStore(); const provider: ProviderContract = { id: "p", stream: async function* () {} }; @@ -643,6 +804,12 @@ describe("turn-sealed event", () => { return null; }, async setCwd() {}, + async clearCwd() {}, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, async getReasoningEffort() { return null; }, @@ -673,13 +840,44 @@ describe("turn-sealed event", () => { return null; }, async ensureWorkspace(id) { - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceTitle(id, title) { - return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceDefaultCwd(id, defaultCwd) { - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -694,6 +892,9 @@ describe("turn-sealed event", () => { async getEffectiveCwd() { return null; }, + async getEffectiveComputer() { + return null; + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -1034,6 +1235,12 @@ describe("turn metrics persistence", () => { return null; }, async setCwd() {}, + async clearCwd() {}, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, async getReasoningEffort() { return null; }, @@ -1064,13 +1271,44 @@ describe("turn metrics persistence", () => { return null; }, async ensureWorkspace(id) { - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceTitle(id, title) { - return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceDefaultCwd(id, defaultCwd) { - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -1085,6 +1323,9 @@ describe("turn metrics persistence", () => { async getEffectiveCwd() { return null; }, + async getEffectiveComputer() { + return null; + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -2781,7 +3022,14 @@ describe("workspace integration", () => { ...base, async ensureWorkspace(id) { ensureWorkspaceCalls.push(id); - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, }; @@ -2999,6 +3247,7 @@ describe("workspace integration", () => { id, title: id, defaultCwd: workspaceDefaultCwds.get(id) ?? null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -3012,7 +3261,14 @@ describe("workspace integration", () => { }, async getWorkspace(id) { const defaultCwd = workspaceDefaultCwds.get(id) ?? null; - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async getEffectiveCwd(conversationId, overrideCwd) { // Real algorithm: relative cwd resolved against workspace defaultCwd. @@ -3089,6 +3345,7 @@ describe("workspace integration", () => { id, title: id, defaultCwd: workspaceDefaultCwds.get(id) ?? null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -3101,7 +3358,14 @@ describe("workspace integration", () => { }, async getWorkspace(id) { const defaultCwd = workspaceDefaultCwds.get(id) ?? null; - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async getEffectiveCwd(conversationId, overrideCwd) { const wsId = assignedWorkspaceIds.get(conversationId) ?? "default"; diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index a533a16..ae27e59 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -37,6 +37,14 @@ export interface StartTurnInput { readonly text: string; readonly modelName?: string; readonly cwd?: string; + /** + * The computer to execute this turn's tools on (SSH config alias). Mirrors + * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`. + * Omitted/`undefined` = use the persisted per-conversation / workspace + * default (LOCAL when none set). The orchestrator never interprets it — it + * forwards the alias string verbatim (like cwd forwards a path). + */ + readonly computerId?: string; readonly reasoningEffort?: ReasoningEffort; /** * The workspace this conversation belongs to. Defaults to `"default"` when @@ -57,6 +65,12 @@ export interface EnqueueInput { readonly text: string; /** Workspace to stamp on a new conversation. Defaults to `"default"`. */ readonly workspaceId?: string; + /** + * Per-turn computer override (SSH alias), threaded to `startTurn` when the + * conversation is idle (the message starts a turn). Additive optional — + * mirrors `workspaceId` on this type (enqueue does not carry `cwd`). + */ + readonly computerId?: string; } /** @@ -87,6 +101,8 @@ interface ActiveTurn { export interface TurnLifecyclePayload { readonly conversationId: string; readonly cwd?: string; + /** The computer this turn executes on (SSH alias), mirroring `cwd`. */ + readonly computerId?: string; readonly modelName?: string; } @@ -256,6 +272,7 @@ export interface SessionOrchestrator { onEvent: (event: AgentEvent) => void; modelName?: string; cwd?: string; + computerId?: string; reasoningEffort?: ReasoningEffort; workspaceId?: string; }): Promise<void>; @@ -369,6 +386,7 @@ export function createSessionOrchestrator( text: string, modelName: string | undefined, cwd: string | undefined, + computerId: string | undefined, reasoningEffortOverride: ReasoningEffort | undefined, workspaceId: string, ): void { @@ -411,6 +429,19 @@ export function createSessionOrchestrator( deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined), ); + // Resolve the effective computer the SAME way cwd resolves — pass the + // per-turn computerId as the overrideAlias. When computerId is + // undefined, getEffectiveComputer reads the persisted per-conversation + // computerId → workspace defaultComputerId → null (LOCAL). Chained + // after workspaceSetupPromise (same timing invariant as cwd). The + // orchestrator never interprets the alias — it forwards the string + // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise. + const effectiveComputerIdPromise = workspaceSetupPromise.then(() => + deps.conversationStore + .getEffectiveComputer(conversationId, computerId) + .then((c) => c ?? undefined), + ); + const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId); // Resolve the persisted model (if any) in parallel with the other // per-conversation reads. The effective model name is @@ -420,13 +451,15 @@ export function createSessionOrchestrator( const payloadPromise = Promise.all([ effectiveCwdPromise, + effectiveComputerIdPromise, storedEffortPromise, storedModelPromise, - ]).then(([effectiveCwd, _storedEffort, storedModel]) => { + ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => { const effectiveModelName = resolveModelName(modelName, storedModel); return { conversationId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}), }; }); @@ -448,17 +481,27 @@ export function createSessionOrchestrator( void (async () => { let sealed = false; try { - const [effectiveCwd, storedEffort, isNewConversation, storedModel] = await Promise.all([ - effectiveCwdPromise, - storedEffortPromise, - workspaceSetupPromise, - storedModelPromise, - ]); + const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] = + await Promise.all([ + effectiveCwdPromise, + effectiveComputerIdPromise, + storedEffortPromise, + workspaceSetupPromise, + storedModelPromise, + ]); if (cwd !== undefined) { await deps.conversationStore.setCwd(conversationId, cwd); } + // Persist the per-turn computer override, mirroring the cwd + // persistence above. Only stamped when a computerId was actually + // provided — NOT when it resolved to undefined (LOCAL) via the + // workspace default. Idempotent when the value is unchanged. + if (computerId !== undefined) { + await deps.conversationStore.setComputerId(conversationId, computerId); + } + const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort); // Effective model name: per-turn override → persisted → undefined // (→ default provider). Resolved here so every downstream consumer @@ -508,6 +551,7 @@ export function createSessionOrchestrator( tools: baseTools, conversationId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), }); const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); const turnLogger = deps.logger?.child({ conversationId, turnId }); @@ -598,6 +642,7 @@ export function createSessionOrchestrator( providerOpts, ...(turnLogger !== undefined ? { logger: turnLogger } : {}), ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), ...(deps.now !== undefined ? { now: deps.now } : {}), ...(drainSteering !== undefined ? { drainSteering } : {}), }; @@ -683,7 +728,7 @@ export function createSessionOrchestrator( } const orchestrator: SessionOrchestrator = { - startTurn({ conversationId, text, modelName, cwd, reasoningEffort, workspaceId }) { + startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId }) { if (activeTurns.has(conversationId)) { return { started: false, reason: "already-active" }; } @@ -692,6 +737,7 @@ export function createSessionOrchestrator( text, modelName, cwd, + computerId, reasoningEffort, workspaceId ?? "default", ); @@ -700,11 +746,12 @@ export function createSessionOrchestrator( return { started: true, turnId }; }, - enqueue({ conversationId, text, workspaceId }) { + enqueue({ conversationId, text, workspaceId, computerId }) { const result = orchestrator.startTurn({ conversationId, text, ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(computerId !== undefined ? { computerId } : {}), }); if (result.started) { return { startedTurn: true, queue: [] }; @@ -785,6 +832,7 @@ export function createSessionOrchestrator( onEvent, modelName, cwd, + computerId, reasoningEffort, workspaceId, }) { @@ -793,6 +841,7 @@ export function createSessionOrchestrator( text, ...(modelName !== undefined ? { modelName } : {}), ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), ...(workspaceId !== undefined ? { workspaceId } : {}), }; diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index 225d1af..adf5d9a 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -72,6 +72,14 @@ function createInMemoryStore(): ConversationStore & { async setCwd(conversationId, cwd) { cwdData.set(conversationId, cwd); }, + async clearCwd(conversationId) { + cwdData.delete(conversationId); + }, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, async getReasoningEffort(conversationId) { return effortData.get(conversationId) ?? null; }, @@ -110,13 +118,44 @@ function createInMemoryStore(): ConversationStore & { return null; }, async ensureWorkspace(id) { - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceTitle(id, title) { - return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceDefaultCwd(id, defaultCwd) { - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -131,6 +170,9 @@ function createInMemoryStore(): ConversationStore & { async getEffectiveCwd(conversationId) { return cwdData.get(conversationId) ?? null; }, + async getEffectiveComputer() { + return null; + }, }; } diff --git a/packages/session-orchestrator/src/tools-filter.test.ts b/packages/session-orchestrator/src/tools-filter.test.ts new file mode 100644 index 0000000..3233469 --- /dev/null +++ b/packages/session-orchestrator/src/tools-filter.test.ts @@ -0,0 +1,75 @@ +import type { ToolContract } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { filterRemoteIncompatibleTools, type ToolAssembly } from "./tools-filter.js"; + +function fakeTool(name: string): ToolContract { + return { + name, + description: `Fake tool: ${name}`, + parameters: { type: "object" }, + execute: async () => ({ content: "ok" }), + }; +} + +const baseAssembly: ToolAssembly = { + tools: [fakeTool("lsp"), fakeTool("mcp__x"), fakeTool("run_shell")], + conversationId: "conv-1", +}; + +describe("filterRemoteIncompatibleTools", () => { + it("REMOTE (computerId set): drops 'lsp' and any '__' namespaced tool, keeps 'run_shell'", () => { + const remote: ToolAssembly = { ...baseAssembly, computerId: "my-server" }; + const result = filterRemoteIncompatibleTools(remote); + const names = result.tools.map((t) => t.name); + expect(names).not.toContain("lsp"); + expect(names).not.toContain("mcp__x"); + expect(names).toContain("run_shell"); + expect(result.tools).toHaveLength(1); + }); + + it("REMOTE: preserves computerId + cwd + conversationId in the returned assembly", () => { + const remote: ToolAssembly = { + tools: [fakeTool("lsp"), fakeTool("run_shell")], + conversationId: "conv-2", + cwd: "/work", + computerId: "ssh-host", + }; + const result = filterRemoteIncompatibleTools(remote); + expect(result.computerId).toBe("ssh-host"); + expect(result.cwd).toBe("/work"); + expect(result.conversationId).toBe("conv-2"); + }); + + it("LOCAL (computerId undefined): passthrough — nothing is dropped", () => { + const local: ToolAssembly = { ...baseAssembly }; + const result = filterRemoteIncompatibleTools(local); + expect(result.tools).toHaveLength(3); + const names = result.tools.map((t) => t.name); + expect(names).toContain("lsp"); + expect(names).toContain("mcp__x"); + expect(names).toContain("run_shell"); + }); + + it("LOCAL: returns the exact same assembly object (byte-identical)", () => { + const local: ToolAssembly = { ...baseAssembly }; + const result = filterRemoteIncompatibleTools(local); + expect(result).toBe(local); + }); + + it("REMOTE: drops multiple MCP-namespaced tools (serverId__toolName pattern)", () => { + const remote: ToolAssembly = { + tools: [ + fakeTool("lsp"), + fakeTool("filesystem__read"), + fakeTool("github__create_issue"), + fakeTool("run_shell"), + fakeTool("write_file"), + ], + conversationId: "conv-3", + computerId: "host", + }; + const result = filterRemoteIncompatibleTools(remote); + const names = result.tools.map((t) => t.name); + expect(names).toEqual(["run_shell", "write_file"]); + }); +}); diff --git a/packages/session-orchestrator/src/tools-filter.ts b/packages/session-orchestrator/src/tools-filter.ts index 19b2eb3..913e574 100644 --- a/packages/session-orchestrator/src/tools-filter.ts +++ b/packages/session-orchestrator/src/tools-filter.ts @@ -6,6 +6,13 @@ export interface ToolAssembly { readonly tools: readonly ToolContract[]; /** This turn's working directory (verbatim from the request), for cwd-aware filters. */ readonly cwd?: string; + /** + * The computer this turn executes on (SSH alias), for computer-aware + * filters. Omitted/`undefined` = LOCAL (today's behavior). When set, the + * turn is REMOTE — {@link filterRemoteIncompatibleTools} drops tools that + * cannot run over SFTP (local-process servers). Mirrors `cwd?`. + */ + readonly computerId?: string; /** The conversation this turn belongs to. */ readonly conversationId: string; } @@ -14,3 +21,39 @@ export interface ToolAssembly { export const toolsFilter: FilterDescriptor<ToolAssembly> = defineFilter<ToolAssembly>( "session-orchestrator/tools", ); + +/** + * Remote-degradation rule (plan §6). When a turn is REMOTE + * (`assembly.computerId !== undefined`), drop tools that cannot execute over + * SFTP because they spawn local processes: + * + * - the tool named exactly `"lsp"` — its servers are local LSP processes that + * can't see remote files over SFTP, AND + * - any tool whose name includes `"__"` — MCP-namespaced tools + * (`<serverId>__<toolName>`); MCP servers spawn local processes. + * + * When `assembly.computerId` is `undefined` (LOCAL), this is a passthrough — + * nothing is dropped (byte-identical to today). Tool-name matching is DATA (the + * kernel routes tool-calls by name — that is the sanctioned string-keyed + * exception), so a name-based filter is correct here, not a string-keyed + * cross-feature code lookup. + * + * Extracted from the filter handler so it is unit-testable without I/O. Mirrors + * MCP's `filterMcpTools` extraction pattern. + */ +export function filterRemoteIncompatibleTools(assembly: ToolAssembly): ToolAssembly { + // LOCAL — passthrough, byte-identical to today. + if (assembly.computerId === undefined) return assembly; + // REMOTE — drop lsp + MCP-namespaced tools (local-process servers). + const filtered = assembly.tools.filter((tool) => { + if (tool.name === "lsp") return false; + if (tool.name.includes("__")) return false; + return true; + }); + return { + tools: filtered, + ...(assembly.cwd !== undefined ? { cwd: assembly.cwd } : {}), + ...(assembly.computerId !== undefined ? { computerId: assembly.computerId } : {}), + conversationId: assembly.conversationId, + }; +} diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts index 6d0129c..0aad643 100644 --- a/packages/transport-contract/src/contract.types.test.ts +++ b/packages/transport-contract/src/contract.types.test.ts @@ -8,12 +8,22 @@ import { describe, expect, it } from "vitest"; import type { + ChatRequest, + Computer, + ComputerEntry, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConversationComputerResponse, CwdResponse, LspServerInfo, LspServerState, LspStatusResponse, McpStatusResponse, + SetConversationComputerRequest, SetCwdRequest, + SetWorkspaceDefaultComputerRequest, + TestComputerResponse, } from "./index.js"; // ─── CwdResponse ───────────────────────────────────────────────────────────── @@ -34,6 +44,89 @@ const _setCwd: SetCwdRequest = { cwd: "/tmp/workspace", }; +// ─── ChatRequest.computerId (additive optional) ────────────────────────────── + +const _chatWithComputer: ChatRequest = { + message: "run the test suite", + computerId: "prod-box", +}; + +const _chatWithoutComputer: ChatRequest = { + message: "hello", +}; + +// ─── Computer list / single response ───────────────────────────────────────── + +const _computer: Computer = { + alias: "prod-box", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/user/.ssh/id_ed25519", + knownHost: true, +}; + +const _computerEntry: ComputerEntry = { + ..._computer, + usageCount: 3, +}; + +const _computerList: ComputerListResponse = { + computers: [_computerEntry], +}; + +const _computerResponse: ComputerResponse = _computer; + +// ─── Computer status / test probe ──────────────────────────────────────────── + +const _statusConnected: ComputerStatusResponse = { + alias: "prod-box", + state: "connected", + knownHost: true, +}; + +const _statusError: ComputerStatusResponse = { + alias: "prod-box", + state: "error", + error: "connection refused", + knownHost: false, +}; + +const _testOk: TestComputerResponse = { + alias: "prod-box", + ok: true, +}; + +const _testFail: TestComputerResponse = { + alias: "prod-box", + ok: false, + error: "auth failed", +}; + +// ─── Per-conversation + workspace computer ─────────────────────────────────── + +const _setConvComputer: SetConversationComputerRequest = { + computerId: "prod-box", +}; + +const _clearConvComputer: SetConversationComputerRequest = { + computerId: null, +}; + +const _convComputer: ConversationComputerResponse = { + conversationId: "conv-1", + computerId: "prod-box", +}; + +const _convComputerNull: ConversationComputerResponse = { + conversationId: "conv-2", + computerId: null, +}; + +const _setDefaultComputer: SetWorkspaceDefaultComputerRequest = { + computerId: null, +}; + // ─── LspServerState ────────────────────────────────────────────────────────── const _stateConnected: LspServerState = "connected"; @@ -151,4 +244,71 @@ describe("transport-contract types compile and are exported", () => { expect(_withServers.servers[0]?.toolCount).toBe(12); expect(_withServers.servers[1]?.error).toBe("spawn failed"); }); + + // ─── ChatRequest.computerId ────────────────────────────────────────────── + + it("ChatRequest: computerId is additive optional (omittable)", () => { + expect(_chatWithoutComputer.computerId).toBeUndefined(); + }); + + it("ChatRequest: carries computerId when set", () => { + expect(_chatWithComputer.computerId).toBe("prod-box"); + }); + + // ─── Computers ─────────────────────────────────────────────────────────── + + it("ComputerListResponse: carries entries with usage counts", () => { + expect(_computerList.computers).toHaveLength(1); + expect(_computerList.computers[0]?.usageCount).toBe(3); + expect(_computerList.computers[0]?.alias).toBe("prod-box"); + }); + + it("ComputerResponse: is a single Computer", () => { + expect(_computerResponse.alias).toBe("prod-box"); + expect(_computerResponse.port).toBe(22); + }); + + it("ComputerStatusResponse: all four states are valid", () => { + const states: ComputerStatusResponse["state"][] = [ + "disconnected", + "connecting", + "connected", + "error", + ]; + expect(states).toHaveLength(4); + }); + + it("ComputerStatusResponse: connected has no error field", () => { + expect(_statusConnected.state).toBe("connected"); + expect(_statusConnected.error).toBeUndefined(); + }); + + it("ComputerStatusResponse: error carries message", () => { + expect(_statusError.state).toBe("error"); + expect(_statusError.error).toBe("connection refused"); + }); + + it("TestComputerResponse: ok has no error field", () => { + expect(_testOk.ok).toBe(true); + expect(_testOk.error).toBeUndefined(); + }); + + it("TestComputerResponse: failure carries error", () => { + expect(_testFail.ok).toBe(false); + expect(_testFail.error).toBe("auth failed"); + }); + + it("SetConversationComputerRequest: null clears to inherit/local", () => { + expect(_setConvComputer.computerId).toBe("prod-box"); + expect(_clearConvComputer.computerId).toBeNull(); + }); + + it("ConversationComputerResponse: null computerId round-trips", () => { + expect(_convComputer.computerId).toBe("prod-box"); + expect(_convComputerNull.computerId).toBeNull(); + }); + + it("SetWorkspaceDefaultComputerRequest: null clears to local", () => { + expect(_setDefaultComputer.computerId).toBeNull(); + }); }); diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 4e3e7dc..b32c8a0 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -22,6 +22,8 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { AgentEvent, + Computer, + ComputerEntry, ConversationMeta, ConversationStatus, QueuedMessage, @@ -35,6 +37,8 @@ import type { export type { AgentEvent, CompactionResult, + Computer, + ComputerEntry, ConversationMeta, ConversationStatus, QueuedMessage, @@ -78,6 +82,16 @@ export interface ChatRequest { readonly cwd?: string; /** + * The computer to run this turn's tools on — an SSH config `Host` alias + * (one of the `alias` values returned by `GET /computers`). Omit to inherit + * the resolved chain: per-conversation `computerId` → the workspace's + * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is + * a per-turn tool-execution target forwarded to tools and never part of the + * model prompt (so it does not affect prompt caching). Mirrors `cwd`. + */ + readonly computerId?: string; + + /** * Reasoning-effort override for THIS turn only (does not persist). When * omitted, the server resolves the conversation's persisted value, falling * back to `"high"`. Must be one of the `ReasoningEffort` levels; an @@ -817,3 +831,80 @@ export interface DeleteWorkspaceResponse { /** Conversations that were closed (status → "closed") by this delete. */ readonly closedCount: number; } + +// ─── Computers ─────────────────────────────────────────────────────────────── + +/** + * Response of `GET /computers` — every remote computer discovered from the + * system's `~/.ssh/config`, sorted by `alias`. Parallel to + * `WorkspaceListResponse`: each entry is a `ComputerEntry` (a `Computer` plus a + * usage count). There is no Computer CRUD — to add one, the user adds a `Host` + * block to `~/.ssh/config` and Dispatch discovers it on the next read. + */ +export interface ComputerListResponse { + readonly computers: readonly ComputerEntry[]; +} + +/** + * Response of `GET /computers/:alias` — a single computer. Parallel to + * `WorkspaceResponse` (the entity itself). `alias` is the `computerId` users + * select; the remaining fields are resolved from the SSH config. + */ +export interface ComputerResponse extends Computer {} + +/** + * Response of `GET /computers/:alias/status` — the live connection state of a + * computer (whether Dispatch currently holds an open SSH session to it). Drives + * the frontend connection indicator. `error` is present only when + * `state === "error"`; `knownHost` mirrors the read-only `Computer` field. + */ +export interface ComputerStatusResponse { + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; +} + +/** + * Body of `PUT /conversations/:id/computer` — set or clear the conversation's + * persisted computer selection (the computer analog of `SetCwdRequest`). Pass + * `null` to clear → the conversation inherits the workspace's + * `defaultComputerId`, then `null`/local. An unknown alias is not validated here + * (the connection resolves at turn time; an unreachable host → turn error, not + * a 400). Mirrors the cwd/model PUT clear semantics. + */ +export interface SetConversationComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `GET /conversations/:id/computer`. `computerId` is the persisted + * SSH `Host` alias, or `null` when never set (the conversation then inherits + * the workspace default → local). Parallel to `CwdResponse`. + */ +export interface ConversationComputerResponse { + readonly conversationId: string; + readonly computerId: string | null; +} + +/** + * Body of `PUT /workspaces/:id/default-computer` — set or clear the workspace's + * default computer (the computer analog of `SetWorkspaceDefaultCwdRequest`). + * `null` means local (no SSH). Conversations in the workspace with no + * `computerId` of their own inherit this. + */ +export interface SetWorkspaceDefaultComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `POST /computers/:alias/test` — the result of a one-shot + * connectivity probe (Dispatch opens an SSH connection to the alias, runs a + * trivial command, then closes). `ok` is true on success; `error` carries the + * failure reason (e.g. auth refused, host unreachable) when `ok` is false. + */ +export interface TestComputerResponse { + readonly alias: string; + readonly ok: boolean; + readonly error?: string; +} @@ -25,13 +25,21 @@ owner-agents on disjoint packages). `edit-file` behind `ExecBackend` (local-only; spawn.ts deleted — logic moved to exec-backend; edit_file gains forward-compatible remote-diagnostics skip). `tsc -b` EXIT 0, biome clean, **1599 vitest** (was 1592). -- [ ] **Wave 3**: `conversation-store` (defaultComputerId + getEffectiveComputer) - + `session-orchestrator` (resolve + thread computerId; drop lsp/mcp when - remote) + `transport-contract` (computerId on ChatRequest + computer types). -- [ ] **Wave 4**: `transport-http` + `transport-ws` (computer endpoints + chat). +- [x] **Wave 3** (parallel): `session-orchestrator` (thread computerId end-to-end + + remote tool-drop filter: drops `lsp` + `__`-namespaced MCP tools when + remote) + `transport-contract` (ChatRequest.computerId + computer endpoint + 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). - [ ] **Wave 5**: `host-bin` wiring + `ssh` package (SshConnectionPool, SshExecBackend, ~/.ssh/config reader via ssh-config, known_hosts pinning). -- [ ] **Wave 6**: `cache-warming` computerId threading + full verify. +- [ ] **DEFERRED — cache-warming**: computerId threading intentionally NOT done + (user-deferred — cache-warming is not needed right now). Known limitation: + a warm probe on a remote turn assembles the tool set WITHOUT the remote-drop + → a potential prompt-cache miss (performance-only, not correctness). Revisit + when cache-warming is re-enabled. Key decisions: ssh2 + ssh-config (project-local deps); key-only auth from `~/.ssh`; auto-trust-and-pin host keys; computers discovered read-only from `~/.ssh/config` (no CRUD entity); computerId persisted per-conversation; LSP/MCP |
