diff options
| author | Adam Malczewski <[email protected]> | 2026-06-23 03:27:01 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-23 03:27:01 +0900 |
| commit | b346a50c3757b3df6086f11b995847144a5e07e8 (patch) | |
| tree | 0119ff2a63e3ef9e9efb0190daeb20707e1c5f7a /packages/transport-http/src | |
| parent | 6d7b3923b40eb4baf3cefadfde236de646990713 (diff) | |
| download | dispatch-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.ts | 508 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 186 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 4 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 20 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 2 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 25 |
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; + }, }; } |
