summaryrefslogtreecommitdiffhomepage
path: root/backend-handoff-workspaces-reply.md
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:52:06 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:52:06 +0900
commiteff289b2b4cc41db706f3c3274a089d46012be87 (patch)
tree3f5de169708f89a38f913954da2a351cb24575da /backend-handoff-workspaces-reply.md
parentc95cc77b658edd072785d3ac93856de3ab9ad2ec (diff)
downloaddispatch-web-eff289b2b4cc41db706f3c3274a089d46012be87.tar.gz
dispatch-web-eff289b2b4cc41db706f3c3274a089d46012be87.zip
chore: regenerate contract mirrors, bump deps, update docs/notes/scripts
- Regenerate .dispatch/{wire,transport-contract}.reference.md mirrors - Bump deps (package.json, bun.lock) - Update AGENTS.md, GLOSSARY.md, README.md, ROADMAP.md - Add workspaces backend-handoff exchange + notes/assumptions-log.md - Add scripts/fix-dist-perms.sh (root-owned dist/ from Docker build) - Add scripts/live-probe-provider-retry.ts
Diffstat (limited to 'backend-handoff-workspaces-reply.md')
-rw-r--r--backend-handoff-workspaces-reply.md216
1 files changed, 216 insertions, 0 deletions
diff --git a/backend-handoff-workspaces-reply.md b/backend-handoff-workspaces-reply.md
new file mode 100644
index 0000000..2ddfaf5
--- /dev/null
+++ b/backend-handoff-workspaces-reply.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/[email protected]`
+
+```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/[email protected]`
+
+### 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`.