summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 12:22:41 +0900
committerAdam Malczewski <[email protected]>2026-06-25 12:22:41 +0900
commit54db4583e66134010375a1fa94256f36034ffdff (patch)
treeec0bcd395d365741ed18e160f9b5842233051ba2 /packages/conversation-store/src
parent0b154bdad4f75a091db3ca46424abd17fbbc23ff (diff)
downloaddispatch-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.ts4
-rw-r--r--packages/conversation-store/src/store-workspace.test.ts241
-rw-r--r--packages/conversation-store/src/store.ts162
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;
+ },
};
}