summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/conversation-store/package.json3
-rw-r--r--packages/conversation-store/src/index.ts7
-rw-r--r--packages/conversation-store/src/keys.ts4
-rw-r--r--packages/conversation-store/src/store-workspace.test.ts322
-rw-r--r--packages/conversation-store/src/store.test.ts4
-rw-r--r--packages/conversation-store/src/store.ts409
-rw-r--r--packages/kernel/src/contracts/conversation.ts2
-rw-r--r--packages/kernel/src/contracts/index.ts2
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts64
-rw-r--r--packages/wire/package.json2
-rw-r--r--packages/wire/src/index.ts40
12 files changed, 857 insertions, 4 deletions
diff --git a/packages/conversation-store/package.json b/packages/conversation-store/package.json
index 6fe0468..ff6cd22 100644
--- a/packages/conversation-store/package.json
+++ b/packages/conversation-store/package.json
@@ -6,6 +6,7 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
- "@dispatch/kernel": "workspace:*"
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/wire": "workspace:*"
}
}
diff --git a/packages/conversation-store/src/index.ts b/packages/conversation-store/src/index.ts
index dbd1bf1..9e78b94 100644
--- a/packages/conversation-store/src/index.ts
+++ b/packages/conversation-store/src/index.ts
@@ -3,4 +3,9 @@ export { extension, manifest } from "./extension.js";
export type { ReconcileReport, ReconcileResult } from "./reconcile.js";
export { reconcile, reconcileWithReport } from "./reconcile.js";
export type { ConversationStore } from "./store.js";
-export { conversationStoreHandle, createConversationStore, extractTitle } from "./store.js";
+export {
+ conversationStoreHandle,
+ createConversationStore,
+ extractTitle,
+ isValidWorkspaceSlug,
+} from "./store.js";
diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts
index 18c683f..98bd5d4 100644
--- a/packages/conversation-store/src/keys.ts
+++ b/packages/conversation-store/src/keys.ts
@@ -62,4 +62,8 @@ export function metaKey(conversationId: string): string {
return `conv:${conversationId}:meta`;
}
+export function workspaceKey(workspaceId: string): string {
+ return `workspace:${workspaceId}`;
+}
+
export const CONVERSATION_INDEX_KEY = "conv-index";
diff --git a/packages/conversation-store/src/store-workspace.test.ts b/packages/conversation-store/src/store-workspace.test.ts
new file mode 100644
index 0000000..077cd9c
--- /dev/null
+++ b/packages/conversation-store/src/store-workspace.test.ts
@@ -0,0 +1,322 @@
+import type { ChatMessage, StorageNamespace } from "@dispatch/kernel";
+import { beforeEach, describe, expect, it } from "vitest";
+import { createConversationStore, isValidWorkspaceSlug } from "./store.js";
+
+function createMemoryStorage(): StorageNamespace {
+ const data = new Map<string, string>();
+ return {
+ get: async (key) => data.get(key) ?? null,
+ set: async (key, value) => {
+ data.set(key, value);
+ },
+ delete: async (key) => {
+ data.delete(key);
+ },
+ has: async (key) => data.has(key),
+ keys: async (prefix) => {
+ const all = [...data.keys()];
+ if (!prefix) return all;
+ return all.filter((k) => k.startsWith(prefix));
+ },
+ };
+}
+
+describe("WorkspaceStore", () => {
+ let storage: StorageNamespace;
+ let clock: number;
+
+ beforeEach(() => {
+ storage = createMemoryStorage();
+ clock = 1000;
+ });
+
+ function makeStore() {
+ return createConversationStore(storage, undefined, () => clock);
+ }
+
+ function userMessage(text: string): ChatMessage {
+ return { role: "user", chunks: [{ type: "text", text }] };
+ }
+
+ it("ensureWorkspace creates with defaults", async () => {
+ const store = makeStore();
+ clock = 1000;
+ const ws = await store.ensureWorkspace("my-work");
+ expect(ws).toEqual({
+ id: "my-work",
+ title: "my-work",
+ defaultCwd: null,
+ createdAt: 1000,
+ lastActivityAt: 1000,
+ });
+ });
+
+ it("ensureWorkspace returns existing as-is", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("my-work");
+ clock = 2000;
+ const ws = await store.ensureWorkspace("my-work", {
+ title: "New Title",
+ defaultCwd: "/ignored",
+ });
+ expect(ws).toEqual({
+ id: "my-work",
+ title: "my-work",
+ defaultCwd: null,
+ createdAt: 1000,
+ lastActivityAt: 1000,
+ });
+ });
+
+ it("ensureWorkspace with custom title/defaultCwd", async () => {
+ const store = makeStore();
+ clock = 3000;
+ const ws = await store.ensureWorkspace("my-work", {
+ title: "Custom",
+ defaultCwd: "/projects/dispatch",
+ });
+ expect(ws).toEqual({
+ id: "my-work",
+ title: "Custom",
+ defaultCwd: "/projects/dispatch",
+ createdAt: 3000,
+ lastActivityAt: 3000,
+ });
+ });
+
+ it("getWorkspace synthesizes default", async () => {
+ const store = makeStore();
+ const ws = await store.getWorkspace("default");
+ expect(ws).toEqual({
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ });
+ });
+
+ it("getWorkspace returns null for unknown", async () => {
+ const store = makeStore();
+ expect(await store.getWorkspace("unknown")).toBeNull();
+ });
+
+ it("setWorkspaceTitle renames", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("my-work");
+ clock = 2000;
+ const ws = await store.setWorkspaceTitle("my-work", "Renamed");
+ expect(ws).toEqual({
+ id: "my-work",
+ title: "Renamed",
+ defaultCwd: null,
+ createdAt: 1000,
+ lastActivityAt: 1000,
+ });
+ });
+
+ it("setWorkspaceDefaultCwd sets and clears", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("my-work");
+ clock = 2000;
+ const setWs = await store.setWorkspaceDefaultCwd("my-work", "/some/path");
+ expect(setWs.defaultCwd).toBe("/some/path");
+ expect(setWs.lastActivityAt).toBe(1000); // does not bump on defaultCwd change
+ const cleared = await store.setWorkspaceDefaultCwd("my-work", null);
+ expect(cleared.defaultCwd).toBeNull();
+ });
+
+ it("deleteWorkspace closes conversations", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("work-a");
+ await store.setWorkspaceId("conv1", "work-a");
+ await store.setWorkspaceId("conv2", "work-a");
+ await store.setWorkspaceId("conv3", "default");
+
+ clock = 2000;
+ await store.append("conv1", [userMessage("hi 1")]);
+ await store.append("conv2", [userMessage("hi 2")]);
+ await store.append("conv3", [userMessage("hi 3")]);
+
+ const result = await store.deleteWorkspace("work-a");
+ expect(result.closedCount).toBe(2);
+
+ const meta1 = await store.getConversationMeta("conv1");
+ expect(meta1?.status).toBe("closed");
+ expect(meta1?.workspaceId).toBe("default");
+
+ const meta2 = await store.getConversationMeta("conv2");
+ expect(meta2?.status).toBe("closed");
+ expect(meta2?.workspaceId).toBe("default");
+
+ const meta3 = await store.getConversationMeta("conv3");
+ expect(meta3?.status).toBe("idle");
+ expect(meta3?.workspaceId).toBe("default");
+
+ expect(await store.getWorkspace("work-a")).toBeNull();
+ });
+
+ it("deleteWorkspace throws for default", async () => {
+ const store = makeStore();
+ await expect(store.deleteWorkspace("default")).rejects.toThrow();
+ });
+
+ it("listWorkspaces sorted by lastActivityAt desc", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("alpha");
+ clock = 2000;
+ await store.ensureWorkspace("beta");
+ clock = 3000;
+ await store.ensureWorkspace("gamma");
+
+ const list = await store.listWorkspaces();
+ expect(list.map((w) => w.id)).toEqual(["gamma", "beta", "alpha", "default"]);
+ });
+
+ it("listWorkspaces includes conversationCount", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("work-a");
+ await store.ensureWorkspace("work-b");
+ await store.setWorkspaceId("a1", "work-a");
+ await store.setWorkspaceId("a2", "work-a");
+ await store.setWorkspaceId("b1", "work-b");
+ await store.append("lonely", [userMessage("hi")]); // defaults to "default"
+
+ const list = await store.listWorkspaces();
+ const counts = Object.fromEntries(list.map((w) => [w.id, w.conversationCount]));
+ expect(counts["work-a"]).toBe(2);
+ expect(counts["work-b"]).toBe(1);
+ expect(counts.default).toBe(1);
+ });
+
+ it("listWorkspaces always includes default", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("only");
+ // No append or explicit default creation — default is synthesized.
+ const list = await store.listWorkspaces();
+ const ids = list.map((w) => w.id);
+ expect(ids).toContain("default");
+ const defaultWs = list.find((w) => w.id === "default");
+ expect(defaultWs).toEqual({
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
+ });
+
+ it("getWorkspaceId returns default for legacy", async () => {
+ const store = makeStore();
+ await store.append("conv1", [userMessage("hi")]);
+ expect(await store.getWorkspaceId("conv1")).toBe("default");
+ expect(await store.getWorkspaceId("never-seen")).toBe("default");
+ });
+
+ it("setWorkspaceId persists and reads back", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.setWorkspaceId("conv1", "my-work");
+ expect(await store.getWorkspaceId("conv1")).toBe("my-work");
+ const meta = await store.getConversationMeta("conv1");
+ expect(meta?.workspaceId).toBe("my-work");
+ expect(meta?.status).toBe("idle");
+ });
+
+ it("getEffectiveCwd explicit conversation", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" });
+ await store.setWorkspaceId("conv1", "my-work");
+ await store.setCwd("conv1", "/explicit/path");
+ expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path");
+ });
+
+ it("getEffectiveCwd inherits workspace default", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" });
+ await store.setWorkspaceId("conv1", "my-work");
+ expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default");
+ });
+
+ it("getEffectiveCwd returns null when nothing set", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work");
+ await store.setWorkspaceId("conv1", "my-work");
+ expect(await store.getEffectiveCwd("conv1")).toBeNull();
+ });
+
+ it("listConversations filtered by workspaceId", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("work-a");
+ await store.ensureWorkspace("work-b");
+ await store.append("a1", [userMessage("a1")]);
+ await store.append("a2", [userMessage("a2")]);
+ await store.append("b1", [userMessage("b1")]);
+ await store.setWorkspaceId("a1", "work-a");
+ await store.setWorkspaceId("a2", "work-a");
+ await store.setWorkspaceId("b1", "work-b");
+
+ const aConvs = await store.listConversations({ workspaceId: "work-a" });
+ expect(aConvs.map((c) => c.id).sort()).toEqual(["a1", "a2"]);
+
+ const bConvs = await store.listConversations({ workspaceId: "work-b" });
+ expect(bConvs.map((c) => c.id)).toEqual(["b1"]);
+ });
+
+ it("append updates workspace lastActivityAt", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("my-work");
+ clock = 2000;
+ await store.setWorkspaceId("conv1", "my-work");
+ clock = 3000;
+ await store.append("conv1", [userMessage("hi")]);
+ const ws = await store.getWorkspace("my-work");
+ expect(ws?.lastActivityAt).toBe(3000);
+ });
+
+ it("forkHistory copies workspaceId", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work");
+ await store.setWorkspaceId("source", "my-work");
+ await store.append("source", [userMessage("hello")]);
+ await store.forkHistory("source", "target");
+ const targetMeta = await store.getConversationMeta("target");
+ expect(targetMeta?.workspaceId).toBe("my-work");
+ });
+
+ it("replaceHistory preserves workspaceId", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work");
+ await store.setWorkspaceId("conv1", "my-work");
+ await store.append("conv1", [userMessage("original")]);
+ await store.replaceHistory("conv1", [userMessage("replaced")]);
+ const meta = await store.getConversationMeta("conv1");
+ expect(meta?.workspaceId).toBe("my-work");
+ });
+});
+
+describe("isValidWorkspaceSlug", () => {
+ it("accepts valid slugs", () => {
+ expect(isValidWorkspaceSlug("my-work")).toBe(true);
+ expect(isValidWorkspaceSlug("default")).toBe(true);
+ expect(isValidWorkspaceSlug("a1b2")).toBe(true);
+ });
+
+ it("rejects invalid slugs", () => {
+ expect(isValidWorkspaceSlug("My-Work")).toBe(false);
+ expect(isValidWorkspaceSlug("-leading")).toBe(false);
+ expect(isValidWorkspaceSlug("trailing-")).toBe(false);
+ expect(isValidWorkspaceSlug("")).toBe(false);
+ expect(isValidWorkspaceSlug("a".repeat(41))).toBe(false);
+ expect(isValidWorkspaceSlug("has space")).toBe(false);
+ });
+});
diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts
index fee7de5..c91bc40 100644
--- a/packages/conversation-store/src/store.test.ts
+++ b/packages/conversation-store/src/store.test.ts
@@ -1025,6 +1025,7 @@ describe("ConversationStore conversation metadata + list + title", () => {
lastActivityAt: 12345,
title: "my title",
status: "idle",
+ workspaceId: "default",
});
});
@@ -1042,6 +1043,7 @@ describe("ConversationStore conversation metadata + list + title", () => {
lastActivityAt: 7777,
title: "hello",
status: "idle",
+ workspaceId: "default",
});
});
@@ -1072,6 +1074,7 @@ describe("ConversationStore conversation metadata + list + title", () => {
lastActivityAt: 5000,
title: "preset title",
status: "idle",
+ workspaceId: "default",
});
// And the new conversation is discoverable in the index.
const list = await store.listConversations();
@@ -1175,6 +1178,7 @@ describe("ConversationStore conversation metadata + list + title", () => {
lastActivityAt: 1000,
title: "persisted",
status: "idle",
+ workspaceId: "default",
});
const list = await store2.listConversations();
expect(list).toHaveLength(1);
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts
index 8c287ae..263275b 100644
--- a/packages/conversation-store/src/store.ts
+++ b/packages/conversation-store/src/store.ts
@@ -11,6 +11,7 @@ import type {
TurnMetrics,
} from "@dispatch/kernel";
import { defineService } from "@dispatch/kernel";
+import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
import {
CONVERSATION_INDEX_KEY,
chunkKey,
@@ -24,6 +25,7 @@ import {
parseSeq,
reasoningEffortKey,
seqKey,
+ workspaceKey,
} from "./keys.js";
import { reconcileWithReport } from "./reconcile.js";
@@ -75,6 +77,7 @@ export interface ConversationStore {
*/
readonly listConversations: (filter?: {
readonly status?: readonly ConversationStatus[];
+ readonly workspaceId?: string;
}) => Promise<readonly ConversationMeta[]>;
/** Single conversation metadata, or null if unknown. */
readonly getConversationMeta: (conversationId: string) => Promise<ConversationMeta | null>;
@@ -114,6 +117,57 @@ export interface ConversationStore {
* the archive conversation that holds the pre-compaction history.
*/
readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise<void>;
+ /**
+ * Returns the workspace, or synthesizes `"default"` if `id === "default"`
+ * and it was never persisted (title `"default"`, defaultCwd `null`,
+ * timestamps `0`). Returns `null` for any other non-existent id.
+ */
+ readonly getWorkspace: (id: string) => Promise<Workspace | null>;
+ /**
+ * Create-on-miss: if absent, create with `title = opts.title ?? id`,
+ * `defaultCwd = opts.defaultCwd ?? null`, `createdAt/lastActivityAt = now`.
+ * If present, return as-is (ignore `opts`). The `"default"` workspace is
+ * always returned as-is (never re-created). This is the `PUT
+ * /workspaces/:id` handler.
+ */
+ readonly ensureWorkspace: (
+ id: string,
+ opts?: { readonly title?: string; readonly defaultCwd?: 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>;
+ /**
+ * 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
+ * === "default"`.
+ */
+ readonly deleteWorkspace: (id: string) => Promise<{ closedCount: number }>;
+ /**
+ * All workspaces sorted by `lastActivityAt` descending. Each entry includes
+ * `conversationCount`. Always includes `"default"` (synthesized if not
+ * persisted, with the count of legacy/unassigned conversations).
+ */
+ readonly listWorkspaces: () => Promise<readonly WorkspaceEntry[]>;
+ /**
+ * Returns the conversation's workspaceId, or `"default"` if the
+ * conversation has no workspaceId persisted (or doesn't exist).
+ */
+ readonly getWorkspaceId: (conversationId: string) => Promise<string>;
+ /**
+ * Persist the conversation's workspace assignment. If the conversation
+ * doesn't exist yet, create a minimal metadata row (like
+ * `setConversationStatus` does).
+ */
+ readonly setWorkspaceId: (conversationId: string, workspaceId: string) => Promise<void>;
+ /**
+ * Resolve the effective cwd: explicit conversation cwd (`getCwd`) →
+ * workspace `defaultCwd` (`getWorkspaceId` + `getWorkspace`) → `null` (null
+ * = use server default). Returns `null` when neither is set.
+ */
+ readonly getEffectiveCwd: (conversationId: string) => Promise<string | null>;
}
export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store");
@@ -160,6 +214,22 @@ interface ConversationMetaRow {
readonly title: string;
readonly status: ConversationStatus;
readonly compactedFrom?: string;
+ /**
+ * The workspace this conversation belongs to. Absent on legacy rows
+ * (read as `"default"`). Persisted only when explicitly assigned.
+ */
+ readonly workspaceId?: string;
+}
+
+/**
+ * The persisted shape of a `Workspace` (JSON at `workspaceKey(id)`). The `id`
+ * is the key, so it is not duplicated in the row.
+ */
+interface WorkspaceRow {
+ readonly title: string;
+ readonly defaultCwd: string | null;
+ readonly createdAt: number;
+ readonly lastActivityAt: number;
}
/** Maximum title length (in characters) before truncation with an ellipsis. */
@@ -215,6 +285,7 @@ function parseMetaRow(raw: string): ConversationMetaRow | null {
title: row.title,
status,
...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}),
+ ...(row.workspaceId !== undefined ? { workspaceId: row.workspaceId } : {}),
};
}
@@ -225,10 +296,64 @@ function toMeta(id: string, row: ConversationMetaRow): ConversationMeta {
lastActivityAt: row.lastActivityAt,
title: row.title,
status: row.status,
+ workspaceId: row.workspaceId ?? "default",
...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}),
};
}
+/**
+ * Validate a workspace slug: 1–40 chars, lowercase alphanumeric + internal
+ * hyphens (must start and end alphanumeric). The transport layer calls this
+ * to validate before hitting the store. Pure (input → boolean).
+ */
+export function isValidWorkspaceSlug(id: string): boolean {
+ return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(id);
+}
+
+/** The always-present, non-deletable default workspace id. */
+const DEFAULT_WORKSPACE_ID = "default";
+
+/**
+ * Parse a persisted {@link WorkspaceRow}, returning `null` on any parse /
+ * shape failure so callers can treat a corrupt row as missing.
+ */
+function parseWorkspaceRow(raw: string): WorkspaceRow | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ typeof (parsed as WorkspaceRow).title !== "string" ||
+ typeof (parsed as WorkspaceRow).createdAt !== "number" ||
+ typeof (parsed as WorkspaceRow).lastActivityAt !== "number"
+ ) {
+ return 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;
+ return {
+ title: row.title,
+ defaultCwd,
+ createdAt: row.createdAt,
+ lastActivityAt: row.lastActivityAt,
+ };
+}
+
+function toWorkspace(id: string, row: WorkspaceRow): Workspace {
+ return {
+ id,
+ title: row.title,
+ defaultCwd: row.defaultCwd,
+ createdAt: row.createdAt,
+ lastActivityAt: row.lastActivityAt,
+ };
+}
+
export function createConversationStore(
storage: StorageNamespace,
logger?: Logger,
@@ -259,6 +384,36 @@ export function createConversationStore(
await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(ids));
}
+ /**
+ * Read a persisted {@link WorkspaceRow} by id, or `null` if absent/corrupt.
+ */
+ async function readWorkspaceRow(id: string): Promise<WorkspaceRow | null> {
+ const raw = await storage.get(workspaceKey(id));
+ if (raw === null) return null;
+ return parseWorkspaceRow(raw);
+ }
+
+ /**
+ * Bump a workspace's `lastActivityAt` to `ts`. Creates the workspace row on
+ * miss (with `title = id`, `defaultCwd = null`, `createdAt/lastActivityAt
+ * = ts`) so that the first activity in any workspace — including the
+ * synthesized `"default"` — is recorded. Does NOT touch `title` or
+ * `defaultCwd` on an existing row.
+ */
+ async function bumpWorkspaceLastActivityAt(workspaceId: string, ts: number): Promise<void> {
+ const existing = await readWorkspaceRow(workspaceId);
+ const row: WorkspaceRow =
+ existing === null
+ ? { title: workspaceId, defaultCwd: null, createdAt: ts, lastActivityAt: ts }
+ : {
+ title: existing.title,
+ defaultCwd: existing.defaultCwd,
+ createdAt: existing.createdAt,
+ lastActivityAt: ts,
+ };
+ await storage.set(workspaceKey(workspaceId), JSON.stringify(row));
+ }
+
return {
async append(conversationId, messages) {
const raw = await storage.get(seqKey(conversationId));
@@ -286,6 +441,7 @@ export function createConversationStore(
// Metadata upsert: track createdAt/lastActivityAt/title and keep the
// conversation discoverable in the index.
const ts = now();
+ let conversationWorkspaceId = DEFAULT_WORKSPACE_ID;
const metaRaw = await storage.get(metaKey(conversationId));
if (metaRaw === null) {
const row: ConversationMetaRow = {
@@ -309,6 +465,7 @@ export function createConversationStore(
await storage.set(metaKey(conversationId), JSON.stringify(row));
await ensureInIndex(conversationId);
} else {
+ conversationWorkspaceId = existing.workspaceId ?? DEFAULT_WORKSPACE_ID;
const title =
existing.title === "Untitled" || existing.title === ""
? extractTitle(messages)
@@ -318,10 +475,16 @@ export function createConversationStore(
lastActivityAt: ts,
title,
status: existing.status,
+ ...(existing.compactedFrom !== undefined
+ ? { compactedFrom: existing.compactedFrom }
+ : {}),
+ ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}),
};
await storage.set(metaKey(conversationId), JSON.stringify(row));
}
}
+ // Bump the owning workspace's lastActivityAt to this append's time.
+ await bumpWorkspaceLastActivityAt(conversationWorkspaceId, ts);
},
async load(conversationId) {
@@ -462,6 +625,7 @@ export function createConversationStore(
}
const statusFilter = filter?.status;
+ const workspaceFilter = filter?.workspaceId;
const metas: ConversationMeta[] = [];
for (const id of ids) {
const metaRaw = await storage.get(metaKey(id));
@@ -469,6 +633,10 @@ export function createConversationStore(
const row = parseMetaRow(metaRaw);
if (row === null) continue;
if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue;
+ if (workspaceFilter !== undefined) {
+ const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID;
+ if (wsId !== workspaceFilter) continue;
+ }
metas.push(toMeta(id, row));
}
// Sort by lastActivityAt descending (most recent first). Stable sort
@@ -518,6 +686,8 @@ export function createConversationStore(
lastActivityAt: existing.lastActivityAt,
title,
status: existing.status,
+ ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}),
+ ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}),
};
await storage.set(metaKey(conversationId), JSON.stringify(row));
},
@@ -562,6 +732,8 @@ export function createConversationStore(
lastActivityAt: existing.lastActivityAt,
title: existing.title,
status,
+ ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}),
+ ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}),
};
await storage.set(metaKey(conversationId), JSON.stringify(row));
},
@@ -607,6 +779,7 @@ export function createConversationStore(
...(existing.compactedFrom !== undefined
? { compactedFrom: existing.compactedFrom }
: {}),
+ ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}),
};
await storage.set(metaKey(targetId), JSON.stringify(row));
}
@@ -649,5 +822,241 @@ export function createConversationStore(
JSON.stringify({ ...row, compactedFrom: newConversationId }),
);
},
+
+ async getWorkspace(id) {
+ 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).
+ if (id === DEFAULT_WORKSPACE_ID) {
+ return {
+ id: DEFAULT_WORKSPACE_ID,
+ title: DEFAULT_WORKSPACE_ID,
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ }
+ return null;
+ },
+
+ async ensureWorkspace(id, opts) {
+ const existing = await readWorkspaceRow(id);
+ if (existing !== null) return toWorkspace(id, existing);
+ // Absent — create with defaults. The synthesized "default" is also
+ // materialized here when first explicitly ensured.
+ const ts = now();
+ const row: WorkspaceRow = {
+ title: opts?.title ?? id,
+ defaultCwd: opts?.defaultCwd ?? null,
+ createdAt: ts,
+ lastActivityAt: ts,
+ };
+ await storage.set(workspaceKey(id), JSON.stringify(row));
+ return toWorkspace(id, row);
+ },
+
+ async setWorkspaceTitle(id, title) {
+ const existing = await readWorkspaceRow(id);
+ const ts = now();
+ const base =
+ existing === null
+ ? {
+ title: id,
+ defaultCwd: null as string | null,
+ createdAt: ts,
+ lastActivityAt: ts,
+ }
+ : existing;
+ const row: WorkspaceRow = {
+ title,
+ defaultCwd: base.defaultCwd,
+ createdAt: base.createdAt,
+ lastActivityAt: base.lastActivityAt,
+ };
+ await storage.set(workspaceKey(id), JSON.stringify(row));
+ return toWorkspace(id, row);
+ },
+
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ const existing = await readWorkspaceRow(id);
+ const ts = now();
+ const base =
+ existing === null
+ ? {
+ title: id,
+ defaultCwd: null as string | null,
+ createdAt: ts,
+ lastActivityAt: ts,
+ }
+ : existing;
+ const row: WorkspaceRow = {
+ title: base.title,
+ defaultCwd,
+ createdAt: base.createdAt,
+ lastActivityAt: base.lastActivityAt,
+ };
+ await storage.set(workspaceKey(id), JSON.stringify(row));
+ return toWorkspace(id, row);
+ },
+
+ async deleteWorkspace(id) {
+ if (id === DEFAULT_WORKSPACE_ID) {
+ throw new Error('The "default" workspace cannot be deleted.');
+ }
+ // (1) Find all conversations with workspaceId === id, (2) set each
+ // to status "closed" and reassign workspaceId to "default".
+ let closedCount = 0;
+ const indexRaw = await storage.get(CONVERSATION_INDEX_KEY);
+ if (indexRaw !== null) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(indexRaw);
+ } catch {
+ parsed = [];
+ }
+ const ids = Array.isArray(parsed)
+ ? (parsed.filter((v) => typeof v === "string") as string[])
+ : [];
+ for (const convId of ids) {
+ const metaRaw = await storage.get(metaKey(convId));
+ if (metaRaw === null) continue;
+ const row = parseMetaRow(metaRaw);
+ if (row === null) continue;
+ const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID;
+ if (wsId !== id) continue;
+ const updated: ConversationMetaRow = {
+ createdAt: row.createdAt,
+ lastActivityAt: row.lastActivityAt,
+ title: row.title,
+ status: "closed",
+ ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}),
+ workspaceId: DEFAULT_WORKSPACE_ID,
+ };
+ await storage.set(metaKey(convId), JSON.stringify(updated));
+ closedCount++;
+ }
+ }
+ // (3) Delete the workspace entity.
+ await storage.delete(workspaceKey(id));
+ return { closedCount };
+ },
+
+ async listWorkspaces() {
+ // Collect persisted workspace rows via the `workspace:` key prefix.
+ const wsPrefix = "workspace:";
+ const wsKeys = await storage.keys(wsPrefix);
+ const byId = new Map<string, Workspace>();
+ for (const key of wsKeys) {
+ // Key shape: `workspace:<id>`. Strip the prefix to recover the id.
+ const id = key.slice(wsPrefix.length);
+ if (id.length === 0) continue;
+ const raw = await storage.get(key);
+ if (raw === null) continue;
+ const row = parseWorkspaceRow(raw);
+ if (row === null) continue;
+ byId.set(id, toWorkspace(id, row));
+ }
+ // Always include "default" (synthesized if not persisted).
+ if (!byId.has(DEFAULT_WORKSPACE_ID)) {
+ byId.set(DEFAULT_WORKSPACE_ID, {
+ id: DEFAULT_WORKSPACE_ID,
+ title: DEFAULT_WORKSPACE_ID,
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ });
+ }
+ // Count conversations per workspace by scanning the index + meta.
+ const counts = new Map<string, number>();
+ for (const id of byId.keys()) counts.set(id, 0);
+ const indexRaw = await storage.get(CONVERSATION_INDEX_KEY);
+ if (indexRaw !== null) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(indexRaw);
+ } catch {
+ parsed = [];
+ }
+ const ids = Array.isArray(parsed)
+ ? (parsed.filter((v) => typeof v === "string") as string[])
+ : [];
+ for (const convId of ids) {
+ const metaRaw = await storage.get(metaKey(convId));
+ if (metaRaw === null) continue;
+ const row = parseMetaRow(metaRaw);
+ if (row === null) continue;
+ const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID;
+ counts.set(wsId, (counts.get(wsId) ?? 0) + 1);
+ }
+ }
+ const entries: WorkspaceEntry[] = [];
+ for (const [id, ws] of byId) {
+ entries.push({ ...ws, conversationCount: counts.get(id) ?? 0 });
+ }
+ // Sort by lastActivityAt descending (most recent first). Stable sort
+ // keeps insertion order for ties.
+ return entries.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
+ },
+
+ async getWorkspaceId(conversationId) {
+ const raw = await storage.get(metaKey(conversationId));
+ if (raw === null) return DEFAULT_WORKSPACE_ID;
+ const row = parseMetaRow(raw);
+ if (row === null) return DEFAULT_WORKSPACE_ID;
+ return row.workspaceId ?? DEFAULT_WORKSPACE_ID;
+ },
+
+ async setWorkspaceId(conversationId, workspaceId) {
+ const ts = now();
+ const raw = await storage.get(metaKey(conversationId));
+ if (raw === null) {
+ // Conversation doesn't exist yet — create a minimal metadata row
+ // (like setConversationStatus does), with the workspace assigned.
+ const row: ConversationMetaRow = {
+ createdAt: ts,
+ lastActivityAt: ts,
+ title: "Untitled",
+ status: "idle",
+ workspaceId,
+ };
+ await storage.set(metaKey(conversationId), JSON.stringify(row));
+ await ensureInIndex(conversationId);
+ return;
+ }
+ const existing = parseMetaRow(raw);
+ if (existing === null) {
+ const row: ConversationMetaRow = {
+ createdAt: ts,
+ lastActivityAt: ts,
+ title: "Untitled",
+ status: "idle",
+ workspaceId,
+ };
+ await storage.set(metaKey(conversationId), JSON.stringify(row));
+ await ensureInIndex(conversationId);
+ return;
+ }
+ const row: ConversationMetaRow = {
+ createdAt: existing.createdAt,
+ lastActivityAt: existing.lastActivityAt,
+ title: existing.title,
+ status: existing.status,
+ ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}),
+ workspaceId,
+ };
+ await storage.set(metaKey(conversationId), JSON.stringify(row));
+ },
+
+ async getEffectiveCwd(conversationId) {
+ // Explicit per-conversation cwd wins.
+ const explicit = await storage.get(cwdKey(conversationId));
+ if (explicit !== null) return explicit;
+ // Otherwise fall through to the workspace's defaultCwd.
+ const workspaceId = await this.getWorkspaceId(conversationId);
+ const workspace = await this.getWorkspace(workspaceId);
+ if (workspace === null) return null;
+ return workspace.defaultCwd;
+ },
};
}
diff --git a/packages/kernel/src/contracts/conversation.ts b/packages/kernel/src/contracts/conversation.ts
index 009d295..b459532 100644
--- a/packages/kernel/src/contracts/conversation.ts
+++ b/packages/kernel/src/contracts/conversation.ts
@@ -23,4 +23,6 @@ export type {
ToolResultChunk,
TurnId,
TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
diff --git a/packages/kernel/src/contracts/index.ts b/packages/kernel/src/contracts/index.ts
index 65bf910..c67607b 100644
--- a/packages/kernel/src/contracts/index.ts
+++ b/packages/kernel/src/contracts/index.ts
@@ -30,6 +30,8 @@ export type {
ToolResultChunk,
TurnId,
TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "./conversation.js";
export type { ToolDispatchPolicy } from "./dispatch.js";
export type {
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index b79b3b3..1923e9f 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/transport-contract",
- "version": "0.15.0",
+ "version": "0.16.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 02b48a0..583f986 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -28,6 +28,8 @@ import type {
ReasoningEffort,
StoredChunk,
TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
export type {
@@ -40,6 +42,8 @@ export type {
StepMetrics,
StoredChunk,
TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
/**
@@ -80,6 +84,13 @@ export interface ChatRequest {
* unrecognized value → HTTP 400 `{ error }`.
*/
readonly reasoningEffort?: ReasoningEffort;
+
+ /**
+ * The workspace to assign this conversation to. Omit for `"default"`.
+ * If the workspace doesn't exist yet, it is auto-created (title = id,
+ * defaultCwd = null).
+ */
+ readonly workspaceId?: string;
}
/**
@@ -289,6 +300,11 @@ export interface CloseConversationResponse {
*/
export interface QueueRequest {
readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
}
/**
@@ -474,6 +490,11 @@ export interface ChatQueueMessage {
readonly type: "chat.queue";
readonly conversationId: string;
readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
}
/**
@@ -606,3 +627,46 @@ export interface CompactPercentResponse {
export interface SetCompactPercentRequest {
readonly threshold: number;
}
+
+// ─── Workspaces ───────────────────────────────────────────────────────────────
+
+/**
+ * Body of `PUT /workspaces/:id` — the idempotent create-on-miss call. All
+ * fields are optional and only applied when the workspace is first created;
+ * an existing workspace is returned as-is.
+ */
+export interface EnsureWorkspaceRequest {
+ /** Display title. Default: the workspace id. Only used on create. */
+ readonly title?: string;
+ /** Default cwd. Default: null (inherit server default). Only used on create. */
+ readonly defaultCwd?: string | null;
+}
+
+/** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */
+export interface WorkspaceResponse extends Workspace {}
+
+/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */
+export interface WorkspaceListResponse {
+ readonly workspaces: readonly WorkspaceEntry[];
+}
+
+/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */
+export interface SetWorkspaceTitleRequest {
+ readonly title: string;
+}
+
+/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */
+export interface SetWorkspaceDefaultCwdRequest {
+ readonly defaultCwd: string | null;
+}
+
+/**
+ * Response of `DELETE /workspaces/:id`. All conversations in the workspace
+ * are closed (status → "closed") and reassigned to "default", then the
+ * workspace entity is deleted. `"default"` is non-deletable (HTTP 409).
+ */
+export interface DeleteWorkspaceResponse {
+ readonly workspaceId: string;
+ /** Conversations that were closed (status → "closed") by this delete. */
+ readonly closedCount: number;
+}
diff --git a/packages/wire/package.json b/packages/wire/package.json
index e375c8f..027c0b3 100644
--- a/packages/wire/package.json
+++ b/packages/wire/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/wire",
- "version": "0.11.0",
+ "version": "0.12.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts
index 4ab8825..bade977 100644
--- a/packages/wire/src/index.ts
+++ b/packages/wire/src/index.ts
@@ -522,6 +522,12 @@ export interface ConversationMeta {
readonly title: string;
readonly status: ConversationStatus;
/**
+ * The workspace this conversation belongs to. Always present; reads as
+ * `"default"` for legacy conversations that were never explicitly assigned.
+ * Conversations created with no `workspaceId` default to `"default"`.
+ */
+ readonly workspaceId: string;
+ /**
* Set on a compacted conversation: points to the archive conversation ID
* that holds the full pre-compaction history. Absent on conversations
* that have never been compacted.
@@ -544,3 +550,37 @@ export interface CompactionResult {
readonly messagesSummarized: number;
readonly messagesKept: number;
}
+
+// ─── Workspaces ──────────────────────────────────────────────────────────────
+
+/**
+ * A named, URL-driven grouping of conversations that owns a default cwd.
+ * Every conversation belongs to exactly one workspace; conversations that
+ * haven't set their own per-conversation cwd inherit `defaultCwd`.
+ *
+ * Workspaces are backend-owned (so cross-device just works): the workspace
+ * entity and each conversation's `workspaceId` live server-side. The
+ * `"default"` workspace is always present and non-deletable; conversations
+ * created with no `workspaceId` are assigned to `"default"`.
+ */
+export interface Workspace {
+ /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */
+ readonly id: string;
+ /** Display title (editable). Defaults to `id` on creation. */
+ readonly title: string;
+ /** The workspace's default cwd, or `null` (fall through to server default). */
+ readonly defaultCwd: string | null;
+ /** Epoch-ms when the workspace was first created. */
+ readonly createdAt: number;
+ /** Epoch-ms of the most recent conversation activity in this workspace. */
+ readonly lastActivityAt: number;
+}
+
+/**
+ * A workspace entry in the list response (`GET /workspaces`) — a `Workspace`
+ * plus a conversation count.
+ */
+export interface WorkspaceEntry extends Workspace {
+ /** Number of conversations assigned to this workspace. */
+ readonly conversationCount: number;
+}