From 6d7b3923b40eb4baf3cefadfde236de646990713 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 23 Jun 2026 02:35:26 +0900 Subject: feat: workspaces contract + conversation-store implementation (Wave 0+1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire 0.12.0: Workspace, WorkspaceEntry, ConversationMeta.workspaceId Transport-contract 0.16.0: workspaceId on ChatRequest/QueueRequest/ChatQueueMessage; workspace endpoint types (EnsureWorkspaceRequest, WorkspaceResponse, etc.) Kernel: re-export Workspace/WorkspaceEntry from contracts Conversation-store: workspace persistence + service methods (getWorkspace, ensureWorkspace, setWorkspaceTitle, setWorkspaceDefaultCwd, deleteWorkspace, listWorkspaces, getWorkspaceId, setWorkspaceId, getEffectiveCwd, isValidWorkspaceSlug); listConversations filter by workspaceId; forkHistory/replaceHistory preserve workspaceId. 111 tests pass. FE handoff: frontend-workspaces-handoff.md (courier doc) 18 typecheck errors in session-orchestrator/transport-http/cli test fakes (expected fan-out — fixed in Wave 2+3). --- bun.lock | 5 +- frontend-workspaces-handoff.md | 216 +++++++++++ packages/conversation-store/package.json | 3 +- packages/conversation-store/src/index.ts | 7 +- packages/conversation-store/src/keys.ts | 4 + .../conversation-store/src/store-workspace.test.ts | 322 ++++++++++++++++ packages/conversation-store/src/store.test.ts | 4 + packages/conversation-store/src/store.ts | 409 +++++++++++++++++++++ packages/kernel/src/contracts/conversation.ts | 2 + packages/kernel/src/contracts/index.ts | 2 + packages/transport-contract/package.json | 2 +- packages/transport-contract/src/index.ts | 64 ++++ packages/wire/package.json | 2 +- packages/wire/src/index.ts | 40 ++ tasks.md | 17 + 15 files changed, 1093 insertions(+), 6 deletions(-) create mode 100644 frontend-workspaces-handoff.md create mode 100644 packages/conversation-store/src/store-workspace.test.ts diff --git a/bun.lock b/bun.lock index 3d66f23..1dbb882 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "version": "0.0.0", "dependencies": { "@dispatch/kernel": "workspace:*", + "@dispatch/wire": "workspace:*", }, }, "packages/credential-store": { @@ -259,7 +260,7 @@ }, "packages/transport-contract": { "name": "@dispatch/transport-contract", - "version": "0.12.0", + "version": "0.16.0", "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*", @@ -296,7 +297,7 @@ }, "packages/wire": { "name": "@dispatch/wire", - "version": "0.8.0", + "version": "0.12.0", }, }, "packages": { diff --git a/frontend-workspaces-handoff.md b/frontend-workspaces-handoff.md new file mode 100644 index 0000000..2ddfaf5 --- /dev/null +++ b/frontend-workspaces-handoff.md @@ -0,0 +1,216 @@ +# Backend handoff — Workspaces (backend → FE) — courier doc + +> **From:** arch-rewrite orchestrator · **To:** dispatch-web orchestrator · **Courier:** the user. +> Response to `backend-handoff-workspaces.md`. This doc finalizes the contract shapes +> the backend will implement. The FE should re-pin `@dispatch/wire` and +> `@dispatch/transport-contract` `file:` deps and re-mirror any `.dispatch/*.reference.md`. + +## Version bumps + +| Package | From | To | Notes | +|---|---|---|---| +| `@dispatch/wire` | `0.11.0` | `0.12.0` | Additive: `Workspace`, `WorkspaceEntry`, `ConversationMeta.workspaceId` | +| `@dispatch/transport-contract` | `0.15.0` | `0.16.0` | Additive: workspace endpoints + `workspaceId` on chat/queue ops | +| `@dispatch/ui-contract` | `0.2.0` | `0.2.0` | **Unchanged** | + +--- + +## 1. Final types — `@dispatch/wire@0.12.0` + +```ts +/** + * 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`. + */ +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 — a `Workspace` plus a conversation count. + */ +export interface WorkspaceEntry extends Workspace { + /** Number of conversations assigned to this workspace. */ + readonly conversationCount: number; +} +``` + +`ConversationMeta` gains a required `workspaceId`: + +```ts +export interface ConversationMeta { + readonly id: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; + readonly status: ConversationStatus; + /** Always present; "default" for legacy/unspecified conversations. */ + readonly workspaceId: string; + readonly compactedFrom?: string; +} +``` + +--- + +## 2. Final types — `@dispatch/transport-contract@0.16.0` + +### Additive fields on existing request types + +```ts +export interface ChatRequest { + readonly conversationId?: string; + readonly message: string; + readonly model?: string; + readonly cwd?: string; + readonly reasoningEffort?: ReasoningEffort; + /** Workspace to assign the conversation to. Default "default". Auto-creates if missing. */ + readonly workspaceId?: string; +} + +export interface QueueRequest { + readonly text: string; + /** Default "default". Auto-creates if missing. */ + readonly workspaceId?: string; +} + +export interface ChatQueueMessage { + readonly type: "chat.queue"; + readonly conversationId: string; + readonly text: string; + /** Default "default". Auto-creates if missing. */ + readonly workspaceId?: string; +} +``` + +### Workspace endpoint types + +```ts +/** Body of `PUT /workspaces/:id` (all fields optional — the ensure/create call). */ +export interface EnsureWorkspaceRequest { + /** Display title. Default: the workspace id. Only used on create; ignored if workspace exists. */ + 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`. */ +export interface SetWorkspaceTitleRequest { + readonly title: string; +} + +/** Body of `PUT /workspaces/:id/default-cwd`. null/absent = clear to server default. */ +export interface SetWorkspaceDefaultCwdRequest { + readonly defaultCwd: string | null; +} + +/** Response of `DELETE /workspaces/:id`. */ +export interface DeleteWorkspaceResponse { + readonly workspaceId: string; + /** Conversations that were closed (status → "closed") by this delete. */ + readonly closedCount: number; +} +``` + +--- + +## 3. Final endpoint list + +| Method & Path | Body | Returns | Notes | +|---|---|---|---| +| `GET /workspaces` | — | `WorkspaceListResponse` | Sorted by `lastActivityAt` desc. Includes `conversationCount`. | +| `PUT /workspaces/:id` | `EnsureWorkspaceRequest?` | `WorkspaceResponse` | **Create-on-miss** (idempotent). Creates with `title=id`, `defaultCwd=null` if missing. Returns existing as-is if present. Slug validated. | +| `GET /workspaces/:id` | — | `WorkspaceResponse` | Pure read. 404 if missing. | +| `PUT /workspaces/:id/title` | `SetWorkspaceTitleRequest` | `WorkspaceResponse` | Rename (display only; id unchanged). | +| `PUT /workspaces/:id/default-cwd` | `SetWorkspaceDefaultCwdRequest` | `WorkspaceResponse` | Set/clear workspace default cwd. | +| `DELETE /workspaces/:id` | — | `DeleteWorkspaceResponse` | **Closes all conversations** (status → "closed"), reassigns them to "default", then deletes the workspace. 409 for `"default"`. | +| `GET /conversations` | `?workspaceId=`, `?status=`, `?q=` | `ConversationListResponse` | Additive `?workspaceId=` filter, composable with existing filters. | +| `DELETE /conversations/:id/cwd` | — | `CwdResponse` | Clears explicit conversation cwd (returns `cwd: null`). | + +### Existing endpoints (semantic note, no type change) + +- `GET /conversations/:id/cwd` — unchanged: returns the **explicit** conversation cwd (`null` = inheriting workspace default). +- `GET /conversations/:id/lsp` — now roots LSP at the **effective** cwd; `LspStatusResponse.cwd` returns the effective cwd. + +--- + +## 4. cwd resolution (backend-owned) + +``` +effectiveCwd = conversationStore.getCwd(conversationId) // explicit per-conversation +if (effectiveCwd == null) { + workspaceId = conversationStore.getWorkspaceId(conversationId) // "default" fallback + workspace = conversationStore.getWorkspace(workspaceId) + effectiveCwd = workspace?.defaultCwd ?? null +} +if (effectiveCwd == null) effectiveCwd = serverDefaultCwd // process.cwd() today +``` + +- `GET /conversations/:id/cwd` → explicit cwd only (`null` = inherit). +- `GET /conversations/:id/lsp` → effective cwd. +- Turn start (`runTurn` / `warm`) → effective cwd. + +--- + +## 5. `DELETE /workspaces/:id` semantics + +1. Close all conversations in that workspace (set `status = "closed"`). +2. Reassign their `workspaceId` to `"default"` (so no dangling reference). +3. Delete the workspace entity. +4. Return `{ workspaceId, closedCount }`. +5. `DELETE /workspaces/default` → HTTP 409. + +Closed conversations are hidden from tab-restore (`?status=active,idle` excludes `closed`). + +--- + +## 6. Workspace lifecycle / auto-creation + +- **Auto-create on turn start:** if `workspaceId` is provided and doesn't exist, the backend auto-creates it (`title = id`, `defaultCwd = null`). +- **`PUT /workspaces/:id` create-on-miss:** if absent, creates with optional `title`/`defaultCwd` from the body (defaults: `title = id`, `defaultCwd = null`). If present, returns existing as-is. +- **Slug validation:** `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$` (1–40 chars, lowercase, digits, internal hyphens only). Reject invalid with 400. No normalization. `"default"` allowed but non-deletable. +- **`"default"` workspace:** always synthesized if not persisted; guaranteed in `GET /workspaces` list. +- **`lastActivityAt`:** updates when a conversation in the workspace appends, or on first creation. Does NOT update on title/default-cwd changes. +- **Compaction:** post-compaction conversations inherit the original's `workspaceId`. + +--- + +## 7. Answers to FE open questions (Q1–Q8) + +| # | Decision | +|---|---| +| Q1 | **Close all conversations** in the workspace (status → "closed"), reassign to "default", then delete the workspace. Return `closedCount`. | +| Q2 | **Add `DELETE /conversations/:id/cwd`** to clear explicit cwd (fall back to workspace default). `PUT` validation unchanged (empty string still 400). | +| Q3 | **Deferred to v1** — no WS lifecycle push. Fetch-on-mount + manual refresh sufficient. Can add `workspace.created/updated/deleted` later, additively. | +| Q4 | **`PUT /workspaces/:id`** is the create-on-miss entry point (idempotent, 200). `GET /workspaces/:id` is a pure read (404 if missing). | +| Q5 | Slug regex `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$`. Reject, don't normalize. `"default"` non-deletable. | +| Q6 | `Workspace` in `@dispatch/wire`. Request/response bodies in `@dispatch/transport-contract`. | +| Q7 | Confirmed — backend does nothing beyond `workspaceId` on `ConversationMeta` + `?workspaceId=` filter. | +| Q8 | Yes — post-compaction conversations inherit `workspaceId`. `forkHistory` copies it. | + +--- + +## 8. Gaps resolved (from FE handoff §3) + +1. **Unknown workspaceId on turn start** → auto-create (title = id, defaultCwd = null). Typos can be deleted. +2. **PUT /workspaces/:id initial state** → body accepts optional `title`/`defaultCwd` with defaults (`title = id`, `defaultCwd = null`). Only applied on create; existing workspace returned as-is. +3. **lastActivityAt on title/default-cwd changes** → no. +4. **LSP cwd field** → returns effective cwd. +5. **Conversation count in list** → yes, included as `WorkspaceEntry.conversationCount`. 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(); + 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; /** Single conversation metadata, or null if unknown. */ readonly getConversationMeta: (conversationId: string) => Promise; @@ -114,6 +117,57 @@ export interface ConversationStore { * the archive conversation that holds the pre-compaction history. */ readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise; + /** + * 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; + /** + * 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; + /** Rename a workspace. Creates the workspace if missing. */ + readonly setWorkspaceTitle: (id: string, title: string) => Promise; + /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ + readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise; + /** + * 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; + /** + * Returns the conversation's workspaceId, or `"default"` if the + * conversation has no workspaceId persisted (or doesn't exist). + */ + readonly getWorkspaceId: (conversationId: string) => Promise; + /** + * 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; + /** + * 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; } export const conversationStoreHandle = defineService("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 { + 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 { + 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(); + for (const key of wsKeys) { + // Key shape: `workspace:`. 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(); + 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 @@ -521,6 +521,12 @@ export interface ConversationMeta { readonly lastActivityAt: number; 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 @@ -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; +} diff --git a/tasks.md b/tasks.md index d7a992a..5bb27fd 100644 --- a/tasks.md +++ b/tasks.md @@ -530,6 +530,23 @@ conversation tab. Short-ID prefix resolution (4+ chars → full ID via `GET /con → `{content:""}`, `POST /conversations/:id/open` → `{conversationId}`. - [ ] Live-verify end-to-end (CLI → real conversation → FE tab open). +## Workspaces — FE design response (in review) +Cross-repo design ask from `../dispatch-web` (`backend-handoff-workspaces.md`). +Outbound courier: `frontend-workspaces-handoff.md` (final shapes + Q1–Q8). Status: +**contracts finalized, awaiting user approval to implement.** +- **Boundary decision:** workspaces live inside `conversation-store` (metadata + + cwd persistence owner); no new extension. Single owner-agent for all workspace + storage + service methods. +- **Versions:** `@dispatch/wire` `0.11.0→0.12.0`, `@dispatch/transport-contract` + `0.15.0→0.16.0`, `@dispatch/ui-contract` unchanged. +- **Key decisions:** `DELETE /workspaces/:id` closes all conversations (status→ + "closed") + reassigns to "default" + deletes workspace; auto-create workspace on + turn start if missing; `PUT /workspaces/:id` create-on-miss with optional + `title`/`defaultCwd`; `DELETE /conversations/:id/cwd` to clear explicit cwd; + `GET /conversations/:id/lsp` roots at effective cwd; WS lifecycle push deferred. +- **Waves:** Wave 0 (contracts, orchestrator) → Wave 1 (conversation-store) → + Wave 2 (session-orchestrator) → Wave 3 (transport-http + transport-ws + cli, parallel). + ## Open items - **`prefix.fingerprint` / `warm|real` cache-bust attributes (deferred):** decoupled from dedup by the content-addressed decision; also gated on cache-warming being -- cgit v1.2.3