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 /packages/conversation-store/src | |
| 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.
Diffstat (limited to 'packages/conversation-store/src')
| -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 |
3 files changed, 403 insertions, 4 deletions
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; + }, }; } |
