diff options
21 files changed, 1413 insertions, 66 deletions
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts index 992d09f..3d07c96 100644 --- a/packages/cli/src/args.test.ts +++ b/packages/cli/src/args.test.ts @@ -52,6 +52,7 @@ describe("parseArgs", () => { reasoningEffort: undefined, showReasoning: false, open: false, + workspaceId: undefined, }); }); @@ -68,6 +69,7 @@ describe("parseArgs", () => { reasoningEffort: undefined, showReasoning: false, open: false, + workspaceId: undefined, }); }); @@ -103,6 +105,7 @@ describe("parseArgs", () => { reasoningEffort: undefined, showReasoning: true, open: false, + workspaceId: undefined, }); }); @@ -119,6 +122,7 @@ describe("parseArgs", () => { reasoningEffort: "high", showReasoning: false, open: false, + workspaceId: undefined, }); }); @@ -183,6 +187,35 @@ describe("parseArgs", () => { const result = parseArgs(["m", "--text", "x", "--conversation"], { defaultServer }); expect(result.kind).toBe("error"); }); + + it("parses --workspace flag", () => { + const result = parseArgs(["m", "--text", "x", "--workspace", "my-work"], { defaultServer }); + expect(result).toEqual({ + kind: "chat", + server: "http://localhost:24203", + modelName: "m", + text: "x", + file: undefined, + cwd: undefined, + conversationId: undefined, + reasoningEffort: undefined, + showReasoning: false, + open: false, + workspaceId: "my-work", + }); + }); + + it("parses -w shorthand", () => { + const result = parseArgs(["m", "--text", "x", "-w", "ws"], { defaultServer }); + expect(result.kind).toBe("chat"); + if (result.kind === "chat") expect(result.workspaceId).toBe("ws"); + }); + + it("errors when --workspace has no value", () => { + const result = parseArgs(["m", "--text", "x", "--workspace"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--workspace requires a value"); + }); }); describe("list", () => { diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index ac5dd4a..8a63777 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -26,6 +26,7 @@ export type ParsedCommand = readonly reasoningEffort?: ReasoningEffort | undefined; readonly showReasoning: boolean; readonly open: boolean; + readonly workspaceId?: string | undefined; } | { readonly kind: "list"; @@ -46,6 +47,7 @@ export type ParsedCommand = readonly open: boolean; readonly cwd?: string; readonly reasoningEffort?: ReasoningEffort; + readonly workspaceId?: string; } | { readonly kind: "stop"; readonly server: string; readonly conversationId: string } | { readonly kind: "help" } @@ -206,6 +208,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma let open = false; let cwd: string | undefined; let reasoningEffort: ReasoningEffort | undefined; + let workspaceId: string | undefined; for (let i = 1; i < argv.length; i++) { const arg = argv[i] as string; @@ -243,6 +246,12 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma reasoningEffort = val; break; } + case "--workspace": + case "-w": + if (i + 1 >= argv.length) + return { kind: "error", message: "--workspace requires a value" }; + workspaceId = argv[++i]; + break; default: if (arg.startsWith("--")) return { kind: "error", message: `Unknown flag: ${arg}` }; if (conversationId !== undefined) @@ -267,6 +276,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma open, ...(cwd !== undefined && { cwd }), ...(reasoningEffort !== undefined && { reasoningEffort }), + ...(workspaceId !== undefined && { workspaceId }), }; } @@ -280,6 +290,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma let showReasoning = false; let open = false; let server = opts.defaultServer; + let workspaceId: string | undefined; for (let i = 1; i < argv.length; i++) { const arg = argv[i] as string; @@ -327,6 +338,11 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma reasoningEffort = val; } break; + case "--workspace": + case "-w": + if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" }; + workspaceId = argv[++i]; + break; default: return { kind: "error", message: `Unknown flag: ${arg}` }; } @@ -350,5 +366,6 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma reasoningEffort, showReasoning, open, + ...(workspaceId !== undefined && { workspaceId }), }; } diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts index 3e7befe..2aa61e9 100644 --- a/packages/cli/src/http.test.ts +++ b/packages/cli/src/http.test.ts @@ -252,7 +252,14 @@ describe("fetchConversations", () => { let calledUrl: string | undefined; const list: ConversationListResponse = { conversations: [ - { id: "abcdef1234567890", title: "first", createdAt: 1, lastActivityAt: 2, status: "idle" }, + { + id: "abcdef1234567890", + title: "first", + createdAt: 1, + lastActivityAt: 2, + status: "idle", + workspaceId: "default", + }, ], }; const fakeFetch = (async (url: string | URL | Request): Promise<Response> => { @@ -408,6 +415,7 @@ describe("resolveConversationId", () => { createdAt: 1, lastActivityAt: 2, status: "idle", + workspaceId: "default", }, ], }); @@ -440,6 +448,7 @@ describe("resolveConversationId", () => { createdAt: 1, lastActivityAt: 2, status: "idle", + workspaceId: "default", }, { id: "abcdef1234567890bbbbbbbbbbbbbbbb", @@ -447,6 +456,7 @@ describe("resolveConversationId", () => { createdAt: 1, lastActivityAt: 3, status: "idle", + workspaceId: "default", }, ], }); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 5935bab..9dfc317 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -29,8 +29,8 @@ const USAGE = `Usage: dispatch compact <conversationId> [--server <url>] dispatch read <conversationId> [--server <url>] dispatch open <conversationId> [--server <url>] - dispatch send <conversationId> --text "..." [--queue] [--open] [--cwd <dir>] [--effort <level>] [--server <url>] - dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--server <url>] [--show-reasoning] [--open] + dispatch send <conversationId> --text "..." [--queue] [--open] [--cwd <dir>] [--effort <level>] [--workspace <id>] [--server <url>] + dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--workspace <id>] [--server <url>] [--show-reasoning] [--open] dispatch --help Effort levels: low, medium, high (default), xhigh, max`; @@ -171,6 +171,7 @@ async function main(): Promise<void> { message: parsed.text, ...(parsed.cwd !== undefined && { cwd: parsed.cwd }), ...(parsed.reasoningEffort !== undefined && { reasoningEffort: parsed.reasoningEffort }), + ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }), }; const { events } = await streamChat( { fetchImpl: globalThis.fetch }, diff --git a/packages/cli/src/message.test.ts b/packages/cli/src/message.test.ts index a3f1e0b..440ec85 100644 --- a/packages/cli/src/message.test.ts +++ b/packages/cli/src/message.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { parseArgs } from "./args.js"; import { buildChatRequest, composeMessage } from "./message.js"; describe("composeMessage", () => { @@ -94,4 +95,53 @@ describe("buildChatRequest", () => { ); expect(req).not.toHaveProperty("reasoningEffort"); }); + + it("includes workspaceId when provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", workspaceId: "my-work", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req.workspaceId).toBe("my-work"); + }); + + it("omits workspaceId when not provided", () => { + const req = buildChatRequest( + { modelName: "m", text: "x", showReasoning: false }, + { cwd: "/work", message: "x" }, + ); + expect(req).not.toHaveProperty("workspaceId"); + }); +}); + +describe("workspace flag → ChatRequest", () => { + const defaultServer = "http://localhost:24203"; + + it("--workspace flag sets workspaceId on request", () => { + const parsed = parseArgs(["my-model", "--text", "hi", "--workspace", "my-work"], { + defaultServer, + }); + expect(parsed.kind).toBe("chat"); + if (parsed.kind !== "chat") return; + const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); + expect(req.workspaceId).toBe("my-work"); + }); + + it("--workspace flag omitted sends no workspaceId", () => { + const parsed = parseArgs(["my-model", "--text", "hi"], { defaultServer }); + expect(parsed.kind).toBe("chat"); + if (parsed.kind !== "chat") return; + const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); + expect(req.workspaceId).toBeUndefined(); + expect(req).not.toHaveProperty("workspaceId"); + }); + + it("-w shorthand sets workspaceId on request", () => { + const parsed = parseArgs(["my-model", "--text", "hi", "-w", "shorthand"], { + defaultServer, + }); + expect(parsed.kind).toBe("chat"); + if (parsed.kind !== "chat") return; + const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" }); + expect(req.workspaceId).toBe("shorthand"); + }); }); diff --git a/packages/cli/src/message.ts b/packages/cli/src/message.ts index 80befec..ec4d6d1 100644 --- a/packages/cli/src/message.ts +++ b/packages/cli/src/message.ts @@ -38,6 +38,7 @@ interface ChatCmd { readonly cwd?: string | undefined; readonly conversationId?: string | undefined; readonly reasoningEffort?: ReasoningEffort | undefined; + readonly workspaceId?: string | undefined; readonly showReasoning: boolean; } @@ -53,5 +54,6 @@ export function buildChatRequest(cmd: ChatCmd, ctx: BuildCtx): ChatRequest { ...(cmd.conversationId !== undefined && { conversationId: cmd.conversationId }), ...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }), ...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }), + ...(cmd.workspaceId !== undefined && { workspaceId: cmd.workspaceId }), }; } diff --git a/packages/cli/src/render.test.ts b/packages/cli/src/render.test.ts index eb89300..1c92733 100644 --- a/packages/cli/src/render.test.ts +++ b/packages/cli/src/render.test.ts @@ -230,6 +230,7 @@ describe("formatConversationList", () => { createdAt: now - ageMs - 1000, lastActivityAt: now - ageMs, status: "idle", + workspaceId: "default", }); it("returns empty string for an empty list", () => { diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index ce134f4..e778705 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -97,6 +97,31 @@ function createInMemoryStore(): ConversationStore & { async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceTitle(id, title) { + return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, }; } @@ -507,53 +532,15 @@ describe("turn-sealed event", () => { const ordering: string[] = []; const wrappedStore: ConversationStore = { + ...store, async append(conversationId, messages) { await store.append(conversationId, messages); ordering.push("append"); }, - async load(conversationId) { - return store.load(conversationId); - }, - async loadSince(conversationId, sinceSeq) { - return store.loadSince(conversationId, sinceSeq); - }, async appendMetrics(conversationId, metrics) { await store.appendMetrics(conversationId, metrics); ordering.push("appendMetrics"); }, - async loadMetrics(conversationId) { - return store.loadMetrics(conversationId); - }, - async getCwd(conversationId) { - return store.getCwd(conversationId); - }, - async setCwd(conversationId, cwd) { - await store.setCwd(conversationId, cwd); - }, - async getReasoningEffort(conversationId) { - return store.getReasoningEffort(conversationId); - }, - async setReasoningEffort(conversationId, effort) { - await store.setReasoningEffort(conversationId, effort); - }, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -627,6 +614,31 @@ describe("turn-sealed event", () => { async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceTitle(id, title) { + return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -989,6 +1001,31 @@ describe("turn metrics persistence", () => { async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceTitle(id, title) { + return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -2486,3 +2523,266 @@ describe("reasoning effort resolution", () => { expect(warmOpts?.reasoningEffort).toBe(captured[0]?.providerOpts?.reasoningEffort); }); }); + +// --- Workspace integration (workspaceId threading + effective cwd) --- + +describe("workspace integration", () => { + function waitForSealed( + orchestrator: ReturnType<typeof createSessionOrchestrator>["orchestrator"], + conversationId: string, + ): Promise<void> { + return new Promise((resolve) => { + const unsub = orchestrator.subscribe(conversationId, (e) => { + if (e.type === "turn-sealed") { + unsub(); + resolve(); + } + }); + }); + } + + it("startTurn stamps workspaceId on new conversation", async () => { + const base = createInMemoryStore(); + const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; + const store: ConversationStore = { + ...base, + async setWorkspaceId(conversationId, workspaceId) { + setWorkspaceIdCalls.push({ conversationId, workspaceId }); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: createCapturingRunTurn().captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-stamp", + text: "hi", + onEvent: () => {}, + workspaceId: "my-workspace", + }); + + expect(setWorkspaceIdCalls).toContainEqual({ + conversationId: "conv-ws-stamp", + workspaceId: "my-workspace", + }); + }); + + it("startTurn defaults workspaceId to default", async () => { + const base = createInMemoryStore(); + const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; + const store: ConversationStore = { + ...base, + async setWorkspaceId(conversationId, workspaceId) { + setWorkspaceIdCalls.push({ conversationId, workspaceId }); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: createCapturingRunTurn().captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-default", + text: "hi", + onEvent: () => {}, + }); + + expect(setWorkspaceIdCalls).toContainEqual({ + conversationId: "conv-ws-default", + workspaceId: "default", + }); + }); + + it("startTurn auto-creates workspace if missing", async () => { + const base = createInMemoryStore(); + const ensureWorkspaceCalls: string[] = []; + const store: ConversationStore = { + ...base, + async ensureWorkspace(id) { + ensureWorkspaceCalls.push(id); + return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: createCapturingRunTurn().captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-autocreate", + text: "hi", + onEvent: () => {}, + workspaceId: "brand-new-workspace", + }); + + expect(ensureWorkspaceCalls).toContain("brand-new-workspace"); + }); + + it("startTurn uses effective cwd when no explicit cwd", async () => { + const base = createInMemoryStore(); + const store: ConversationStore = { + ...base, + async getEffectiveCwd() { + return "/workspace/default/cwd"; + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-effcwd", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/workspace/default/cwd"); + }); + + it("startTurn explicit cwd overrides workspace default", async () => { + const base = createInMemoryStore(); + const store: ConversationStore = { + ...base, + async getEffectiveCwd() { + return "/workspace/default/cwd"; + }, + }; + + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-override", + text: "hi", + onEvent: () => {}, + cwd: "/explicit/cwd", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBe("/explicit/cwd"); + }); + + it("startTurn effective cwd null when nothing set", async () => { + const store = createInMemoryStore(); + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-ws-null-cwd", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.cwd).toBeUndefined(); + }); + + it("warm uses effective cwd", async () => { + const base = createInMemoryStore(); + await base.append("conv-warm-effcwd", [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + ]); + const store: ConversationStore = { + ...base, + async getEffectiveCwd() { + return "/workspace/warm/cwd"; + }, + }; + + let assemblyCwd: string | undefined = "UNSET"; + const provider: ProviderContract = { + id: "p", + stream: async function* () { + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + }, + }; + + const deps = { + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: (assembly: ToolAssembly) => { + assemblyCwd = assembly.cwd; + return Promise.resolve(assembly); + }, + runTurn, + emit: () => {}, + }; + + const { activeConversations } = createSessionOrchestrator(deps); + const warmService = createWarmService(deps, activeConversations); + + await warmService.warm("conv-warm-effcwd"); + expect(assemblyCwd).toBe("/workspace/warm/cwd"); + }); + + it("enqueue threads workspaceId", async () => { + const base = createInMemoryStore(); + const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = []; + const store: ConversationStore = { + ...base, + async setWorkspaceId(conversationId, workspaceId) { + setWorkspaceIdCalls.push({ conversationId, workspaceId }); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => ({ id: "p", stream: async function* () {} }), + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: createCapturingRunTurn().captureRunTurn, + }); + + orchestrator.enqueue({ + conversationId: "conv-enq-ws", + text: "hello", + workspaceId: "enqueued-ws", + }); + await waitForSealed(orchestrator, "conv-enq-ws"); + + expect(setWorkspaceIdCalls).toContainEqual({ + conversationId: "conv-enq-ws", + workspaceId: "enqueued-ws", + }); + }); +}); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index adf5680..54b1b40 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -36,6 +36,13 @@ export interface StartTurnInput { readonly modelName?: string; readonly cwd?: string; readonly reasoningEffort?: ReasoningEffort; + /** + * The workspace this conversation belongs to. Defaults to `"default"` when + * omitted. On the first turn for a new conversation, the workspaceId is + * persisted (the workspace is auto-created if missing) so subsequent turns + * resolve the effective cwd from the workspace's `defaultCwd`. + */ + readonly workspaceId?: string; } export type StartTurnResult = @@ -46,6 +53,8 @@ export type StartTurnResult = export interface EnqueueInput { readonly conversationId: string; readonly text: string; + /** Workspace to stamp on a new conversation. Defaults to `"default"`. */ + readonly workspaceId?: string; } /** @@ -234,6 +243,7 @@ export interface SessionOrchestrator { modelName?: string; cwd?: string; reasoningEffort?: ReasoningEffort; + workspaceId?: string; }): Promise<void>; } @@ -338,6 +348,7 @@ export function createSessionOrchestrator( modelName: string | undefined, cwd: string | undefined, reasoningEffortOverride: ReasoningEffort | undefined, + workspaceId: string, ): void { const turnId = generateTurnId(); const controller = new AbortController(); @@ -349,7 +360,7 @@ export function createSessionOrchestrator( const effectiveCwdPromise = cwd !== undefined ? Promise.resolve(cwd) - : deps.conversationStore.getCwd(conversationId).then((c) => c ?? undefined); + : deps.conversationStore.getEffectiveCwd(conversationId).then((c) => c ?? undefined); const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId); @@ -384,6 +395,15 @@ export function createSessionOrchestrator( const history = await deps.conversationStore.load(conversationId); const userMsg = buildUserMessage(text); + // New conversation: stamp the workspaceId so subsequent turns resolve + // the effective cwd from the workspace's defaultCwd. Auto-create the + // workspace if missing (idempotent). Only for new conversations (no + // history) — existing conversations keep their assigned workspace. + if (history.length === 0) { + await deps.conversationStore.ensureWorkspace(workspaceId); + await deps.conversationStore.setWorkspaceId(conversationId, workspaceId); + } + let provider: ProviderContract; let modelOverride: string | undefined; @@ -540,18 +560,29 @@ export function createSessionOrchestrator( } const orchestrator: SessionOrchestrator = { - startTurn({ conversationId, text, modelName, cwd, reasoningEffort }) { + startTurn({ conversationId, text, modelName, cwd, reasoningEffort, workspaceId }) { if (activeTurns.has(conversationId)) { return { started: false, reason: "already-active" }; } - runTurnDetached(conversationId, text, modelName, cwd, reasoningEffort); + runTurnDetached( + conversationId, + text, + modelName, + cwd, + reasoningEffort, + workspaceId ?? "default", + ); const turn = activeTurns.get(conversationId); const turnId = turn !== undefined ? turn.turnId : ""; return { started: true, turnId }; }, - enqueue({ conversationId, text }) { - const result = orchestrator.startTurn({ conversationId, text }); + enqueue({ conversationId, text, workspaceId }) { + const result = orchestrator.startTurn({ + conversationId, + text, + ...(workspaceId !== undefined ? { workspaceId } : {}), + }); if (result.started) { return { startedTurn: true, queue: [] }; } @@ -615,13 +646,22 @@ export function createSessionOrchestrator( return { abortedTurn }; }, - async handleMessage({ conversationId, text, onEvent, modelName, cwd, reasoningEffort }) { + async handleMessage({ + conversationId, + text, + onEvent, + modelName, + cwd, + reasoningEffort, + workspaceId, + }) { const turnInput: StartTurnInput = { conversationId, text, ...(modelName !== undefined ? { modelName } : {}), ...(cwd !== undefined ? { cwd } : {}), ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), }; const result = orchestrator.startTurn(turnInput); if (!result.started) { @@ -687,7 +727,8 @@ export function createWarmService( // the prompt-cache prefix — diverges and the cache misses entirely (0%). // A manual reheat sends no cwd, so without this fallback it would warm the // wrong prefix. See notes/observability-design.md §3.1. - const cwd = opts?.cwd ?? (await deps.conversationStore.getCwd(conversationId)) ?? undefined; + const cwd = + opts?.cwd ?? (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? undefined; const assembled = await deps.applyToolsFilter({ tools: baseTools, conversationId, diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index 58de4a9..71b1fb4 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -93,6 +93,31 @@ function createInMemoryStore(): ConversationStore & { async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace(id) { + return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceTitle(id, title) { + return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd(conversationId) { + return cwdData.get(conversationId) ?? null; + }, }; } 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; + }, }; } diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index b42f434..899dabb 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -278,6 +278,7 @@ export function createTransportWsExtension(): Extension { ...(result.reasoningEffort !== undefined ? { reasoningEffort: result.reasoningEffort } : {}), + ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), }); if (!startResult.started) { send(ws, { @@ -319,6 +320,7 @@ export function createTransportWsExtension(): Extension { const enqueueResult = orchestrator.enqueue({ conversationId: result.conversationId, text: result.text, + ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), }); if (enqueueResult.startedTurn) { ensureChatSubscribed(ws, state, result.conversationId); diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts index 66e2611..66e84cf 100644 --- a/packages/transport-ws/src/router.test.ts +++ b/packages/transport-ws/src/router.test.ts @@ -347,6 +347,38 @@ describe("routeClientMessage", () => { expect(result.cwd).toBe("/tmp"); }); + it("chat.send threads workspaceId", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + conversationId: "conv-ws", + message: "hello workspace", + workspaceId: "my-workspace", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.workspaceId).toBe("my-workspace"); + }); + + it("chat.send defaults workspaceId when omitted", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello no workspace", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + // workspaceId is absent (undefined) — the orchestrator receives no + // workspaceId and applies its own "default" resolution. + expect(result).not.toHaveProperty("workspaceId"); + }); + it("rejects a malformed chat.send (empty message)", () => { const registry = fakeRegistry([]); const connSubs = new Set<string>(); @@ -470,6 +502,22 @@ describe("routeClientMessage", () => { }); }); + it("chat.queue threads workspaceId", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.queue", + conversationId: "conv-ws", + text: "steer here", + workspaceId: "my-workspace", + }); + + expect(result.kind).toBe("chat-queue"); + if (result.kind !== "chat-queue") throw new Error("expected chat-queue"); + expect(result.workspaceId).toBe("my-workspace"); + }); + it("rejects empty/whitespace text → chat-error (no enqueue signal)", () => { const registry = fakeRegistry([]); const connSubs = new Set<string>(); diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts index e1f53c5..d43894d 100644 --- a/packages/transport-ws/src/router.ts +++ b/packages/transport-ws/src/router.ts @@ -48,6 +48,7 @@ export interface ChatRouteResult { readonly model: string | undefined; readonly cwd: string | undefined; readonly reasoningEffort?: ReasoningEffort; + readonly workspaceId?: string; } /** A malformed chat.send that should yield a chat.error reply. */ @@ -80,6 +81,7 @@ export interface ChatQueueRouteResult { readonly kind: "chat-queue"; readonly conversationId: string; readonly text: string; + readonly workspaceId?: string; } /** The effect any client WS message should produce. */ @@ -170,6 +172,7 @@ function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError model: msg.model, cwd: msg.cwd, ...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}), + ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}), }; } @@ -199,6 +202,7 @@ function handleChatQueue(msg: ChatQueueMessage): ChatQueueRouteResult | ChatRout kind: "chat-queue", conversationId: msg.conversationId, text: msg.text, + ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}), }; } diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts index 6d5db96..3a1bf03 100644 --- a/packages/transport-ws/src/server.bun.test.ts +++ b/packages/transport-ws/src/server.bun.test.ts @@ -104,8 +104,8 @@ interface FakeOrchestratorOpts { function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & { readonly listeners: Map<string, Set<TurnEventListener>>; - readonly startCalls: readonly { conversationId: string; text: string }[]; - readonly enqueueCalls: readonly { conversationId: string; text: string }[]; + readonly startCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; + readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; readonly aborted: boolean; } { const listeners = opts?.listeners ?? new Map<string, Set<TurnEventListener>>(); @@ -126,7 +126,11 @@ function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & { return aborted; }, startTurn(input) { - startCalls.push({ conversationId: input.conversationId, text: input.text }); + startCalls.push({ + conversationId: input.conversationId, + text: input.text, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + }); if (opts?.startTurn) { return opts.startTurn(input); } @@ -136,7 +140,11 @@ function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & { return { started: true, turnId: "fake-turn-id" }; }, enqueue(input) { - enqueueCalls.push({ conversationId: input.conversationId, text: input.text }); + enqueueCalls.push({ + conversationId: input.conversationId, + text: input.text, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + }); if (opts?.enqueue) { return opts.enqueue(input); } @@ -181,7 +189,7 @@ function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & { /** Create a fake orchestrator that broadcasts events when `broadcast` is called. */ function fakeOrchestratorWithBroadcast(): SessionOrchestrator & { readonly listeners: Map<string, Set<TurnEventListener>>; - readonly enqueueCalls: readonly { conversationId: string; text: string }[]; + readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[]; broadcast(conversationId: string, event: AgentEvent): void; } { const listeners = new Map<string, Set<TurnEventListener>>(); @@ -202,7 +210,11 @@ function fakeOrchestratorWithBroadcast(): SessionOrchestrator & { return { started: true, turnId: "fake-turn-id" }; }, enqueue(input) { - enqueueCalls.push({ conversationId: input.conversationId, text: input.text }); + enqueueCalls.push({ + conversationId: input.conversationId, + text: input.text, + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + }); return { startedTurn: true, queue: [] }; }, subscribe(conversationId, listener) { @@ -332,6 +344,7 @@ function startServer( text: result.message, ...(result.model !== undefined ? { modelName: result.model } : {}), ...(result.cwd !== undefined ? { cwd: result.cwd } : {}), + ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), }); if (!startResult.started) { ws.send( @@ -376,6 +389,7 @@ function startServer( const enqueueResult = orchestrator.enqueue({ conversationId: result.conversationId, text: result.text, + ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), }); if (enqueueResult.startedTurn) { if (!state.chatSubscriptions.has(result.conversationId)) { @@ -606,6 +620,55 @@ describe("chat ops (new orchestrator API)", () => { ws.close(); }); + test("chat.send threads workspaceId — orchestrator receives it", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send( + JSON.stringify({ + type: "chat.send", + conversationId: "c1", + message: "hello workspace", + workspaceId: "my-workspace", + }), + ); + await new Promise((r) => setTimeout(r, 50)); + + expect(orch.startCalls).toHaveLength(1); + expect(orch.startCalls[0]?.workspaceId).toBe("my-workspace"); + + ws.close(); + }); + + test("chat.send defaults workspaceId when omitted — orchestrator receives undefined", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send( + JSON.stringify({ + type: "chat.send", + conversationId: "c1", + message: "hello no workspace", + }), + ); + await new Promise((r) => setTimeout(r, 50)); + + expect(orch.startCalls).toHaveLength(1); + expect(orch.startCalls[0]).not.toHaveProperty("workspaceId"); + + ws.close(); + }); + test("multi-client fan-out — two connections both subscribe the same conversation", async () => { const orch = fakeOrchestratorWithBroadcast(); const registry = fakeRegistry([]); @@ -760,6 +823,32 @@ describe("chat.queue (steering enqueue)", () => { ws.close(); }); + test("chat.queue threads workspaceId — orchestrator receives it", async () => { + const orch = fakeOrchestrator(); // idle → startedTurn:true + const registry = fakeRegistry([]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + ws.send( + JSON.stringify({ + type: "chat.queue", + conversationId: "c1", + text: "steer here", + workspaceId: "my-workspace", + }), + ); + await new Promise((r) => setTimeout(r, 50)); + + expect(orch.enqueueCalls).toEqual([ + { conversationId: "c1", text: "steer here", workspaceId: "my-workspace" }, + ]); + + ws.close(); + }); + test("chat.queue on startedTurn:true auto-subscribes the sender (deltas stream as chat.delta)", async () => { const orch = fakeOrchestratorWithBroadcast(); // enqueue → startedTurn:true const registry = fakeRegistry([]); |
