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/session-orchestrator/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/session-orchestrator/src')
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 378 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 55 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 25 |
3 files changed, 412 insertions, 46 deletions
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; + }, }; } |
