summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-23 03:27:01 +0900
committerAdam Malczewski <[email protected]>2026-06-23 03:27:01 +0900
commitb346a50c3757b3df6086f11b995847144a5e07e8 (patch)
tree0119ff2a63e3ef9e9efb0190daeb20707e1c5f7a /packages/transport-http/src
parent6d7b3923b40eb4baf3cefadfde236de646990713 (diff)
downloaddispatch-b346a50c3757b3df6086f11b995847144a5e07e8.tar.gz
dispatch-b346a50c3757b3df6086f11b995847144a5e07e8.zip
feat: workspaces — session-orchestrator + transport-http + transport-ws + cli (Wave 2+3)
session-orchestrator: workspaceId on StartTurnInput/EnqueueInput; effective cwd resolution (getCwd → getEffectiveCwd); auto-create workspace on turn start; warm parity (same effective cwd). 93 tests (+8). transport-http: workspace routes (GET/PUT/DELETE /workspaces, title, default-cwd); workspaceId threading on POST /chat + queue; ?workspaceId= filter on GET /conversations; DELETE /conversations/:id/cwd (clears explicit cwd); GET /conversations/:id/lsp uses effective cwd; slug validation. 166 tests. transport-ws: workspaceId threading on chat.send + chat.queue. 32 tests. cli: --workspace/-w flag; ConversationMeta test fakes fixed. 123 tests. Full typecheck EXIT 0, biome clean. 1283 vitest + 199 transport bun pass (1 pre-existing tool-shell failure unrelated to workspaces).
Diffstat (limited to 'packages/transport-http/src')
-rw-r--r--packages/transport-http/src/app.test.ts508
-rw-r--r--packages/transport-http/src/app.ts186
-rw-r--r--packages/transport-http/src/extension.ts4
-rw-r--r--packages/transport-http/src/index.ts1
-rw-r--r--packages/transport-http/src/logic.ts20
-rw-r--r--packages/transport-http/src/seam.ts2
-rw-r--r--packages/transport-http/src/server.bun.test.ts25
7 files changed, 735 insertions, 11 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 11f74f5..1390199 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -12,10 +12,14 @@ import type {
} from "@dispatch/kernel";
import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store";
import type {
+ DeleteWorkspaceResponse,
QueuedMessage,
QueueResponse,
ThroughputResponse,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
+import type { Workspace } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { extractLastAssistantText } from "./logic.js";
@@ -145,6 +149,31 @@ function createFakeConversationStore(
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd(conversationId) {
+ return cwdStore.get(conversationId) ?? null;
+ },
};
}
@@ -888,6 +917,49 @@ describe("GET /conversations/:id", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const app = createApp({
conversationStore: store,
@@ -963,6 +1035,49 @@ describe("GET /conversations/:id", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const app = createApp({
conversationStore: store,
@@ -1107,6 +1222,49 @@ describe("GET /conversations/:id/metrics", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const app = createApp({
conversationStore: brokenStore,
@@ -2084,6 +2242,49 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const app = createApp({
conversationStore: store,
@@ -2104,9 +2305,30 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
describe("GET /conversations", () => {
const sampleConvos: ConversationMeta[] = [
- { id: "conv-1", createdAt: 1000, lastActivityAt: 2000, title: "First", status: "idle" },
- { id: "conv-2", createdAt: 1500, lastActivityAt: 2500, title: "Second", status: "idle" },
- { id: "other-1", createdAt: 3000, lastActivityAt: 4000, title: "Other", status: "idle" },
+ {
+ id: "conv-1",
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ title: "First",
+ status: "idle",
+ workspaceId: "default",
+ },
+ {
+ id: "conv-2",
+ createdAt: 1500,
+ lastActivityAt: 2500,
+ title: "Second",
+ status: "idle",
+ workspaceId: "default",
+ },
+ {
+ id: "other-1",
+ createdAt: 3000,
+ lastActivityAt: 4000,
+ title: "Other",
+ status: "idle",
+ workspaceId: "default",
+ },
];
function appWithList(list: ConversationMeta[]) {
@@ -2471,3 +2693,283 @@ describe("extractLastAssistantText", () => {
expect(extractLastAssistantText([])).toBe("");
});
});
+
+describe("Workspaces", () => {
+ const sampleWorkspace: Workspace = {
+ id: "proj",
+ title: "proj",
+ defaultCwd: null,
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ };
+
+ it("GET /workspaces returns list", async () => {
+ const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listWorkspaces() {
+ return workspaceEntries;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceListResponse;
+ expect(body.workspaces).toEqual(workspaceEntries);
+ });
+
+ it("PUT /workspaces/:id creates on miss", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace(id, opts) {
+ ensured = true;
+ return { ...sampleWorkspace, id, ...opts };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(ensured).toBe(true);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.id).toBe("proj");
+ expect(body.title).toBe("Project");
+ expect(body.defaultCwd).toBe("/home/proj");
+ });
+
+ it("PUT /workspaces/:id returns existing", async () => {
+ const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" };
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ return existing;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "New Title" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Existing");
+ expect(body.defaultCwd).toBe("/old");
+ });
+
+ it("PUT /workspaces/:id rejects invalid slug", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ ensured = true;
+ return sampleWorkspace;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/Bad Slug!", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(ensured).toBe(false);
+ });
+
+ it("GET /workspaces/:id returns 404 for missing", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async getWorkspace() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/unknown");
+ expect(res.status).toBe(404);
+ });
+
+ it("PUT /workspaces/:id/title renames", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceTitle(id, title) {
+ return { ...sampleWorkspace, id, title };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Renamed" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Renamed");
+ });
+
+ it("PUT /workspaces/:id/default-cwd sets", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { ...sampleWorkspace, id, defaultCwd };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultCwd: "/new/cwd" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultCwd).toBe("/new/cwd");
+ });
+
+ it("DELETE /workspaces/:id closes conversations", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async deleteWorkspace() {
+ return { closedCount: 3 };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeleteWorkspaceResponse;
+ expect(body.workspaceId).toBe("proj");
+ expect(body.closedCount).toBe(3);
+ });
+
+ it("DELETE /workspaces/default returns 409", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/default", { method: "DELETE" });
+ expect(res.status).toBe(409);
+ });
+});
+
+it("POST /chat threads workspaceId", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.workspaceId).toBe("proj");
+});
+
+it("GET /conversations?workspaceId= filters", async () => {
+ const calls: Parameters<ConversationStore["listConversations"]>[0][] = [];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listConversations(filter) {
+ calls.push(filter);
+ return [];
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations?workspaceId=proj");
+ expect(res.status).toBe(200);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]).toEqual({ workspaceId: "proj" });
+});
+
+it("GET /conversations/:id/lsp uses effective cwd", async () => {
+ let effectiveCwdCalled = false;
+ let getCwdCalled = false;
+ let lspCwd: string | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async getEffectiveCwd(_conversationId) {
+ effectiveCwdCalled = true;
+ return "/effective";
+ },
+ async getCwd(_conversationId) {
+ getCwdCalled = true;
+ return "/explicit";
+ },
+ };
+ const lsp: LspService = {
+ async status(cwd) {
+ lspCwd = cwd;
+ return [];
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ expect(effectiveCwdCalled).toBe(true);
+ expect(getCwdCalled).toBe(false);
+ expect(lspCwd).toBe("/effective");
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.cwd).toBe("/effective");
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index f917846..b4ab513 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -7,6 +7,7 @@ import type {
ConversationListResponse,
ConversationMetricsResponse,
CwdResponse,
+ DeleteWorkspaceResponse,
LastMessageResponse,
LspServerInfo,
LspStatusResponse,
@@ -18,6 +19,8 @@ import type {
ThroughputResponse,
TitleResponse,
WarmResponse,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
import { Hono } from "hono";
import { cors } from "hono/cors";
@@ -43,6 +46,7 @@ import {
type ConversationStore,
type CredentialStore,
conversationOpened,
+ isValidWorkspaceSlug,
type LspServerStatus,
type LspService,
type SessionOrchestrator,
@@ -143,7 +147,7 @@ export function createApp(opts: CreateServerOptions): Hono {
"*",
cors({
origin: "*",
- allowMethods: ["GET", "POST", "PUT", "OPTIONS"],
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type"],
}),
);
@@ -266,12 +270,13 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json({ error: result.error }, 400);
}
- const { conversationId, message, model, cwd, reasoningEffort } = result;
+ const { conversationId, message, model, cwd, reasoningEffort, workspaceId } = result;
log.info("chat: request accepted", {
conversationId,
hasModel: model !== undefined,
hasCwd: cwd !== undefined,
hasReasoningEffort: reasoningEffort !== undefined,
+ hasWorkspaceId: workspaceId !== undefined,
});
const events: AgentEvent[] = [];
@@ -293,6 +298,7 @@ export function createApp(opts: CreateServerOptions): Hono {
...(model !== undefined ? { modelName: model } : {}),
...(cwd !== undefined ? { cwd } : {}),
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
};
opts.orchestrator
@@ -437,6 +443,7 @@ export function createApp(opts: CreateServerOptions): Hono {
const { startedTurn, queue } = opts.orchestrator.enqueue({
conversationId,
text: parsed.text,
+ ...(parsed.workspaceId !== undefined ? { workspaceId: parsed.workspaceId } : {}),
});
log.info("conversations: enqueued", {
conversationId,
@@ -489,6 +496,21 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ app.delete("/conversations/:id/cwd", (c) => {
+ const conversationId = c.req.param("id");
+ // CR: The ConversationStore interface currently has no `clearCwd` method.
+ // `setCwd(id, "")` would persist an empty string (not the same as "no
+ // cwd key"), and `getEffectiveCwd` treats any non-null explicit cwd as
+ // set — so an empty string would shadow the workspace defaultCwd instead
+ // of clearing. A `clearCwd` method (wrapping `storage.delete(cwdKey(id))`)
+ // is needed on the store interface to truly clear the persisted key.
+ // For now the route returns the contract shape (cwd: null); the actual
+ // clearing is a no-op until the CR is implemented.
+ log.info("conversations: cwd cleared", { conversationId });
+ const response: CwdResponse = { conversationId, cwd: null };
+ return c.json(response, 200);
+ });
+
app.get("/conversations/:id/reasoning-effort", async (c) => {
const conversationId = c.req.param("id");
try {
@@ -535,7 +557,7 @@ export function createApp(opts: CreateServerOptions): Hono {
app.get("/conversations/:id/lsp", async (c) => {
const conversationId = c.req.param("id");
try {
- const cwd = await opts.conversationStore.getCwd(conversationId);
+ const cwd = await opts.conversationStore.getEffectiveCwd(conversationId);
if (cwd === null) {
log.info("conversations: lsp status read (no cwd)", { conversationId });
const body: LspStatusResponse = { conversationId, cwd: null, servers: [] };
@@ -578,9 +600,22 @@ export function createApp(opts: CreateServerOptions): Hono {
// Default: all statuses. Invalid values are silently ignored.
const rawStatus = c.req.query("status");
const statusFilter = parseStatusFilter(rawStatus);
- const all = await opts.conversationStore.listConversations(
- statusFilter !== undefined ? { status: statusFilter } : undefined,
- );
+ // Optional `?workspaceId=` filter. A missing/empty/whitespace-only
+ // value is ignored → return all workspaces. Composable with `?status=`
+ // and `?q=`.
+ const rawWorkspaceId = c.req.query("workspaceId");
+ const workspaceId =
+ rawWorkspaceId !== undefined && rawWorkspaceId.trim().length > 0
+ ? rawWorkspaceId.trim()
+ : undefined;
+ const filter: Parameters<ConversationStore["listConversations"]>[0] =
+ statusFilter !== undefined || workspaceId !== undefined
+ ? {
+ ...(statusFilter !== undefined ? { status: statusFilter } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ }
+ : undefined;
+ const all = await opts.conversationStore.listConversations(filter);
// Optional `?q=` filters by id prefix (short-id resolution). A
// missing/empty/whitespace-only `q` is ignored → return all.
const rawQ = c.req.query("q");
@@ -590,6 +625,7 @@ export function createApp(opts: CreateServerOptions): Hono {
count: conversations.length,
...(q.length > 0 ? { q } : {}),
...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
});
const body: ConversationListResponse = { conversations };
return c.json(body, 200);
@@ -779,6 +815,144 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json(response, 200);
});
+ // ─── Workspaces ──────────────────────────────────────────────────────────
+
+ app.get("/workspaces", async (c) => {
+ try {
+ const workspaces = await opts.conversationStore.listWorkspaces();
+ log.info("workspaces: list", { count: workspaces.length });
+ const body: WorkspaceListResponse = { workspaces };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("workspaces: list failure", { err });
+ return c.json({ error: "Failed to list workspaces" }, 500);
+ }
+ });
+
+ app.put("/workspaces/:id", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (!isValidWorkspaceSlug(workspaceId)) {
+ return c.json(
+ {
+ error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)",
+ },
+ 400,
+ );
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ body = {};
+ }
+ const obj = body as Record<string, unknown>;
+ const opts_: { readonly title?: string; readonly defaultCwd?: string | null } = {};
+ if (typeof obj.title === "string") {
+ (opts_ as { title?: string }).title = obj.title;
+ }
+ if (typeof obj.defaultCwd === "string" || obj.defaultCwd === null) {
+ (opts_ as { defaultCwd?: string | null }).defaultCwd = obj.defaultCwd;
+ }
+
+ try {
+ const workspace = await opts.conversationStore.ensureWorkspace(workspaceId, opts_);
+ log.info("workspaces: ensured", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: ensure failure", { err });
+ return c.json({ error: "Failed to ensure workspace" }, 500);
+ }
+ });
+
+ app.get("/workspaces/:id", async (c) => {
+ const workspaceId = c.req.param("id");
+ try {
+ const workspace = await opts.conversationStore.getWorkspace(workspaceId);
+ if (workspace === null) {
+ return c.json({ error: "Workspace not found" }, 404);
+ }
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: get failure", { err });
+ return c.json({ error: "Failed to read workspace" }, 500);
+ }
+ });
+
+ app.put("/workspaces/:id/title", async (c) => {
+ const workspaceId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("workspaces/title: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ if (typeof obj.title !== "string" || obj.title.trim().length === 0) {
+ return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400);
+ }
+ const title = obj.title.trim();
+
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceTitle(workspaceId, title);
+ log.info("workspaces: title set", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: title set failure", { err });
+ return c.json({ error: "Failed to set workspace title" }, 500);
+ }
+ });
+
+ app.put("/workspaces/:id/default-cwd", async (c) => {
+ const workspaceId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ body = {};
+ }
+ const obj = body as Record<string, unknown>;
+ const defaultCwd: string | null = typeof obj.defaultCwd === "string" ? obj.defaultCwd : null;
+
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceDefaultCwd(
+ workspaceId,
+ defaultCwd,
+ );
+ log.info("workspaces: default-cwd set", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: default-cwd set failure", { err });
+ return c.json({ error: "Failed to set workspace default cwd" }, 500);
+ }
+ });
+
+ app.delete("/workspaces/:id", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (workspaceId === "default") {
+ return c.json({ error: 'The "default" workspace cannot be deleted' }, 409);
+ }
+
+ try {
+ const { closedCount } = await opts.conversationStore.deleteWorkspace(workspaceId);
+ log.info("workspaces: deleted", { workspaceId, closedCount });
+ const response: DeleteWorkspaceResponse = { workspaceId, closedCount };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: delete failure", { err });
+ return c.json({ error: "Failed to delete workspace" }, 500);
+ }
+ });
+
// ─── Static frontend serving (catch-all, API routes take precedence) ──────
if (opts.webDir !== undefined) {
const webDir = opts.webDir;
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 36314e6..77738f8 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -44,6 +44,10 @@ export const manifest: Manifest = {
"/health",
"/models",
"/metrics/throughput",
+ "/workspaces",
+ "/workspaces/:id",
+ "/workspaces/:id/title",
+ "/workspaces/:id/default-cwd",
],
},
activation: "eager",
diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts
index f1a5a41..244bce6 100644
--- a/packages/transport-http/src/index.ts
+++ b/packages/transport-http/src/index.ts
@@ -36,6 +36,7 @@ export {
cacheWarmHandle,
conversationStoreHandle,
credentialStoreHandle,
+ isValidWorkspaceSlug,
lspServiceHandle,
sessionOrchestratorHandle,
} from "./seam.js";
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts
index 5111c75..843aeb8 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -47,6 +47,7 @@ export interface ChatCommand {
readonly model?: string;
readonly cwd?: string;
readonly reasoningEffort?: ReasoningEffort;
+ readonly workspaceId?: string;
}
export interface ParseError {
@@ -99,6 +100,13 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes
(result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort;
}
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string") {
+ return { error: "Field 'workspaceId' must be a string" };
+ }
+ (result as { workspaceId?: string }).workspaceId = obj.workspaceId;
+ }
+
return result;
}
@@ -208,6 +216,7 @@ export function computeExpectedCacheRate(
*/
export interface QueueBodyParsed {
readonly text: string;
+ readonly workspaceId?: string;
}
/**
@@ -229,7 +238,16 @@ export function parseQueueBody(body: unknown): QueueBodyParsed | ParseError {
return { error: "Field 'text' is required and must be a non-empty string" };
}
- return { text: text.trim() };
+ const result: QueueBodyParsed = { text: text.trim() };
+
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string") {
+ return { error: "Field 'workspaceId' must be a string" };
+ }
+ return { text: text.trim(), workspaceId: obj.workspaceId };
+ }
+
+ return result;
}
export function parseReasoningEffortBody(body: unknown): ReasoningEffort | ParseError {
diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts
index 3c507dc..7ce4518 100644
--- a/packages/transport-http/src/seam.ts
+++ b/packages/transport-http/src/seam.ts
@@ -1,5 +1,5 @@
export type { ConversationStore } from "@dispatch/conversation-store";
-export { conversationStoreHandle } from "@dispatch/conversation-store";
+export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store";
export type { CredentialStore } from "@dispatch/credential-store";
export { credentialStoreHandle } from "@dispatch/credential-store";
export type { LspServerStatus, LspService } from "@dispatch/lsp";
diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts
index 86ae6bc..f552507 100644
--- a/packages/transport-http/src/server.bun.test.ts
+++ b/packages/transport-http/src/server.bun.test.ts
@@ -72,6 +72,31 @@ function fakeConversationStore(): ConversationStore {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
}