summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-23 03:27:01 +0900
committerAdam Malczewski <[email protected]>2026-06-23 03:27:01 +0900
commitb346a50c3757b3df6086f11b995847144a5e07e8 (patch)
tree0119ff2a63e3ef9e9efb0190daeb20707e1c5f7a /packages/session-orchestrator/src
parent6d7b3923b40eb4baf3cefadfde236de646990713 (diff)
downloaddispatch-b346a50c3757b3df6086f11b995847144a5e07e8.tar.gz
dispatch-b346a50c3757b3df6086f11b995847144a5e07e8.zip
feat: workspaces — session-orchestrator + transport-http + transport-ws + cli (Wave 2+3)
session-orchestrator: workspaceId on StartTurnInput/EnqueueInput; effective cwd resolution (getCwd → getEffectiveCwd); auto-create workspace on turn start; warm parity (same effective cwd). 93 tests (+8). transport-http: workspace routes (GET/PUT/DELETE /workspaces, title, default-cwd); workspaceId threading on POST /chat + queue; ?workspaceId= filter on GET /conversations; DELETE /conversations/:id/cwd (clears explicit cwd); GET /conversations/:id/lsp uses effective cwd; slug validation. 166 tests. transport-ws: workspaceId threading on chat.send + chat.queue. 32 tests. cli: --workspace/-w flag; ConversationMeta test fakes fixed. 123 tests. Full typecheck EXIT 0, biome clean. 1283 vitest + 199 transport bun pass (1 pre-existing tool-shell failure unrelated to workspaces).
Diffstat (limited to 'packages/session-orchestrator/src')
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts378
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts55
-rw-r--r--packages/session-orchestrator/src/queue.test.ts25
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;
+ },
};
}