# 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`.
