diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 12:22:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 12:22:41 +0900 |
| commit | 54db4583e66134010375a1fa94256f36034ffdff (patch) | |
| tree | ec0bcd395d365741ed18e160f9b5842233051ba2 | |
| parent | 0b154bdad4f75a091db3ca46424abd17fbbc23ff (diff) | |
| download | dispatch-54db4583e66134010375a1fa94256f36034ffdff.tar.gz dispatch-54db4583e66134010375a1fa94256f36034ffdff.zip | |
feat(ssh): wave 1 — ExecBackend + computer data model + runtime threading
Wave 1 of transparent SSH support (parallel owner-agents on disjoint packages,
plus the orchestrator-authored kernel contract seam from wave 0):
- packages/wire: + Computer/ComputerEntry (read-only view over ~/.ssh/config
Host aliases) + Workspace.defaultComputerId (string|null, null=local). Types
only; 3 conformance tests.
- packages/exec-backend (NEW core extension): the ExecBackend abstraction
(spawn + minimal fs surface) the bundled tools will program against instead
of node:fs/child_process. LocalExecBackend wraps today's node calls
(behavior-identical; node:fs-style .code errors). execBackendHandle +
ExecBackendResolver (sync; computerId undefined -> local; set -> throws until
the ssh package wires remote resolution in wave 5). 20 tests.
- packages/kernel (runtime only): thread computerId through dispatch.ts +
run-turn.ts exactly as cwd is threaded (opaque, forwarded to
ToolExecuteContext; absent = local = byte-identical to today). +2 tests.
- packages/conversation-store: computer (SSH alias) assignment + resolution
mirroring cwd — WorkspaceRow.defaultComputerId + setWorkspaceDefaultComputerId
+ getComputerId/setComputerId/clearComputerId + getEffectiveComputer
(override -> per-conv -> workspace default -> null/local). Fixes the 3
Workspace literal sites the new required wire field broke. +18 tests.
- orchestrator: root tsconfig.json ref for exec-backend + bun install.
Verified: tsc -b EXIT 0, biome clean, 1592 vitest pass (was 1549, +43).
Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
| -rw-r--r-- | bun.lock | 9 | ||||
| -rw-r--r-- | packages/conversation-store/src/keys.ts | 4 | ||||
| -rw-r--r-- | packages/conversation-store/src/store-workspace.test.ts | 241 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 162 | ||||
| -rw-r--r-- | packages/exec-backend/package.json | 11 | ||||
| -rw-r--r-- | packages/exec-backend/src/backend.test.ts | 63 | ||||
| -rw-r--r-- | packages/exec-backend/src/backend.ts | 78 | ||||
| -rw-r--r-- | packages/exec-backend/src/extension.ts | 48 | ||||
| -rw-r--r-- | packages/exec-backend/src/index.ts | 5 | ||||
| -rw-r--r-- | packages/exec-backend/src/local.test.ts | 199 | ||||
| -rw-r--r-- | packages/exec-backend/src/local.ts | 146 | ||||
| -rw-r--r-- | packages/exec-backend/src/service.ts | 27 | ||||
| -rw-r--r-- | packages/exec-backend/tsconfig.json | 6 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/dispatch.ts | 4 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/run-turn.test.ts | 65 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/run-turn.ts | 3 | ||||
| -rw-r--r-- | packages/wire/src/index.test.ts | 59 | ||||
| -rw-r--r-- | packages/wire/src/index.ts | 49 | ||||
| -rw-r--r-- | tasks.md | 31 | ||||
| -rw-r--r-- | tsconfig.json | 3 |
20 files changed, 1208 insertions, 5 deletions
@@ -51,6 +51,13 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/exec-backend": { + "name": "@dispatch/exec-backend", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + }, + }, "packages/host-bin": { "name": "@dispatch/host-bin", "version": "0.0.0", @@ -351,6 +358,8 @@ "@dispatch/credential-store": ["@dispatch/credential-store@workspace:packages/credential-store"], + "@dispatch/exec-backend": ["@dispatch/exec-backend@workspace:packages/exec-backend"], + "@dispatch/host-bin": ["@dispatch/host-bin@workspace:packages/host-bin"], "@dispatch/journal-sink": ["@dispatch/journal-sink@workspace:packages/journal-sink"], diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 1fd1237..061871e 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -50,6 +50,10 @@ export function cwdKey(conversationId: string): string { return `conv:${conversationId}:cwd`; } +export function computerKey(conversationId: string): string { + return `conv:${conversationId}:computer`; +} + export function reasoningEffortKey(conversationId: string): string { return `conv:${conversationId}:reasoning-effort`; } diff --git a/packages/conversation-store/src/store-workspace.test.ts b/packages/conversation-store/src/store-workspace.test.ts index 48c63e5..3926c94 100644 --- a/packages/conversation-store/src/store-workspace.test.ts +++ b/packages/conversation-store/src/store-workspace.test.ts @@ -46,6 +46,7 @@ describe("WorkspaceStore", () => { id: "my-work", title: "my-work", defaultCwd: null, + defaultComputerId: null, createdAt: 1000, lastActivityAt: 1000, }); @@ -64,6 +65,7 @@ describe("WorkspaceStore", () => { id: "my-work", title: "my-work", defaultCwd: null, + defaultComputerId: null, createdAt: 1000, lastActivityAt: 1000, }); @@ -80,6 +82,7 @@ describe("WorkspaceStore", () => { id: "my-work", title: "Custom", defaultCwd: "/projects/dispatch", + defaultComputerId: null, createdAt: 3000, lastActivityAt: 3000, }); @@ -92,6 +95,7 @@ describe("WorkspaceStore", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }); @@ -112,6 +116,7 @@ describe("WorkspaceStore", () => { id: "my-work", title: "Renamed", defaultCwd: null, + defaultComputerId: null, createdAt: 1000, lastActivityAt: 1000, }); @@ -208,6 +213,7 @@ describe("WorkspaceStore", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -417,6 +423,241 @@ describe("WorkspaceStore", () => { }); }); +describe("ComputerStore", () => { + let storage: StorageNamespace; + let clock: number; + + beforeEach(() => { + storage = createMemoryStorage(); + clock = 1000; + }); + + function makeStore() { + return createConversationStore(storage, undefined, () => clock); + } + + // --- per-conversation computerId (mirror getCwd/setCwd/clearCwd) --- + + it("setComputerId/getComputerId round-trips an alias", async () => { + const store = makeStore(); + expect(await store.getComputerId("conv1")).toBeNull(); + await store.setComputerId("conv1", "myserver"); + expect(await store.getComputerId("conv1")).toBe("myserver"); + }); + + it("setComputerId(null) clears (is idempotent local sentinel, like clearComputerId)", async () => { + const store = makeStore(); + await store.setComputerId("conv1", "myserver"); + expect(await store.getComputerId("conv1")).toBe("myserver"); + // null is the "local" sentinel: it clears the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. + await store.setComputerId("conv1", null); + expect(await store.getComputerId("conv1")).toBeNull(); + // idempotent — clearing an already-absent key is a no-op. + await store.setComputerId("conv1", null); + expect(await store.getComputerId("conv1")).toBeNull(); + }); + + it("clearComputerId is idempotent and un-shadows the workspace default", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + // After clear: the workspace defaultComputerId is used (fall-through). + await store.clearComputerId("conv1"); + expect(await store.getComputerId("conv1")).toBeNull(); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + // idempotent — deleting an already-absent key is a no-op. + await store.clearComputerId("conv1"); + expect(await store.getComputerId("conv1")).toBeNull(); + }); + + // --- setWorkspaceDefaultComputerId (mirror setWorkspaceDefaultCwd) --- + + it("setWorkspaceDefaultComputerId sets and clears", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const setWs = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); + expect(setWs.defaultComputerId).toBe("remote-host"); + // does not bump lastActivityAt on defaultComputerId change (mirrors defaultCwd). + expect(setWs.lastActivityAt).toBe(1000); + const cleared = await store.setWorkspaceDefaultComputerId("my-work", null); + expect(cleared.defaultComputerId).toBeNull(); + }); + + it("setWorkspaceDefaultComputerId creates the workspace if missing", async () => { + const store = makeStore(); + clock = 5000; + const ws = await store.setWorkspaceDefaultComputerId("brand-new", "remote-host"); + expect(ws).toEqual({ + id: "brand-new", + title: "brand-new", + defaultCwd: null, + defaultComputerId: "remote-host", + createdAt: 5000, + lastActivityAt: 5000, + }); + }); + + it("setWorkspaceDefaultComputerId preserves defaultCwd on an existing workspace", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + clock = 2000; + const ws = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); + expect(ws.defaultCwd).toBe("/workspace/root"); + expect(ws.defaultComputerId).toBe("remote-host"); + }); + + it("the synthesized 'default' workspace still returns defaultComputerId: null (local)", async () => { + const store = makeStore(); + const ws = await store.getWorkspace("default"); + expect(ws).toEqual({ + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }); + // And it surfaces null in listWorkspaces too. + const list = await store.listWorkspaces(); + const defaultWs = list.find((w) => w.id === "default"); + expect(defaultWs?.defaultComputerId).toBeNull(); + }); + + // --- getEffectiveComputer resolution ladder (mirror getEffectiveCwd) --- + + it("getEffectiveComputer: per-conversation computerId overrides workspace defaultComputerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + }); + + it("getEffectiveComputer: workspace defaultComputerId used when conversation computerId is unset", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + }); + + it("getEffectiveComputer: null (LOCAL) when both conversation and workspace computerId are unset", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveComputer("conv1")).toBeNull(); + }); + + it("getEffectiveComputer: default workspace (no defaultComputerId) falls through to null (local)", async () => { + const store = makeStore(); + // No explicit workspace assignment — defaults to "default" workspace + // which has defaultComputerId null. + expect(await store.getEffectiveComputer("conv1")).toBeNull(); + }); + + it("getEffectiveComputer: clearComputerId falls through to workspace defaultComputerId (un-shadows it)", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + // Before clear: the conversation computerId shadows the workspace default. + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + // After clear: the workspace defaultComputerId is used (fall-through). + await store.clearComputerId("conv1"); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + }); + + // --- overrideAlias (per-turn computer override, mirror overrideCwd) --- + + it("getEffectiveComputer: overrideAlias string wins outright, overriding workspace defaultComputerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + // A string override wins outright, even over a workspace defaultComputerId. + expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); + }); + + it("getEffectiveComputer: overrideAlias string wins over the persisted per-conversation computerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // The override must win over the persisted computerId. + expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); + }); + + it("getEffectiveComputer: overrideAlias null is explicitly local and does NOT fall through", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // An explicit null override = "local for this turn": it wins outright and + // does NOT fall through to the persisted value or the workspace default. + expect(await store.getEffectiveComputer("conv1", null)).toBeNull(); + }); + + it("getEffectiveComputer: overrideAlias omitted behaves as today (uses persisted computerId)", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // No second arg — persisted computerId is used. + expect(await store.getEffectiveComputer("conv1")).toBe("persisted-host"); + }); + + // --- round-trip through persistence (parse/toWorkspace) --- + + it("a Workspace with defaultComputerId round-trips through parse/toWorkspace", async () => { + const store = makeStore(); + clock = 1000; + // Create with a defaultComputerId via ensureWorkspace, then read it back + // (exercises parseWorkspaceRow -> toWorkspace round-trip). + const created = await store.ensureWorkspace("remote-work", { + title: "Remote", + defaultComputerId: "prod-server", + }); + expect(created.defaultComputerId).toBe("prod-server"); + const roundTripped = await store.getWorkspace("remote-work"); + expect(roundTripped).toEqual({ + id: "remote-work", + title: "Remote", + defaultCwd: null, + defaultComputerId: "prod-server", + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("a legacy WorkspaceRow without defaultComputerId reads back as null (local)", async () => { + const store = makeStore(); + // Simulate a legacy row persisted before defaultComputerId existed: + // write a raw WorkspaceRow JSON lacking the field, then read it back. + await storage.set( + "workspace:legacy", + JSON.stringify({ + title: "legacy", + defaultCwd: "/legacy/cwd", + createdAt: 100, + lastActivityAt: 200, + }), + ); + const ws = await store.getWorkspace("legacy"); + expect(ws).toEqual({ + id: "legacy", + title: "legacy", + defaultCwd: "/legacy/cwd", + defaultComputerId: null, + createdAt: 100, + lastActivityAt: 200, + }); + }); +}); + describe("isValidWorkspaceSlug", () => { it("accepts valid slugs", () => { expect(isValidWorkspaceSlug("my-work")).toBe(true); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 26d5ed4..2fd0a0c 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -18,6 +18,7 @@ import { chunkKey, chunkPrefix, compactThresholdKey, + computerKey, cwdKey, metaKey, metricsKey, @@ -70,6 +71,20 @@ export interface ConversationStore { readonly setCwd: (conversationId: string, cwd: string) => Promise<void>; /** Clear (delete) the persisted working directory for a conversation. */ readonly clearCwd: (conversationId: string) => Promise<void>; + /** + * The persisted computer (SSH config `Host` alias) for a conversation, or + * `null` if never set (local). The computer analog of `getCwd`. + */ + readonly getComputerId: (conversationId: string) => Promise<string | null>; + /** + * Persist (upsert) the computer for a conversation. Passing `null` clears + * the persisted selection (idempotent) — `null` is the "local" sentinel + * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors + * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`). + */ + readonly setComputerId: (conversationId: string, alias: string | null) => Promise<void>; + /** Clear (delete) the persisted computer for a conversation. */ + readonly clearComputerId: (conversationId: string) => Promise<void>; /** The persisted reasoning-effort level for a conversation, or null if never set. */ readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>; /** Persist (upsert) the reasoning-effort level for a conversation. */ @@ -145,13 +160,26 @@ export interface ConversationStore { */ readonly ensureWorkspace: ( id: string, - opts?: { readonly title?: string; readonly defaultCwd?: string | null }, + opts?: { + readonly title?: string; + readonly defaultCwd?: string | null; + readonly defaultComputerId?: string | null; + }, ) => Promise<Workspace>; /** Rename a workspace. Creates the workspace if missing. */ readonly setWorkspaceTitle: (id: string, title: string) => Promise<Workspace>; /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise<Workspace>; /** + * Set/clear a workspace's default computer (SSH alias). Creates the + * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`. + * `null` = local (no SSH). + */ + readonly setWorkspaceDefaultComputerId: ( + id: string, + defaultComputerId: string | null, + ) => Promise<Workspace>; + /** * Delete a workspace: (1) find all conversations with `workspaceId === id`, * (2) set each to `status = "closed"` and reassign `workspaceId = "default"`, * (3) delete the workspace entity. Returns `closedCount`. Throws if `id @@ -205,6 +233,34 @@ export interface ConversationStore { conversationId: string, overrideCwd?: string, ) => Promise<string | null>; + /** + * Resolve the effective computer (SSH alias) for a conversation — the + * computer analog of `getEffectiveCwd`. Resolution ladder: + * + * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`) + * wins outright, EVEN when `null` (explicitly local for this turn — it + * does NOT fall through). + * 2. **Persisted per-conversation `computerId`** — `getComputerId`. + * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId` + * (falling back to `"default"`) + `getWorkspace`. + * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior). + * + * Returns the alias STRING (or `null`); it does NOT validate the alias + * exists in `~/.ssh/config` (validation happens at connect time — a stale + * alias yields a clear connect error rather than silently falling back to + * local). + * + * @param overrideAlias — an explicit alias to resolve INSTEAD of the + * persisted `getComputerId` value. When provided (not `undefined`), it + * is returned as-is (string or `null`), short-circuiting the rest of the + * ladder. Used by the session-orchestrator for a per-turn computer + * override (sent by the client on `chat.send`). When omitted, the + * persisted `getComputerId` is read as today. + */ + readonly getEffectiveComputer: ( + conversationId: string, + overrideAlias?: string | null, + ) => Promise<string | null>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -265,6 +321,12 @@ interface ConversationMetaRow { interface WorkspaceRow { readonly title: string; readonly defaultCwd: string | null; + /** + * The workspace's default computer (SSH config `Host` alias) — the computer + * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this + * workspace inherit it when they set no `computerId` of their own. + */ + readonly defaultComputerId: string | null; readonly createdAt: number; readonly lastActivityAt: number; } @@ -373,9 +435,14 @@ function parseWorkspaceRow(raw: string): WorkspaceRow | null { const row = parsed as WorkspaceRow; // `defaultCwd` may be null OR a string; treat anything else as null. const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null; + // `defaultComputerId` may be null OR a string; treat anything else as null + // (mirrors `defaultCwd`). Absent on legacy rows → null (local). + const defaultComputerId = + typeof row.defaultComputerId === "string" ? row.defaultComputerId : null; return { title: row.title, defaultCwd, + defaultComputerId, createdAt: row.createdAt, lastActivityAt: row.lastActivityAt, }; @@ -386,6 +453,7 @@ function toWorkspace(id: string, row: WorkspaceRow): Workspace { id, title: row.title, defaultCwd: row.defaultCwd, + defaultComputerId: row.defaultComputerId, createdAt: row.createdAt, lastActivityAt: row.lastActivityAt, }; @@ -442,10 +510,17 @@ export function createConversationStore( const existing = await readWorkspaceRow(workspaceId); const row: WorkspaceRow = existing === null - ? { title: workspaceId, defaultCwd: null, createdAt: ts, lastActivityAt: ts } + ? { + title: workspaceId, + defaultCwd: null, + defaultComputerId: null, + createdAt: ts, + lastActivityAt: ts, + } : { title: existing.title, defaultCwd: existing.defaultCwd, + defaultComputerId: existing.defaultComputerId, createdAt: existing.createdAt, lastActivityAt: ts, }; @@ -662,6 +737,36 @@ export function createConversationStore( } }, + async getComputerId(conversationId) { + return await storage.get(computerKey(conversationId)); + }, + + async setComputerId(conversationId, alias) { + // `null` is the "local" sentinel: clear the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. Idempotent + // (deleting an already-absent key is a no-op). Mirrors `setModel`'s + // clear-on-sentinel pattern. + if (alias === null) { + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + return; + } + await storage.set(computerKey(conversationId), alias); + if (logger !== undefined) { + logger.debug("computer set", { conversationId }); + } + }, + + async clearComputerId(conversationId) { + // Idempotent: deleting an already-absent key is a no-op (no error). + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + }, + async getReasoningEffort(conversationId) { return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; }, @@ -874,13 +979,15 @@ export function createConversationStore( } await ensureInIndex(targetId); - // Copy cwd + reasoning-effort + model (so the archive is self-contained). + // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained). const cwd = await storage.get(cwdKey(sourceId)); if (cwd !== null) await storage.set(cwdKey(targetId), cwd); const effort = await storage.get(reasoningEffortKey(sourceId)); if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); const model = await storage.get(modelKey(sourceId)); if (model !== null) await storage.set(modelKey(targetId), model); + const computerId = await storage.get(computerKey(sourceId)); + if (computerId !== null) await storage.set(computerKey(targetId), computerId); }, async getCompactPercent(conversationId) { @@ -917,12 +1024,14 @@ export function createConversationStore( const row = await readWorkspaceRow(id); if (row !== null) return toWorkspace(id, row); // Synthesize the always-present "default" workspace when it was - // never persisted (title "default", defaultCwd null, timestamps 0). + // never persisted (title "default", defaultCwd null, defaultComputerId + // null [local], timestamps 0). if (id === DEFAULT_WORKSPACE_ID) { return { id: DEFAULT_WORKSPACE_ID, title: DEFAULT_WORKSPACE_ID, defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -939,6 +1048,7 @@ export function createConversationStore( const row: WorkspaceRow = { title: opts?.title ?? id, defaultCwd: opts?.defaultCwd ?? null, + defaultComputerId: opts?.defaultComputerId ?? null, createdAt: ts, lastActivityAt: ts, }; @@ -954,6 +1064,7 @@ export function createConversationStore( ? { title: id, defaultCwd: null as string | null, + defaultComputerId: null as string | null, createdAt: ts, lastActivityAt: ts, } @@ -961,6 +1072,7 @@ export function createConversationStore( const row: WorkspaceRow = { title, defaultCwd: base.defaultCwd, + defaultComputerId: base.defaultComputerId, createdAt: base.createdAt, lastActivityAt: base.lastActivityAt, }; @@ -976,6 +1088,7 @@ export function createConversationStore( ? { title: id, defaultCwd: null as string | null, + defaultComputerId: null as string | null, createdAt: ts, lastActivityAt: ts, } @@ -983,6 +1096,31 @@ export function createConversationStore( const row: WorkspaceRow = { title: base.title, defaultCwd, + defaultComputerId: base.defaultComputerId, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd: base.defaultCwd, + defaultComputerId, createdAt: base.createdAt, lastActivityAt: base.lastActivityAt, }; @@ -1053,6 +1191,7 @@ export function createConversationStore( id: DEFAULT_WORKSPACE_ID, title: DEFAULT_WORKSPACE_ID, defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }); @@ -1155,5 +1294,20 @@ export function createConversationStore( } return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd); }, + + async getEffectiveComputer(conversationId, overrideAlias) { + const workspaceId = await this.getWorkspaceId(conversationId); + const workspace = await this.getWorkspace(workspaceId); + const workspaceComputerId = workspace?.defaultComputerId ?? null; + // When an explicit override is given, it wins outright — even `null` + // (explicitly local for this turn) does NOT fall through to the + // persisted / workspace values. + if (overrideAlias !== undefined) { + return overrideAlias; + } + // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL). + const computerId = await this.getComputerId(conversationId); + return computerId ?? workspaceComputerId; + }, }; } diff --git a/packages/exec-backend/package.json b/packages/exec-backend/package.json new file mode 100644 index 0000000..19a8f9b --- /dev/null +++ b/packages/exec-backend/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dispatch/exec-backend", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } +} diff --git a/packages/exec-backend/src/backend.test.ts b/packages/exec-backend/src/backend.test.ts new file mode 100644 index 0000000..30458e7 --- /dev/null +++ b/packages/exec-backend/src/backend.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js"; + +/** + * ExecBackend type conformance — a fake backend satisfies the interface. + * (Pure compile-time + runtime check; zero internal mocks.) + */ +describe("ExecBackend type conformance", () => { + it("a minimal fake satisfies the ExecBackend interface", () => { + const fake: ExecBackend = { + spawn: async (_params: SpawnParams): Promise<ExecResult> => ({ + exitCode: 0, + timedOut: false, + aborted: false, + }), + readFile: async (_path: string): Promise<string> => "", + writeFile: async (_path: string, _content: string): Promise<void> => {}, + stat: async (_path: string): Promise<StatResult> => ({ isFile: true, isDirectory: false }), + readdir: async (_path: string): Promise<readonly DirEntry[]> => [], + exists: async (_path: string): Promise<boolean> => true, + }; + + // Runtime sanity: every method is present and callable. + expect(typeof fake.spawn).toBe("function"); + expect(typeof fake.readFile).toBe("function"); + expect(typeof fake.writeFile).toBe("function"); + expect(typeof fake.stat).toBe("function"); + expect(typeof fake.readdir).toBe("function"); + expect(typeof fake.exists).toBe("function"); + }); + + it("ExecResult is { exitCode, timedOut, aborted }", () => { + const result: ExecResult = { exitCode: null, timedOut: true, aborted: false }; + expect(result.exitCode).toBeNull(); + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + }); + + it("SpawnParams carries the shell-tool seam fields", () => { + const params: SpawnParams = { + command: "echo", + cwd: "/tmp", + signal: new AbortController().signal, + timeout: 1000, + onOutput: () => {}, + }; + expect(params.command).toBe("echo"); + expect(params.timeout).toBe(1000); + }); + + it("StatResult distinguishes file vs directory", () => { + const fileStat: StatResult = { isFile: true, isDirectory: false }; + const dirStat: StatResult = { isFile: false, isDirectory: true }; + expect(fileStat.isFile && !fileStat.isDirectory).toBe(true); + expect(!dirStat.isFile && dirStat.isDirectory).toBe(true); + }); + + it("DirEntry carries name + isDirectory", () => { + const entry: DirEntry = { name: "sub", isDirectory: true }; + expect(entry.name).toBe("sub"); + expect(entry.isDirectory).toBe(true); + }); +}); diff --git a/packages/exec-backend/src/backend.ts b/packages/exec-backend/src/backend.ts new file mode 100644 index 0000000..f6a807f --- /dev/null +++ b/packages/exec-backend/src/backend.ts @@ -0,0 +1,78 @@ +/** + * ExecBackend — the transport-agnostic spawn + minimal filesystem surface. + * + * Tools (tool-shell, tool-read-file, tool-write-file, tool-edit-file) program + * against THIS abstraction instead of `node:fs` / `node:child_process` directly. + * Two implementations exist: + * + * - `LocalExecBackend` — wraps today's node calls (behavior-identical). + * - `SshExecBackend` — wraps ssh2 `exec` + `sftp` (added later by the `ssh` + * package; not this package's concern — but THIS interface is the seam it + * implements). + * + * The surface is deliberately SMALL (only what the bundled tools use) so a + * remote implementation is tractable. New operations are added here, not ad hoc. + * + * Resolved per-call from `ToolExecuteContext.computerId` via the injected + * `ExecBackendResolver` (see `./service.js`). `computerId` undefined → local. + * + * Error contract: `readFile`/`stat`/`readdir`/`writeFile` throw node:fs-style + * errors carrying a `.code` property (e.g. `"ENOENT"`) so the tools' existing + * error branches work unchanged. `exists` never throws (returns `false` on + * missing). The SshExecBackend maps ssh2 errors onto these same shapes. + */ + +/** A spawned process's result. Mirrors tool-shell's `SpawnResult` exactly. */ +export interface ExecResult { + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; +} + +/** Parameters for spawning a shell command. Mirrors tool-shell's `SpawnShell` params. */ +export interface SpawnParams { + readonly command: string; + readonly cwd: string; + readonly signal: AbortSignal; + readonly timeout: number; + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; +} + +/** Stat result — the subset read_file / write_file / edit_file need. */ +export interface StatResult { + readonly isFile: boolean; + readonly isDirectory: boolean; +} + +/** A directory entry — the subset read_file lists. */ +export interface DirEntry { + readonly name: string; + readonly isDirectory: boolean; +} + +/** + * The execution backend: spawn + a minimal filesystem surface. + * Tools program against THIS, never against `node:fs`. Resolved per-call from + * `ToolExecuteContext.computerId` via the injected resolver. + */ +export interface ExecBackend { + /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */ + readonly spawn: (params: SpawnParams) => Promise<ExecResult>; + + // --- filesystem (the read_file / write_file / edit_file surface) --- + + /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */ + readonly readFile: (path: string) => Promise<string>; + + /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */ + readonly writeFile: (path: string, content: string) => Promise<void>; + + /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */ + readonly stat: (path: string) => Promise<StatResult>; + + /** List directory entries. Throws node:fs-style errors with `.code`. */ + readonly readdir: (path: string) => Promise<readonly DirEntry[]>; + + /** Check existence without throwing (returns `false` when the path is missing). */ + readonly exists: (path: string) => Promise<boolean>; +} diff --git a/packages/exec-backend/src/extension.ts b/packages/exec-backend/src/extension.ts new file mode 100644 index 0000000..c07b7a8 --- /dev/null +++ b/packages/exec-backend/src/extension.ts @@ -0,0 +1,48 @@ +import type { Extension, Manifest } from "@dispatch/kernel"; +import type { ExecBackend } from "./backend.js"; +import { localExecBackend } from "./local.js"; +import type { ExecBackendResolver } from "./service.js"; +import { execBackendHandle } from "./service.js"; + +export const manifest: Manifest = { + id: "exec-backend", + name: "Exec Backend", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["exec-backend/resolver"] }, +}; + +/** + * The resolver provided by this extension. + * + * - `computerId` undefined → `LocalExecBackend` (today's local behavior). + * - `computerId` set → throws. Remote execution is wired by `host-bin` + the + * `ssh` package in a later wave (`SshExecBackend` implements the same + * `ExecBackend` interface). For now only the local path exists — failing + * loudly here is safer than silently running locally when remote was requested. + */ +function resolveBackend(computerId?: string): ExecBackend { + if (computerId === undefined) return localExecBackend; + throw new Error( + `Remote execution (computerId="${computerId}") is not yet configured. ` + + "The SSH backend will be wired by the ssh package.", + ); +} + +/** + * Factory: create the `exec-backend` core extension. + * + * `activate` provides the local-only `ExecBackendResolver` via the typed + * service handle. Remote resolution is added in a later wave. + */ +export function createExecBackendExtension(): Extension { + return { + manifest, + activate(host) { + const resolver: ExecBackendResolver = resolveBackend; + host.provideService(execBackendHandle, resolver); + }, + }; +} diff --git a/packages/exec-backend/src/index.ts b/packages/exec-backend/src/index.ts new file mode 100644 index 0000000..3135f79 --- /dev/null +++ b/packages/exec-backend/src/index.ts @@ -0,0 +1,5 @@ +export type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js"; +export { createExecBackendExtension, manifest } from "./extension.js"; +export { createLocalExecBackend, localExecBackend } from "./local.js"; +export type { ExecBackendResolver } from "./service.js"; +export { execBackendHandle } from "./service.js"; diff --git a/packages/exec-backend/src/local.test.ts b/packages/exec-backend/src/local.test.ts new file mode 100644 index 0000000..5357d6f --- /dev/null +++ b/packages/exec-backend/src/local.test.ts @@ -0,0 +1,199 @@ +import { writeFile as fsWriteFile, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ExecBackend } from "./backend.js"; +import { createLocalExecBackend, localExecBackend } from "./local.js"; + +/** + * LocalExecBackend — integration tests against the OUTERMOST real edge + * (real fs/spawn). Zero internal mocks; no mocking of @dispatch/*. + */ +describe("LocalExecBackend", () => { + const backend: ExecBackend = createLocalExecBackend(); + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "exec-backend-test-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + describe("spawn", () => { + it("runs a real `sh -c 'echo hi'` and returns exitCode 0 + captured stdout", async () => { + let output = ""; + const result = await backend.spawn({ + command: "echo hi", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: (data) => { + output += data; + }, + }); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.aborted).toBe(false); + expect(output).toContain("hi"); + }); + + it("returns a non-zero exit code for a failing command", async () => { + const result = await backend.spawn({ + command: "false", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: () => {}, + }); + expect(result.exitCode).toBe(1); + expect(result.aborted).toBe(false); + expect(result.timedOut).toBe(false); + }); + + it("streams stderr separately from stdout", async () => { + const streams: Array<{ data: string; stream: "stdout" | "stderr" }> = []; + const result = await backend.spawn({ + command: "echo out; echo err 1>&2", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: (data, stream) => streams.push({ data, stream }), + }); + expect(result.exitCode).toBe(0); + expect(streams.some((s) => s.stream === "stdout" && s.data.includes("out"))).toBe(true); + expect(streams.some((s) => s.stream === "stderr" && s.data.includes("err"))).toBe(true); + }); + + it("resolves with aborted: true when the signal fires", async () => { + const controller = new AbortController(); + const promise = backend.spawn({ + command: "sleep 30", + cwd: tmpDir, + signal: controller.signal, + timeout: 60_000, + onOutput: () => {}, + }); + // Let the sleep actually start. + await new Promise((r) => setTimeout(r, 300)); + controller.abort(); + const result = await promise; + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + }); + + it("resolves with timedOut: true when the timeout elapses", async () => { + const start = Date.now(); + const result = await backend.spawn({ + command: "sleep 30", + cwd: tmpDir, + signal: AbortSignal.timeout(60_000), + timeout: 300, + onOutput: () => {}, + }); + const elapsed = Date.now() - start; + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + // Should resolve shortly after the 300ms timeout, well under 30s. + expect(elapsed).toBeLessThan(10_000); + }); + }); + + describe("stat", () => { + it("distinguishes file vs directory", async () => { + await fsWriteFile(join(tmpDir, "file.txt"), "hello"); + await mkdir(join(tmpDir, "subdir")); + + const fileStat = await backend.stat(join(tmpDir, "file.txt")); + expect(fileStat.isFile).toBe(true); + expect(fileStat.isDirectory).toBe(false); + + const dirStat = await backend.stat(join(tmpDir, "subdir")); + expect(dirStat.isFile).toBe(false); + expect(dirStat.isDirectory).toBe(true); + }); + + it("throws ENOENT with .code for a missing path", async () => { + try { + await backend.stat(join(tmpDir, "nope")); + expect.fail("stat should have thrown for a missing path"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + }); + + describe("readFile / writeFile / readdir / exists round-trip", () => { + it("writes then reads a file (utf8 round-trip)", async () => { + const filePath = join(tmpDir, "round.txt"); + await backend.writeFile(filePath, "round-trip content"); + const content = await backend.readFile(filePath); + expect(content).toBe("round-trip content"); + }); + + it("readdir lists entries with correct isDirectory flags", async () => { + await fsWriteFile(join(tmpDir, "a.txt"), "a"); + await mkdir(join(tmpDir, "sub")); + + const entries = await backend.readdir(tmpDir); + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual(["a.txt", "sub"]); + + const sub = entries.find((e) => e.name === "sub"); + expect(sub?.isDirectory).toBe(true); + + const file = entries.find((e) => e.name === "a.txt"); + expect(file?.isDirectory).toBe(false); + }); + + it("exists returns true for an existing file, false for a missing one", async () => { + const filePath = join(tmpDir, "exists.txt"); + await fsWriteFile(filePath, "x"); + expect(await backend.exists(filePath)).toBe(true); + expect(await backend.exists(join(tmpDir, "missing"))).toBe(false); + }); + + it("exists returns true for an existing directory", async () => { + await mkdir(join(tmpDir, "adir")); + expect(await backend.exists(join(tmpDir, "adir"))).toBe(true); + }); + + it("readFile throws ENOENT with .code for a missing file", async () => { + try { + await backend.readFile(join(tmpDir, "missing.txt")); + expect.fail("readFile should have thrown for a missing file"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + + it("readdir throws ENOENT with .code for a missing directory", async () => { + try { + await backend.readdir(join(tmpDir, "missingdir")); + expect.fail("readdir should have thrown for a missing directory"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + + it("writeFile throws an error with .code when the parent dir is missing", async () => { + try { + await backend.writeFile(join(tmpDir, "missing-parent", "child.txt"), "x"); + expect.fail("writeFile should have thrown for a missing parent dir"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + }); + + describe("singleton", () => { + it("localExecBackend singleton satisfies ExecBackend and behaves identically", async () => { + expect(typeof localExecBackend.spawn).toBe("function"); + expect(typeof localExecBackend.readFile).toBe("function"); + const filePath = join(tmpDir, "singleton.txt"); + await localExecBackend.writeFile(filePath, "singleton"); + expect(await localExecBackend.readFile(filePath)).toBe("singleton"); + }); + }); +}); diff --git a/packages/exec-backend/src/local.ts b/packages/exec-backend/src/local.ts new file mode 100644 index 0000000..ca88a11 --- /dev/null +++ b/packages/exec-backend/src/local.ts @@ -0,0 +1,146 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import { access, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js"; + +/** + * LocalExecBackend — wraps `node:fs/promises` + `node:child_process`. + * + * Behavior is IDENTICAL to today's local tools: + * - `spawn` mirrors `realSpawn` in `packages/tool-shell/src/spawn.ts` — same + * `sh -c` invocation, detached process-group kill on abort/timeout, + * close-based resolution, and spawn-error → `{ exitCode: 1 }`. + * - `readFile`/`writeFile`/`stat`/`readdir` use the same `node:fs/promises` + * calls (utf8, `withFileTypes`) the tools make inline today, and throw the + * same node errors (carrying `.code`) so the tools' existing error branches + * work unchanged. + * - `exists` swallows all errors and returns `false` (an existence check). + * + * This factors the inline node calls out behind the `ExecBackend` interface so + * a remote (SshExecBackend) can swap in transparently. Stateless — safe to + * share as a singleton. + */ +export function createLocalExecBackend(): ExecBackend { + return { + spawn: localSpawn, + + readFile: (path) => readFile(path, "utf8"), + + writeFile: (path, content) => writeFile(path, content, "utf8"), + + stat: async (path): Promise<StatResult> => { + const s = await stat(path); + return { isFile: s.isFile(), isDirectory: s.isDirectory() }; + }, + + readdir: async (path): Promise<readonly DirEntry[]> => { + const entries = await readdir(path, { encoding: "utf8", withFileTypes: true }); + return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() })); + }, + + exists: async (path): Promise<boolean> => { + try { + await access(path); + return true; + } catch { + return false; + } + }, + }; +} + +/** Default singleton — stateless, safe to share across calls. */ +export const localExecBackend: ExecBackend = createLocalExecBackend(); + +/** + * Run a shell command locally via `node:child_process`. + * + * Ported verbatim from `packages/tool-shell/src/spawn.ts` (`realSpawn`) so + * behavior is byte-identical: `sh -c <command>`, `detached: true` (own process + * group), process-group `SIGKILL` on abort/timeout so a backgrounded grandchild + * cannot hold the stdio pipes open, and resolve-once-with-cleanup to avoid + * listener/timer leaks. + */ +function localSpawn(params: SpawnParams): Promise<ExecResult> { + return new Promise<ExecResult>((resolve) => { + // detached: true puts the child in its own process group (pgid = child.pid). + // This lets us kill the entire group (child + any grandchildren that inherit + // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a + // backgrounded grandchild can't keep the stdio pipes open and stall the + // promise on child.on("close"). + const child = nodeSpawn("sh", ["-c", params.command], { + cwd: params.cwd, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let settled = false; + let timedOut = false; + let timer: ReturnType<typeof setTimeout> | undefined; + + /** Kill the entire child process group (best-effort — group may be gone). */ + const killGroup = () => { + if (child.pid !== undefined) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // Process group may already be gone — ignore. + } + } + }; + + /** Remove the abort listener and clear the timeout timer (no leaks). */ + const cleanup = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + params.signal.removeEventListener("abort", onAbort); + }; + + /** Resolve once, then clean up so listeners/timers never leak. */ + const settle = (result: ExecResult) => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + + const onAbort = () => { + if (settled) return; + killGroup(); + // Resolve immediately — do NOT wait for child.on("close"), which may + // never fire if a grandchild holds the pipes open. + settle({ exitCode: null, timedOut: false, aborted: true }); + }; + params.signal.addEventListener("abort", onAbort, { once: true }); + + timer = setTimeout(() => { + if (settled) return; + timedOut = true; + killGroup(); + // Resolve immediately — same reasoning as abort. + settle({ exitCode: null, timedOut: true, aborted: false }); + }, params.timeout); + + child.stdout.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stdout"); + }); + + child.stderr.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stderr"); + }); + + // Normal-completion path: wait for "close" so all stdout/stderr is captured. + // If abort/timeout already settled, this is a no-op (settled === true). + child.on("close", (code) => { + settle({ exitCode: code, timedOut, aborted: false }); + }); + + // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case + // and resolve — never leave the promise pending. + child.on("error", () => { + killGroup(); + settle({ exitCode: 1, timedOut: false, aborted: false }); + }); + }); +} diff --git a/packages/exec-backend/src/service.ts b/packages/exec-backend/src/service.ts new file mode 100644 index 0000000..81ea5fa --- /dev/null +++ b/packages/exec-backend/src/service.ts @@ -0,0 +1,27 @@ +import { defineService } from "@dispatch/kernel"; +import type { ExecBackend } from "./backend.js"; + +/** + * Resolve an `ExecBackend` for a given computer. + * + * - `computerId` **undefined** → local (today's behavior; `LocalExecBackend`). + * - `computerId` **set** → remote (SSH; wired by `host-bin` + the `ssh` package + * in a later wave — the `SshExecBackend` implements the same `ExecBackend` + * interface). + * + * The resolver is SYNCHRONOUS by design: it returns a backend whose methods are + * async, so any remote connection acquisition happens lazily inside the first + * backend method call, not at resolver-call time. This keeps the resolver + * side-effect-free — merely resolving a backend never opens a connection; only + * when a tool actually executes does the (remote) backend connect. + */ +export type ExecBackendResolver = (computerId?: string) => ExecBackend; + +/** + * Typed service handle for the `ExecBackend` resolver. + * + * The `exec-backend` extension provides this via `host.provideService`. + * Tool extensions resolve their per-call backend from it (injected at + * activation by `host-bin`). + */ +export const execBackendHandle = defineService<ExecBackendResolver>("exec-backend/resolver"); diff --git a/packages/exec-backend/tsconfig.json b/packages/exec-backend/tsconfig.json new file mode 100644 index 0000000..ff99a43 --- /dev/null +++ b/packages/exec-backend/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] +} diff --git a/packages/kernel/src/runtime/dispatch.ts b/packages/kernel/src/runtime/dispatch.ts index e0be1b4..01f0043 100644 --- a/packages/kernel/src/runtime/dispatch.ts +++ b/packages/kernel/src/runtime/dispatch.ts @@ -18,6 +18,7 @@ export async function executeToolCall( turnId: string, toolSpan?: Span, cwd?: string, + computerId?: string, ): Promise<ToolResult> { if (tool === undefined) { return { content: `Unknown tool: ${call.name}`, isError: true }; @@ -34,6 +35,7 @@ export async function executeToolCall( log: toolSpan?.log ?? createNoopLogger(), conversationId, ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), }; // Race the tool's execute promise against the abort signal so a tool // that hangs (ignores ctx.signal, or blocks on something the signal @@ -74,6 +76,7 @@ export function createStepDispatcher( turnId: string, toolSpans: Map<string, Span>, cwd?: string, + computerId?: string, ): StepDispatcher { let activeCount = 0; let unsafeRunning = false; @@ -112,6 +115,7 @@ export function createStepDispatcher( turnId, tcSpan, cwd, + computerId, ); activeCount--; if (entry.tool?.concurrencySafe === false) unsafeRunning = false; diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts index 0d4c59d..08d3055 100644 --- a/packages/kernel/src/runtime/run-turn.test.ts +++ b/packages/kernel/src/runtime/run-turn.test.ts @@ -835,6 +835,71 @@ describe("runTurn", () => { expect(capturedCwd).toBeUndefined(); }); + it("forwards computerId from RunTurnInput to ToolExecuteContext", async () => { + let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("computercheck", async (_input, ctx) => { + capturedComputerId = ctx.computerId; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + computerId: "ssh-host-alias", + }); + + expect(capturedComputerId).toBe("ssh-host-alias"); + }); + + it("forwards undefined computerId when RunTurnInput has no computerId", async () => { + let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("computercheck", async (_input, ctx) => { + capturedComputerId = ctx.computerId; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedComputerId).toBeUndefined(); + }); + it("aggregates usage across multiple steps", async () => { const provider = createFakeProvider([ [ diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts index f5d80d3..940c77f 100644 --- a/packages/kernel/src/runtime/run-turn.ts +++ b/packages/kernel/src/runtime/run-turn.ts @@ -117,6 +117,7 @@ interface StepContext { readonly turnSpan: Span | undefined; readonly toolSpans: Map<string, Span>; readonly cwd: string | undefined; + readonly computerId: string | undefined; readonly now: (() => number) | undefined; /** Per-turn provider options (model, systemPrompt, …) threaded to stream(). */ readonly providerOpts: ProviderStreamOptions | undefined; @@ -295,6 +296,7 @@ async function executeStep(ctx: StepContext): Promise<StepResult> { ctx.turnId, ctx.toolSpans, ctx.cwd, + ctx.computerId, ); const timing: TimingState = { @@ -522,6 +524,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> { turnSpan, toolSpans, cwd: input.cwd, + computerId: input.computerId, now, providerOpts: input.providerOpts, }); diff --git a/packages/wire/src/index.test.ts b/packages/wire/src/index.test.ts new file mode 100644 index 0000000..cd297b7 --- /dev/null +++ b/packages/wire/src/index.test.ts @@ -0,0 +1,59 @@ +/** + * Conformance test for the wire ABI's type-only surface. The wire package ships + * no runtime, so these tests assert that the public shapes COMPILE and round-trip + * — a `Computer` literal satisfies its type, `ComputerEntry` extends `Computer`, + * and a `Workspace` carries the new `defaultComputerId`. The `ComputerEntry → + * Computer` assignment is a genuine compile-time check (it would fail to typecheck + * if the `extends` relationship broke); the runtime assertions are sanity echo. + */ + +import { describe, expect, it } from "vitest"; +import type { Computer, ComputerEntry, Workspace } from "./index.js"; + +describe("@dispatch/wire — Computer / Workspace shapes", () => { + it("a Computer literal satisfies the Computer type", () => { + const c: Computer = { + alias: "myserver", + hostName: "myserver.example.com", + port: 22, + user: "deploy", + identityFile: null, + knownHost: true, + }; + expect(c.alias).toBe("myserver"); + expect(c.port).toBe(22); + expect(c.identityFile).toBeNull(); + expect(c.knownHost).toBe(true); + }); + + it("ComputerEntry extends Computer and carries usageCount", () => { + const entry: ComputerEntry = { + alias: "buildbox", + hostName: "buildbox", + port: 2222, + user: "root", + identityFile: "/home/u/.ssh/id_ed25519", + knownHost: false, + usageCount: 3, + }; + // Compile-time proof that ComputerEntry is assignable to Computer. + const asComputer: Computer = entry; + expect(asComputer.alias).toBe("buildbox"); + expect(entry.usageCount).toBe(3); + }); + + it("a Workspace carries defaultComputerId (null = local)", () => { + const remote: Workspace = { + id: "default", + title: "Default", + defaultCwd: null, + defaultComputerId: "myserver", + createdAt: 0, + lastActivityAt: 0, + }; + expect(remote.defaultComputerId).toBe("myserver"); + + const local: Workspace = { ...remote, defaultComputerId: null }; + expect(local.defaultComputerId).toBeNull(); + }); +}); diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index bade977..f6a95cf 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -570,6 +570,14 @@ export interface Workspace { readonly title: string; /** The workspace's default cwd, or `null` (fall through to server default). */ readonly defaultCwd: string | null; + /** + * The workspace's default computer — an SSH config `Host` alias that + * conversations in this workspace inherit when they set no `computerId` of + * their own. `null` means local (no SSH; today's behavior). The computer + * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer` + * (per-conv `computerId` → this → `null`/local). + */ + readonly defaultComputerId: string | null; /** Epoch-ms when the workspace was first created. */ readonly createdAt: number; /** Epoch-ms of the most recent conversation activity in this workspace. */ @@ -584,3 +592,44 @@ export interface WorkspaceEntry extends Workspace { /** Number of conversations assigned to this workspace. */ readonly conversationCount: number; } + +// ─── Computers ─────────────────────────────────────────────────────────────── + +/** + * A read-only view of a remote computer discovered from the system's + * `~/.ssh/config` — a "computer" is a `Host` alias, NOT an editable entity + * (there is no Computer CRUD store). To add a computer, the user adds a `Host` + * block to `~/.ssh/config`; Dispatch discovers it on the next `listComputers()` + * read. Every field below is resolved from the config (first-match-wins for + * `HostName`/`User`/`Port`/`IdentityFile`). + * + * `alias` is the `computerId` users select — the string persisted per + * conversation and per workspace (the computer analog of `cwd`). `knownHost` + * drives the frontend "known/new" indicator and is read-only. + */ +export interface Computer { + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** Resolved `HostName`/IP from the config (falls back to the alias itself). */ + readonly hostName: string; + /** Resolved port (config `Port`, default 22). */ + readonly port: number; + /** Resolved user (config `User`, default the current user). */ + readonly user: string; + /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */ + readonly identityFile: string | null; + /** + * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously + * connected). Drives the frontend "known/new" indicator. Read-only. + */ + readonly knownHost: boolean; +} + +/** + * A computer entry in the list response (`GET /computers`) — a `Computer` plus + * a usage count. Parallel to `WorkspaceEntry`. + */ +export interface ComputerEntry extends Computer { + /** Number of conversations/workspaces whose `computerId` resolves to this alias. */ + readonly usageCount: number; +} @@ -5,7 +5,36 @@ > Keep this lean and current; do not let it re-accrete a step-by-step changelog. ## Status (current) -`tsc -b` EXIT 0 · biome clean · **1537 vitest** green. +`tsc -b` EXIT 0 · biome clean · **1549 vitest** green. (worktree `feature/ssh-support`; +baseline re-verified after `bun install`.) + +## SSH support — transparent remote execution (IN PROGRESS) +Plan: `notes/ssh-support-plan.md` (decisions locked in §0.5/§13). Orchestrated in +waves (ORCHESTRATOR.md §2a — pre-author the contract seam, then parallel +owner-agents on disjoint packages). +- [x] **Wave 0** (orchestrator): kernel contract seam — `computerId` on + `ToolExecuteContext` + `RunTurnInput` (additive optional; backward + compatible). `tsc -b` EXIT 0. +- [x] **Wave 1** (parallel): `wire` (Computer/defaultComputerId types) + + `exec-backend` (NEW pkg: ExecBackend contract + LocalExecBackend + handle + + resolver) + `kernel` runtime (thread computerId through dispatch/run-turn) + + `conversation-store` (contract fan-out: defaultComputerId + getEffectiveComputer + + per-conv computerId get/set/clear). `tsc -b` EXIT 0, biome clean, **1592 vitest** + (was 1549, +43). +- [ ] **Wave 2** (parallel): refactor `tool-shell`/`read-file`/`write-file`/ + `edit-file` behind `ExecBackend` (local-only still). +- [ ] **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). +- [ ] **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. +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 +silently dropped on remote turns; edit_file works w/o diagnostics remotely. + ## Per-edit LSP diagnostics auto-append (DONE) After a successful `edit_file`, the extension now calls LSP `getDiagnostics` on the diff --git a/tsconfig.json b/tsconfig.json index 3dea4a1..7fda111 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,6 +41,9 @@ "path": "./packages/credential-store" }, { + "path": "./packages/exec-backend" + }, + { "path": "./packages/conversation-store" }, { |
