diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 4cf098c93d..bda08351cc 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -65,7 +65,7 @@ test('WorkHub rebuilds Session conversation after navigating away and back', asy ).toBeVisible(); }); -test('WorkHub handles a first natural-language correction', async ({ +test('WorkHub defers destructive correction until linked delegation exists', async ({ window: page, }) => { const sourceSessionName = '检查支付回调重复投递时的幂等性'; @@ -107,10 +107,11 @@ test('WorkHub handles a first natural-language correction', async ({ ).toBeEnabled(); await workHubComposer.press('Enter'); - await expect(page.locator('.workhub-correction-note').last()).toBeVisible(); - await expect( - page.locator('.workhub-turn', { - hasText: '不是这个,换成登录稳定性,补充刷新令牌失败判定。', - }).locator('.workhub-submitted-session strong'), - ).toHaveText('登录稳定性'); + const correctionTurn = page.locator('.workhub-turn', { + hasText: '不是这个,换成登录稳定性,补充刷新令牌失败判定。', + }); + await expect(correctionTurn.locator('.workhub-error')).toContainText( + '跨 Session 更正将在持久委托关联完成后开放', + ); + await expect(correctionTurn.locator('.workhub-submitted')).toHaveCount(0); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 40dfc58ecc..6be9f11be2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -98,6 +98,8 @@ test('restarts a paginated catalog read instead of mixing revisions', async () = test('resolves WorkHub coordination through the dedicated Host operation', async () => { const { client, requests } = clientWithResponses([ { sessionId: 'maka_workhub_coordination' }, + { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }, + { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, { turnId: 'answer-turn' }, { turnId: 'summary-turn' }, ]); @@ -105,6 +107,18 @@ test('resolves WorkHub coordination through the dedicated Host operation', async assert.deepEqual(await client.resolveWorkHubCoordinationSession(), { sessionId: 'maka_workhub_coordination', }); + assert.deepEqual(await client.listWorkHubCoordinationCandidates(), { + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }); + assert.deepEqual( + await client.actWorkHubCoordination({ + actionId: 'action', + userText: 'Question', + proposal: { disposition: 'answer_here' }, + }), + { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, + ); assert.deepEqual( await client.answerWorkHubCoordination({ turnId: 'answer-turn', text: 'Question' }), { turnId: 'answer-turn' }, @@ -119,6 +133,15 @@ test('resolves WorkHub coordination through the dedicated Host operation', async ); assert.deepEqual(requests, [ { operation: 'workhub.coordination.resolve', input: {} }, + { operation: 'workhub.coordination.candidates', input: {} }, + { + operation: 'workhub.coordination.act', + input: { + actionId: 'action', + userText: 'Question', + proposal: { disposition: 'answer_here' }, + }, + }, { operation: 'workhub.coordination.answer', input: { turnId: 'answer-turn', text: 'Question' }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts index 0a47cb7915..95e6f40d21 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts @@ -26,6 +26,9 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' let resolveCalls = 0; const answers: unknown[] = []; const records: unknown[] = []; + const actions: unknown[] = []; + const changes: unknown[] = []; + const createdSessionId = 'runtime-created-session'; registerRuntimeHostWorkHubIpc( { resolveWorkHubCoordinationSession: async () => { @@ -44,12 +47,31 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' records.push(input); return { turnId: input.turnId }; }, + listWorkHubCoordinationCandidates: async () => ({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }), + actWorkHubCoordination: async (input: unknown) => { + actions.push(input); + return { + disposition: 'create_new', + targetSessionId: createdSessionId, + targetTurnId: 'created-turn', + }; + }, } as never, { handle: (channel: string, handler: (...args: unknown[]) => unknown) => { handlers.set(channel, handler); }, } as never, + { + resolveCreateProject: async () => ({ + kind: 'host_path', + path: '/tmp/workhub-project', + }), + emitSessionsChanged: (reason, sessionId) => changes.push({ reason, sessionId }), + }, ); const handler = handlers.get('workhub:resolveCoordinationSession'); @@ -74,4 +96,33 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' userText: 'Request', assistantText: 'Summary', }]); + assert.deepEqual(await handlers.get('workhub:candidates')?.({}), { + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }); + assert.deepEqual( + await handlers.get('workhub:act')?.({}, { + actionId: 'create-action', + userText: 'Start accessibility review', + proposal: { disposition: 'create_new', title: 'Accessibility review' }, + create: { + sessionId: 'renderer-invented', + workspace: { kind: 'host_path', path: '/renderer-path' }, + }, + }), + { + disposition: 'create_new', + targetSessionId: createdSessionId, + targetTurnId: 'created-turn', + }, + ); + assert.deepEqual(actions, [{ + actionId: 'create-action', + userText: 'Start accessibility review', + proposal: { disposition: 'create_new', title: 'Accessibility review' }, + create: { + workspace: { kind: 'host_path', path: '/tmp/workhub-project' }, + }, + }]); + assert.deepEqual(changes, [{ reason: 'created', sessionId: createdSessionId }]); }); diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index a2921ccee5..12a9e8f0f2 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -21,7 +21,8 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; import { - createWorkHubController, + createLegacyWorkHubControllerForTests as createWorkHubController, + createWorkHubController as createGatedWorkHubController, WorkHubSessionSubmitError, WORKHUB_ROUTING_STRATEGY_ID, type WorkHubSessionFacts, @@ -2362,21 +2363,29 @@ test('submit lets strong foreign core evidence override a vague focus word', asy test('submit keeps unmatched non-executable conversation in WorkHub', async () => { let created = false; - const answered: Array<{ turnId: string; text: string }> = []; + const actions: unknown[] = []; const sessions = port([]); sessions.create = async () => { created = true; return session('unexpected'); }; - const controller = createWorkHubController({ + const controller = createGatedWorkHubController({ sessions, coordination: { open: async () => ({ close: async () => undefined }), - answer: async (input) => { - answered.push(input); - return { turnId: input.turnId }; - }, + answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'answer_here', + coordinationTurnId: 'coordination-turn', + }; + }, }, }); @@ -2392,9 +2401,299 @@ test('submit keeps unmatched non-executable conversation in WorkHub', async () = text: '你觉得统一入口最重要的价值是什么?', }); assert.equal(created, false); - assert.deepEqual(answered, [ - { turnId: 'request-discussion', text: '你觉得统一入口最重要的价值是什么?' }, + assert.deepEqual(actions, [ + { + actionId: 'request-discussion', + userText: '你觉得统一入口最重要的价值是什么?', + proposal: { disposition: 'answer_here' }, + }, + ]); +}); + +test('production submission delegates only through the Runtime-owned candidate reference', async () => { + const actions: unknown[] = []; + const sessions = port([session('payment')]); + sessions.submit = async () => { + throw new Error('renderer direct submit must not be used'); + }; + sessions.stop = async () => { + throw new Error('renderer direct stop must not be used'); + }; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'b'.repeat(64)}`, + candidates: [{ + candidateRef: 'candidate-payment', + sessionId: 'payment', + sessionName: 'payment', + workspace: { + target: { kind: 'host_path', path: '/workspace/payment' }, + hostCwd: '/workspace/payment', + }, + state: 'active', + updatedAt: 1, + }], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'delegate_existing', + targetSessionId: 'payment', + targetTurnId: 'target-turn', + }; + }, + }, + }); + + const result = await controller.submit({ + requestId: 'delegate-action', + text: '继续支付工作', + explicitTarget: { sessionId: 'payment' }, + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.turnId : undefined, 'target-turn'); + assert.deepEqual(actions, [{ + actionId: 'delegate-action', + userText: '继续支付工作', + candidateSetId: `sha256:${'b'.repeat(64)}`, + proposal: { + disposition: 'delegate_existing', + candidateRef: 'candidate-payment', + }, + }]); +}); + +test('production defers destructive correction until persistent delegation exists', async () => { + const actions: unknown[] = []; + const sessions = port([session('source'), session('target')]); + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'d'.repeat(64)}`, + candidates: [ + { + candidateRef: 'candidate-source', + sessionId: 'source', + sessionName: 'source', + workspace: { + target: { kind: 'host_path', path: '/workspace/source' }, + hostCwd: '/workspace/source', + }, + state: 'active', + updatedAt: 1, + }, + { + candidateRef: 'candidate-target', + sessionId: 'target', + sessionName: 'target', + workspace: { + target: { kind: 'host_path', path: '/workspace/target' }, + hostCwd: '/workspace/target', + }, + state: 'active', + updatedAt: 2, + }, + ], + }), + act: async (input) => { + actions.push(input); + throw new Error('incomplete correction must not reach the Action Gate'); + }, + }, + }); + + await assert.rejects( + controller.submit({ + requestId: 'deferred-correction', + text: 'No, use target instead', + explicitTarget: { sessionId: 'target' }, + correction: { from: { sessionId: 'source' }, turnId: 'source-turn' }, + }), + /linked correction requires persistent delegation support/u, + ); + assert.deepEqual(actions, []); +}); + +test('production natural-language correction fails closed before a second delegation', async () => { + const actions: unknown[] = []; + const sessions = port([ + session('login', { + sessionName: '登录稳定性', + latestResult: '刷新令牌过期导致重复登录', + updatedAt: 20, + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '支付回调重复投递', + updatedAt: 30, + }), ]); + const candidateSetId = `sha256:${'e'.repeat(64)}`; + const candidates = [ + { + candidateRef: 'candidate-login', + sessionId: 'login', + sessionName: '登录稳定性', + workspace: { + target: { kind: 'host_path' as const, path: '/workspace/login' }, + hostCwd: '/workspace/login', + }, + state: 'active' as const, + updatedAt: 20, + }, + { + candidateRef: 'candidate-payment', + sessionId: 'payment', + sessionName: '支付稳定性', + workspace: { + target: { kind: 'host_path' as const, path: '/workspace/payment' }, + hostCwd: '/workspace/payment', + }, + state: 'active' as const, + updatedAt: 30, + }, + ]; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId, candidates }), + act: async (input) => { + actions.push(input); + return { + disposition: 'delegate_existing', + targetSessionId: input.proposal.disposition === 'delegate_existing' && + input.proposal.candidateRef === 'candidate-login' + ? 'login' + : 'payment', + targetTurnId: input.actionId === 'production-wrong-payment' + ? 'runtime-payment-turn' + : 'runtime-login-turn', + }; + }, + }, + }); + await controller.read(); + await controller.submit({ + requestId: 'production-wrong-payment', + text: '继续这个工作,补充验收项', + }); + + await assert.rejects( + controller.submit({ + requestId: 'production-natural-correction', + text: '不是这个,换成登录那个,补充刷新令牌失败判定', + }), + /linked correction requires persistent delegation support/u, + ); + + assert.deepEqual(actions, [{ + actionId: 'production-wrong-payment', + userText: '继续这个工作,补充验收项', + candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: 'candidate-payment', + }, + }]); +}); + +test('production clarification is persisted through the typed Action Gate disposition', async () => { + const actions: unknown[] = []; + const controller = createGatedWorkHubController({ + sessions: port([]), + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async () => { + throw new Error('legacy summary recording must not persist clarification'); + }, + candidates: async () => ({ + candidateSetId: `sha256:${'c'.repeat(64)}`, + candidates: [], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'clarify', + coordinationTurnId: 'clarification-turn', + }; + }, + }, + }); + + assert.deepEqual(await controller.recordConversationTurn({ + turnId: 'clarification-action', + userText: '继续稳定性问题', + assistantText: '请选择目标 Session', + disposition: 'clarify', + }), { turnId: 'clarification-turn' }); + assert.deepEqual(actions, [{ + actionId: 'clarification-action', + userText: '继续稳定性问题', + proposal: { + disposition: 'clarify', + assistantText: '请选择目标 Session', + }, + }]); +}); + +test('production creation leaves Session identity and workspace authority to main and Runtime', async () => { + const actions: unknown[] = []; + const sessions = port([]); + sessions.create = async () => { + throw new Error('renderer direct create must not be used'); + }; + const controller = createGatedWorkHubController({ + sessions, + coordination: { + open: async () => ({ close: async () => undefined }), + answer: async (input) => ({ turnId: input.turnId }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'c'.repeat(64)}`, + candidates: [], + }), + act: async (input) => { + actions.push(input); + return { + disposition: 'create_new', + targetSessionId: 'runtime-created', + targetTurnId: 'runtime-turn', + }; + }, + }, + }); + + const result = await controller.submit({ + requestId: 'create-action', + text: '请创建新任务,检查支付回调重复投递。', + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { + sessionId: 'runtime-created', + }); + assert.deepEqual(actions, [{ + actionId: 'create-action', + userText: '请创建新任务,检查支付回调重复投递。', + proposal: { + disposition: 'create_new', + title: '检查支付回调重复投递', + }, + }]); }); test('submit treats a design question containing an action word as discussion', async () => { diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 662bcf4f98..af3d8d48d7 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -163,6 +163,14 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and }, answer: async (input) => ({ turnId: input.turnId }), record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [], + }), + act: async () => ({ + disposition: 'answer_here', + coordinationTurnId: 'coordination-turn', + }), }); const handle = await adapter.open((turns) => snapshots.push(turns), () => {}); diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 0b7812c0ad..3440e0b854 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -28,11 +28,11 @@ import { WorkHubSurfaceRouteGate, submitWorkHubSurfaceInput, visibleWorkHubConversation, - workHubSubmissionCanCorrect, + workHubSurfaceFailure, workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; import { - createWorkHubController, + createLegacyWorkHubControllerForTests as createWorkHubController, WORKHUB_ROUTING_STRATEGY_ID, type WorkHubController, type WorkHubSubmitInput, @@ -42,6 +42,26 @@ import { type WorkHubDesktopSession, } from '../../renderer/workhub-session-port.js'; +test('surface turns Action Gate rejections into safe actionable failures', () => { + assert.equal( + workHubSurfaceFailure( + new Error('WorkHub Session candidates changed; refresh before delegating'), + ), + 'candidates_changed', + ); + assert.equal( + workHubSurfaceFailure( + new Error('WorkHub linked correction requires persistent delegation support'), + ), + 'linked_correction_unavailable', + ); + assert.equal( + workHubSurfaceFailure(new Error('Target Session is waiting for user input')), + 'target_waiting', + ); + assert.equal(workHubSurfaceFailure(new Error('private transport detail')), 'delivery_failed'); +}); + test('surface route gate rejects same-frame duplicate operations and reopens after settle', async () => { const gate = new WorkHubSurfaceRouteGate(); let release: (() => void) | undefined; @@ -111,20 +131,6 @@ test('surface keeps the Composer draft when routing fails or the target is waiti }), true); }); -test('surface disables correction after a request was steered into existing work', () => { - const submission = { - kind: 'submitted' as const, - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'steered', - target: { sessionId: 'payment' }, - turnId: 'turn-existing', - evidence: 'explicit_target' as const, - }; - - assert.equal(workHubSubmissionCanCorrect(submission), true); - assert.equal(workHubSubmissionCanCorrect({ ...submission, steered: true }), false); -}); - test('surface replaces a local discussion placeholder with its durable model answer', () => { const local = [{ requestId: 'discussion-turn', diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ff9e34e11b..c1b539c1a3 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -834,6 +834,16 @@ export class DesktopRuntimeHostClient { return this.request("workhub.coordination.resolve", {}); } + listWorkHubCoordinationCandidates() { + return this.request("workhub.coordination.candidates", {}); + } + + actWorkHubCoordination( + input: OperationInput<"workhub.coordination.act">, + ): Promise> { + return this.request("workhub.coordination.act", input); + } + answerWorkHubCoordination( input: OperationInput<"workhub.coordination.answer">, ): Promise> { diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index db8bab7e58..b455deca90 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -679,7 +679,10 @@ export async function createDesktopRuntimeHostCandidate( }, ipc, ); - registerRuntimeHostWorkHubIpc(client, ipc); + registerRuntimeHostWorkHubIpc(client, ipc, { + resolveCreateProject: () => deps.resolveSessionCreateProject({}, target), + emitSessionsChanged, + }); registerRuntimeHostExternalSessionsIpc( { client, diff --git a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts index d0ad7e94e1..621bc5a570 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -17,20 +17,35 @@ * under the License. */ +import type { + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkspaceTarget, +} from '@maka/runtime-host/protocol'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; type RuntimeHostWorkHubClient = Pick< DesktopRuntimeHostClient, + | 'actWorkHubCoordination' | 'answerWorkHubCoordination' + | 'listWorkHubCoordinationCandidates' | 'recordWorkHubCoordination' | 'resolveWorkHubCoordinationSession' >; +type RendererWorkHubActionInput = Omit; + +export interface RuntimeHostWorkHubIpcOptions { + resolveCreateProject(): Promise; + emitSessionsChanged(reason: 'created' | 'status-change', sessionId: string): void; +} + /** Projects the Runtime Host WorkHub domain onto renderer IPC. */ export function registerRuntimeHostWorkHubIpc( client: RuntimeHostWorkHubClient, ipcMain: Pick, + options: RuntimeHostWorkHubIpcOptions, ): void { ipcMain.handle('workhub:resolveCoordinationSession', () => client.resolveWorkHubCoordinationSession(), @@ -41,4 +56,35 @@ export function registerRuntimeHostWorkHubIpc( ipcMain.handle('workhub:record', (_event, input) => client.recordWorkHubCoordination(input), ); + ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); + ipcMain.handle('workhub:act', async (_event, rawInput: RendererWorkHubActionInput) => { + const proposal = rawInput?.proposal; + const base = { + actionId: rawInput?.actionId, + userText: rawInput?.userText, + proposal, + } as Pick; + let result: WorkHubCoordinationActResult; + if (proposal?.disposition === 'create_new') { + result = await client.actWorkHubCoordination({ + ...base, + create: { + workspace: await options.resolveCreateProject(), + }, + }); + } else { + result = await client.actWorkHubCoordination({ + ...base, + ...(rawInput?.candidateSetId === undefined + ? {} + : { candidateSetId: rawInput.candidateSetId }), + }); + } + if (result.disposition === 'create_new') { + options.emitSessionsChanged('created', result.targetSessionId); + } else if (result.disposition === 'delegate_existing') { + options.emitSessionsChanged('status-change', result.targetSessionId); + } + return result; + }); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 81e5862974..a15f6575af 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -770,6 +770,15 @@ export interface MakaBridge { coordinationSessionId: string, input: { turnId: string; userText: string; assistantText: string }, ): Promise<{ turnId: string }>; + /** Read one bounded, Host-issued candidate set for a coordination action. */ + candidates( + coordinationSessionId: string, + ): Promise>; + /** Submit a typed proposal; trusted creation context is added outside the renderer. */ + act( + coordinationSessionId: string, + input: Omit, 'create'>, + ): Promise>; /** Create an ordinary Session on the exact Host owning the resolved conversation. */ createSession( coordinationSessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d6d68036f1..9e7a8563c5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1556,6 +1556,47 @@ const makaBridge = { ); return ipcRenderer.invoke('workhub:record', scope, input) as Promise<{ turnId: string }>; }, + async candidates( + coordinationSessionId: string, + ): Promise> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + const result = await ipcRenderer.invoke( + 'workhub:candidates', + scope, + ) as OperationOutput<'workhub.coordination.candidates'>; + return { + ...result, + candidates: result.candidates.map((candidate) => ({ + ...candidate, + sessionId: desktopSessionKey({ hostId: scope.hostId, sessionId: candidate.sessionId }), + })), + }; + }, + async act( + coordinationSessionId: string, + input: Omit, 'create'>, + ): Promise> { + const scope = await resolveDesktopWorkHubCoordinationCreateScope( + coordinationSessionId, + runtimeHostSessionRef, + ); + const result = await ipcRenderer.invoke( + 'workhub:act', + scope, + input, + ) as OperationOutput<'workhub.coordination.act'>; + if (result.disposition === 'answer_here' || result.disposition === 'clarify') return result; + return { + ...result, + targetSessionId: desktopSessionKey({ + hostId: scope.hostId, + sessionId: result.targetSessionId, + }), + }; + }, async createSession( coordinationSessionId: string, input: { name: string }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 013ed52498..3d97a37bae 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1532,6 +1532,10 @@ function AppShellContent({ window.maka.workHub.answer(workHubCoordinationSessionId!, input), record: (input) => window.maka.workHub.record(workHubCoordinationSessionId!, input), + candidates: () => + window.maka.workHub.candidates(workHubCoordinationSessionId!), + act: (input) => + window.maka.workHub.act(workHubCoordinationSessionId!, input), }), sessions: createDesktopWorkHubSessionPort({ sessions: scopeWorkHubSessionsToCoordinationHost( diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index cf2b288a3c..420e11a8af 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -28,6 +28,11 @@ import { type WorkHubRouteEvidence, workHubNewSessionName, } from './workhub-route-policy.js'; +import type { + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkHubCoordinationCandidatesResult, +} from '@maka/runtime-host/protocol'; export interface WorkHubSessionTarget { sessionId: string; @@ -197,6 +202,8 @@ export interface WorkHubCoordinationPort { userText: string; assistantText: string; }): Promise<{ turnId: string }>; + candidates(): Promise; + act(input: Omit): Promise; } export class WorkHubSessionSubmitError extends Error { @@ -221,6 +228,7 @@ export interface WorkHubController { turnId: string; userText: string; assistantText: string; + disposition?: 'clarify' | 'summary'; }): Promise<{ turnId: string }>; subscribe(handler: () => void): () => void; resetVisitContext(): void; @@ -243,6 +251,20 @@ interface WorkHubOwnershipTombstone { } export function createWorkHubController(deps: { + sessions: WorkHubSessionPort; + coordination: WorkHubCoordinationPort; +}): WorkHubController { + return createWorkHubControllerImplementation(deps); +} + +/** @internal Transitional R2.4 regression harness; application code must use the Action Gate. */ +export function createLegacyWorkHubControllerForTests(deps: { + sessions: WorkHubSessionPort; +}): WorkHubController { + return createWorkHubControllerImplementation(deps); +} + +function createWorkHubControllerImplementation(deps: { sessions: WorkHubSessionPort; coordination?: WorkHubCoordinationPort; }): WorkHubController { @@ -580,8 +602,26 @@ export function createWorkHubController(deps: { openConversation(handler, onError) { return coordination.open(handler, onError); }, - recordConversationTurn(input) { - return coordination.record(input); + async recordConversationTurn(input) { + if (deps.coordination && input.disposition === 'clarify') { + const result = await coordination.act({ + actionId: input.turnId, + userText: input.userText, + proposal: { + disposition: 'clarify', + assistantText: input.assistantText, + }, + }); + if (result.disposition !== 'clarify') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + return { turnId: result.coordinationTurnId }; + } + return coordination.record({ + turnId: input.turnId, + userText: input.userText, + assistantText: input.assistantText, + }); }, subscribe(handler) { return deps.sessions.subscribe(handler); @@ -633,6 +673,11 @@ export function createWorkHubController(deps: { // learned only after successful delivery, but their precedence follows // user submission order rather than network completion order. const submissionOrder = submissionPolicy.reserveSubmissionOrder(); + if (deps.coordination && input.correction) { + throw new Error( + 'WorkHub linked correction requires persistent delegation support', + ); + } const { catalog, allowAuthoritativePruning } = await readCatalog(); reconcileConfirmedOwnership(catalog, allowAuthoritativePruning); @@ -644,9 +689,20 @@ export function createWorkHubController(deps: { const sessions = catalog.sessions; reconcileFocus(submissionPolicy, sessions); const ordinary = sessions.filter((session) => session.kind === 'ordinary'); + const candidateSet = deps.coordination + ? await coordination.candidates() + : undefined; + const candidateBySessionId = new Map( + candidateSet?.candidates.map((candidate) => [candidate.sessionId, candidate]), + ); // Archived Sessions remain visible as historical work, but Runtime Host - // rejects new root Turns for them. Never offer one as a routing target. - const routable = ordinary.filter((session) => !session.archived); + // rejects new root Turns for them. In production the Runtime-owned + // candidate set is the only target namespace the strategy can see. + const routable = ordinary.filter( + (session) => + !session.archived && + (!candidateSet || candidateBySessionId.has(session.target.sessionId)), + ); const routingEvidence = input.explicitTarget ? [] : await deps.sessions.routingEvidence(routable.map((session) => session.target)); @@ -659,6 +715,11 @@ export function createWorkHubController(deps: { ...(input.explicitTarget ? { explicitTarget: input.explicitTarget } : {}), }); if (decision.kind === 'clarification') { + if (deps.coordination && decision.correctedFrom) { + throw new Error( + 'WorkHub linked correction requires persistent delegation support', + ); + } const correction = decision.correctedFrom ? correctionFor(decision.correctedFrom) : undefined; @@ -676,10 +737,18 @@ export function createWorkHubController(deps: { }; } if (decision.kind === 'discussion') { - await coordination.answer({ - turnId: input.requestId, - text: input.text, - }); + if (candidateSet) { + await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { disposition: 'answer_here' }, + }); + } else { + await coordination.answer({ + turnId: input.requestId, + text: input.text, + }); + } return { kind: 'discussion', strategyId: WORKHUB_ROUTING_STRATEGY_ID, @@ -692,6 +761,35 @@ export function createWorkHubController(deps: { const correction = input.correction ?? (decision.kind === 'target' && decision.correctedFrom ? correctionFor(decision.correctedFrom) : undefined); + if (deps.coordination && correction) { + throw new Error( + 'WorkHub linked correction requires persistent delegation support', + ); + } + if (candidateSet && decision.kind === 'new_session') { + const admitted = await coordination.act({ + actionId: input.requestId, + userText: input.text, + proposal: { + disposition: 'create_new', + title: workHubNewSessionName(input.text), + }, + }); + if (admitted.disposition !== 'create_new') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + target = { sessionId: admitted.targetSessionId }; + submissionPolicy.rememberTarget(target); + return { + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + turnId: admitted.targetTurnId, + ...(admitted.steered ? { steered: true as const } : {}), + evidence: 'new_session', + }; + } if (decision.kind === 'new_session') { const created = await deps.sessions.create({ name: workHubNewSessionName(input.text) }); if (created.kind !== 'ordinary') { @@ -718,6 +816,36 @@ export function createWorkHubController(deps: { target, }; } + if (candidateSet) { + const candidate = candidateBySessionId.get(target.sessionId); + if (!candidate) { + throw new Error('WorkHub target Session is unavailable'); + } + const action: WorkHubCoordinationActInput = { + actionId: input.requestId, + userText: input.text, + candidateSetId: candidateSet.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: candidate.candidateRef, + }, + }; + const admitted = await coordination.act(action); + if (admitted.disposition !== 'delegate_existing') { + throw new Error('WorkHub Action Gate returned an unexpected disposition'); + } + target = { sessionId: admitted.targetSessionId }; + submissionPolicy.rememberTarget(target); + return { + kind: 'submitted', + strategyId: WORKHUB_ROUTING_STRATEGY_ID, + requestId: input.requestId, + target, + turnId: admitted.targetTurnId, + ...(admitted.steered ? { steered: true as const } : {}), + evidence, + }; + } if (correction) { await stopOwnedRoots(correction, submissionOrder); } @@ -780,5 +908,11 @@ function legacyTestCoordinationPort(): WorkHubCoordinationPort { async record(input) { return { turnId: input.turnId }; }, + async candidates() { + throw new Error('The legacy WorkHub test adapter does not expose Action Gate candidates'); + }, + async act() { + throw new Error('The legacy WorkHub test adapter does not expose Action Gate actions'); + }, }; } diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 38bcbf1bbc..e2581cd67f 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -29,6 +29,11 @@ import type { WorkHubCoordinationTurn, WorkHubProjectedTurnState, } from './workhub-controller.js'; +import type { + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkHubCoordinationCandidatesResult, +} from '@maka/runtime-host/protocol'; import { boundedWorkHubTimelineText } from './workhub-controller.js'; import type { WorkHubDesktopTranscriptBridge } from './workhub-session-port.js'; @@ -43,10 +48,14 @@ export function createDesktopWorkHubCoordinationPort(deps: { userText: string; assistantText: string; }): Promise<{ turnId: string }>; + candidates(): Promise; + act(input: Omit): Promise; }): WorkHubCoordinationPort { return { answer: deps.answer, record: deps.record, + candidates: deps.candidates, + act: deps.act, async open(handler, onError) { const store = new DesktopTranscriptRangeStore(deps.sessionId); let disposed = false; diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index c03b15ba53..71f0179a6d 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -41,8 +41,16 @@ export interface WorkHubConversationTurn { text: string; state: 'routing' | 'settled' | 'failed'; outcome?: WorkHubSubmission; + failure?: WorkHubSurfaceFailure; } +export type WorkHubSurfaceFailure = + | 'candidates_changed' + | 'linked_correction_unavailable' + | 'target_waiting' + | 'action_changed' + | 'delivery_failed'; + export class WorkHubSurfaceRouteGate { #pending = false; @@ -80,10 +88,23 @@ export function workHubSubmissionClearsDraft( return Boolean(result && result.kind !== 'waiting'); } -export function workHubSubmissionCanCorrect( - result: WorkHubSubmission, -): result is Extract { - return result.kind === 'submitted' && !result.steered; +export function workHubSurfaceFailure(error: unknown): WorkHubSurfaceFailure { + const message = error instanceof Error ? error.message : ''; + if ( + /candidates changed|not in the admitted candidate set|source or target is not in/iu.test( + message, + ) + ) { + return 'candidates_changed'; + } + if (/linked correction requires persistent delegation support/iu.test(message)) { + return 'linked_correction_unavailable'; + } + if (/waiting for user input/iu.test(message)) return 'target_waiting'; + if (/identity belongs to a different proposal/iu.test(message)) { + return 'action_changed'; + } + return 'delivery_failed'; } export function visibleWorkHubConversation( @@ -213,6 +234,7 @@ export function WorkHubSurface(props: { turnId: input.requestId, userText: recordedUserText, assistantText: workHubCoordinationSummary(result, projection, copy), + disposition: result.kind === 'clarification' ? 'clarify' : 'summary', }); } catch { // The ordinary Session admission has already settled. A failed @@ -227,10 +249,15 @@ export function WorkHubSurface(props: { )); if (result.kind === 'submitted') await refresh(); return result; - } catch { + } catch (error) { setTurns((current) => current.map((turn) => turn.requestId === localRequestId - ? { ...turn, state: 'failed', outcome: undefined } + ? { + ...turn, + state: 'failed', + outcome: undefined, + failure: workHubSurfaceFailure(error), + } : turn, )); return undefined; @@ -327,21 +354,6 @@ export function WorkHubSurface(props: { : {}), }, turn.requestId, copy.choseWork(selected?.sessionName ?? copy.sessionFallback)); }} - onCorrect={(from, target) => { - const selected = projection.sessions.find( - (session) => session.target.sessionId === target.sessionId, - ); - void route({ - requestId: crypto.randomUUID(), - text: turn.text, - explicitTarget: target, - correction: { - from: from.target, - turnId: from.turnId, - ...(from.steered ? { steered: true } : {}), - }, - }, turn.requestId, copy.correctedWork(selected?.sessionName ?? copy.sessionFallback)); - }} onOpenSession={props.onOpenSession} /> ))} @@ -484,10 +496,6 @@ function WorkHubTurnView(props: { copy: ReturnType; pending: boolean; onChoose(target: { sessionId: string }): void; - onCorrect( - from: Extract, - target: { sessionId: string }, - ): void; onOpenSession(sessionId: string): void; }) { const { turn, copy } = props; @@ -501,7 +509,9 @@ function WorkHubTurnView(props: { {turn.state === 'routing' ? (

{copy.routing}

) : turn.state === 'failed' ? ( -

{copy.submitFailed}

+

+ {copy.submitFailures[turn.failure ?? 'delivery_failed']} +

) : turn.outcome?.kind === 'clarification' ? ( <>

{copy.chooseWork}

@@ -535,12 +545,6 @@ function WorkHubTurnView(props: { ) : submitted ? ( - session.target.sessionId === submitted.correctedFrom?.sessionId, - ) - : undefined} targetSessionId={submitted.target.sessionId} heading={copy.sentTo} state={target @@ -548,15 +552,6 @@ function WorkHubTurnView(props: { : copy.accepted} result={target?.latestResult} copy={copy} - correctionOptions={workHubSubmissionCanCorrect(submitted) - ? props.projection.sessions.filter( - (session) => - !session.archived && - session.target.sessionId !== submitted.target.sessionId, - ) - : []} - pending={props.pending} - onCorrect={(target) => props.onCorrect(submitted, target)} onOpenSession={props.onOpenSession} /> ) : null} @@ -591,15 +586,11 @@ function WorkHubMessageFrame(props: { function SubmittedWorkView(props: { session: WorkHubSessionSummary | undefined; - correctedFrom: WorkHubSessionSummary | undefined; targetSessionId: string; heading: string; state: string; result: string | undefined; copy: ReturnType; - correctionOptions: WorkHubSessionSummary[]; - pending: boolean; - onCorrect?(target: { sessionId: string }): void; onOpenSession(sessionId: string): void; }) { const { session, copy } = props; @@ -617,33 +608,7 @@ function SubmittedWorkView(props: { {session?.projectName ? {session.projectName} : null} - {props.correctedFrom ? ( - - {copy.correctedFrom(props.correctedFrom.sessionName)} - - ) : null} {props.result ?

{props.result}

: null} - {props.correctionOptions.length > 0 ? ( -
- {copy.correctTarget} -
- {props.correctionOptions.map((option) => ( - - ))} -
-
- ) : null} ); } @@ -663,10 +628,7 @@ function workHubCopy(locale: UiLocale) { discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。', answering: '正在回答…', choseWork: (name: string) => `选择“${name}”`, - correctedWork: (name: string) => `更正目标为“${name}”`, sentTo: '已交给:', accepted: '已接收', sessionFallback: '普通 Session', - correctTarget: '更正目标', - correctedFrom: (name: string) => `已从“${name}”更正`, waitingForDecision: '这项工作正在等待你的决定。', requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', @@ -675,7 +637,13 @@ function workHubCopy(locale: UiLocale) { coordinationFailedTitle: 'WorkHub 暂时无法启动', coordinationFailedBody: '请检查当前 Runtime Host 的默认模型配置,然后重试。', retry: '重试', - submitFailed: '输入未能送达,请重试。', scrollToBottom: '滚动到底部', archived: '已归档', + submitFailures: { + candidates_changed: '工作列表已变化,请重新发送以使用最新目标。', + linked_correction_unavailable: '跨 Session 更正将在持久委托关联完成后开放;请先打开原 Session 停止当前工作。', + target_waiting: '目标 Session 正在等待你的处理;请先打开并完成该交互。', + action_changed: '这次操作已发生变化,请重新发送。', + delivery_failed: '输入未能送达,请重试。', + }, scrollToBottom: '滚动到底部', archived: '已归档', states: { active: '活跃', running: '进行中', waiting_for_user: '等待你', blocked: '受阻', aborted: '已中止' }, turnStates: { running: '进行中', completed: '已完成', aborted: '已中止', failed: '失败' }, } as const; @@ -693,10 +661,7 @@ function workHubCopy(locale: UiLocale) { discussionHint: 'State an executable goal and I will hand it to the owning Session.', answering: 'Answering…', choseWork: (name: string) => `Choose “${name}”`, - correctedWork: (name: string) => `Correct the target to “${name}”`, sentTo: 'Sent to:', accepted: 'Accepted', sessionFallback: 'Ordinary Session', - correctTarget: 'Correct target', - correctedFrom: (name: string) => `Corrected from “${name}”`, waitingForDecision: 'This work is waiting for your decision.', requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', @@ -705,7 +670,13 @@ function workHubCopy(locale: UiLocale) { coordinationFailedTitle: 'WorkHub could not start', coordinationFailedBody: 'Check the default model for the current Runtime Host, then retry.', retry: 'Retry', - submitFailed: 'The input could not be delivered. Try again.', scrollToBottom: 'Scroll to bottom', archived: 'Archived', + submitFailures: { + candidates_changed: 'The work list changed. Send again to use the latest targets.', + linked_correction_unavailable: 'Cross-Session correction will be available with persistent delegation. Open the original Session to stop its current work first.', + target_waiting: 'The target Session needs your input. Open it and resolve that interaction first.', + action_changed: 'This action changed. Send it again.', + delivery_failed: 'The input could not be delivered. Try again.', + }, scrollToBottom: 'Scroll to bottom', archived: 'Archived', states: { active: 'Active', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Blocked', aborted: 'Aborted' }, turnStates: { running: 'Running', completed: 'Completed', aborted: 'Aborted', failed: 'Failed' }, } as const; diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts new file mode 100644 index 0000000000..9d36dae540 --- /dev/null +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + WorkHubActionEffectFailure, + WorkHubActionGateFailure, + WorkHubCoordinationActionGate, + type WorkHubActionGateEffects, + type WorkHubActionGateSession, +} from '../server/workhub-coordination-action-gate.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; + +const CONTEXT: ConnectionContext = { + hostEpoch: 'workhub-action-gate-test', + connectionId: 'workhub-action-gate-client', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), +}; + +describe('WorkHub Coordination Action Gate', () => { + test('exposes only bounded ordinary candidates and proposals use opaque refs', async () => { + const effects = fakeEffects([ + session('ordinary'), + session('archived', { isArchived: true }), + session('waiting', { status: 'waiting_for_user' }), + session('side', { labels: ['mode:side_conversation'] }), + session('child', { + subagentParent: { + kind: 'subagent', + parentSessionId: 'ordinary', + spawnedBy: { parentTurnId: 'turn', parentRunId: 'run', toolCallId: 'tool' }, + lifecycle: 'foreground', + }, + }), + session('maka_workhub_coordination', { role: 'workhub_coordination' }), + ]); + const result = await new WorkHubCoordinationActionGate(effects).candidates(); + + assert.deepEqual( + result.candidates.map(({ sessionId }) => sessionId), + ['ordinary', 'waiting'], + ); + assert.match(result.candidateSetId, /^sha256:[a-f0-9]{64}$/u); + assert.notEqual(result.candidates[0]?.candidateRef, 'ordinary'); + + const bounded = await new WorkHubCoordinationActionGate( + fakeEffects(Array.from({ length: 40 }, (_, index) => session(`ordinary-${index}`))), + ).candidates(); + assert.equal(bounded.candidates.length, 32); + }); + + test('rejects stale and invented candidates before any Session effect', async () => { + const effects = fakeEffects([session('payments')]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + effects.sessions[0] = session('payments', { statusUpdatedAt: 9 }); + + await assert.rejects( + gate.act( + { + actionId: 'stale-action', + userText: 'Continue payments', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'candidate_set_stale', + ); + assert.deepEqual(effects.submissions, []); + + const refreshed = await gate.candidates(); + const retried = await gate.act( + { + actionId: 'stale-action', + userText: 'Continue payments', + candidateSetId: refreshed.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: refreshed.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ); + assert.equal(retried.disposition, 'delegate_existing'); + effects.submissions.length = 0; + + const current = await gate.candidates(); + await assert.rejects( + gate.act( + { + actionId: 'invented-action', + userText: 'Continue payments', + candidateSetId: current.candidateSetId, + proposal: { disposition: 'delegate_existing', candidateRef: 'invented_candidate' }, + }, + CONTEXT, + ), + (error) => + error instanceof WorkHubActionGateFailure && error.code === 'candidate_unavailable', + ); + assert.deepEqual(effects.submissions, []); + }); + + test('rejects waiting targets independently of strategy behavior', async () => { + const effects = fakeEffects([session('waiting', { status: 'waiting_for_user' })]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + + await assert.rejects( + gate.act( + { + actionId: 'waiting-action', + userText: 'Do another thing', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing', + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }, + CONTEXT, + ), + (error) => + error instanceof WorkHubActionGateFailure && error.code === 'target_waiting_for_user', + ); + assert.deepEqual(effects.submissions, []); + }); + + test('answers and clarifies only through the Coordination transcript effects', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + + const answered = await gate.act( + { + actionId: 'answer-action', + userText: 'What is useMemo?', + proposal: { disposition: 'answer_here' }, + }, + CONTEXT, + ); + const clarified = await gate.act( + { + actionId: 'clarify-action', + userText: 'Continue that task', + proposal: { disposition: 'clarify', assistantText: 'Which task do you mean?' }, + }, + CONTEXT, + ); + + assert.equal(answered.disposition, 'answer_here'); + assert.equal(clarified.disposition, 'clarify'); + assert.equal(effects.answers.length, 1); + assert.equal(effects.clarifications.length, 1); + assert.deepEqual(effects.submissions, []); + assert.equal(effects.creations.length, 0); + }); + + test('only create_new creates and retries the exact action idempotently', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + await assert.rejects( + gate.act( + { + actionId: 'missing-create-context', + userText: 'Create an accessibility audit', + proposal: { disposition: 'create_new', title: 'Accessibility audit' }, + }, + CONTEXT, + ), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.creations.length, 0); + const input = { + actionId: 'create-action', + userText: 'Create an accessibility audit', + proposal: { disposition: 'create_new' as const, title: 'Accessibility audit' }, + create: { + workspace: { kind: 'host_path' as const, path: '/workspace' }, + }, + }; + + const first = await gate.act(input, CONTEXT); + const replay = await gate.act(input, CONTEXT); + + assert.deepEqual(replay, first); + assert.equal(effects.creations.length, 1); + assert.equal(effects.submissions.length, 1); + assert.match(effects.creations[0]?.sessionId ?? '', /^whs_[a-f0-9]{48}$/u); + assert.equal(first.disposition, 'create_new'); + if (first.disposition === 'create_new') { + assert.equal(first.targetSessionId, effects.creations[0]?.sessionId); + } + assert.deepEqual(Object.keys(effects.creations[0]!).sort(), [ + 'sessionId', + 'title', + 'workspace', + ]); + await assert.rejects( + gate.act({ ...input, proposal: { disposition: 'create_new', title: 'Different' } }, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); + assert.equal(effects.creations.length, 1); + }); + + test('effect rejection grants no root ownership and releases the action identity', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const submit = effects.submit; + effects.submit = async () => { + throw new WorkHubActionEffectFailure('unauthorized', 'Target permission denied'); + }; + const input = { + actionId: 'permission-rejected-action', + userText: 'Continue ordinary work', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing' as const, + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }; + + await assert.rejects( + gate.act(input, CONTEXT), + (error) => error instanceof WorkHubActionEffectFailure && error.code === 'unauthorized', + ); + effects.submit = submit; + assert.equal((await gate.act(input, CONTEXT)).disposition, 'delegate_existing'); + }); + + test('replays an ordinary delegation without submitting twice', async () => { + const effects = fakeEffects([session('ordinary')]); + const gate = new WorkHubCoordinationActionGate(effects); + const snapshot = await gate.candidates(); + const input = { + actionId: 'delegate-action', + userText: 'Continue ordinary work', + candidateSetId: snapshot.candidateSetId, + proposal: { + disposition: 'delegate_existing' as const, + candidateRef: snapshot.candidates[0]!.candidateRef, + }, + }; + + const first = await gate.act(input, CONTEXT); + const replay = await gate.act(input, CONTEXT); + + assert.deepEqual(replay, first); + assert.equal(effects.submissions.length, 1); + assert.equal(effects.submissions[0]?.sessionId, 'ordinary'); + }); +}); + +function session( + id: string, + patch: Partial = {}, +): WorkHubActionGateSession { + return { + id, + cwd: `/workspace/${id}`, + projectId: null, + createdAt: 1, + name: id, + labels: [], + isArchived: false, + status: 'active', + ...patch, + }; +} + +function fakeEffects(initialSessions: WorkHubActionGateSession[]) { + const state = { + sessions: [...initialSessions], + answers: [] as Array<{ turnId: string; text: string }>, + clarifications: [] as Array<{ + turnId: string; + userText: string; + assistantText: string; + }>, + creations: [] as Array<{ + sessionId: string; + workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; + title: string; + }>, + submissions: [] as Array<{ sessionId: string; messageId: string; text: string }>, + async listSessions() { + return this.sessions; + }, + async answer(input: { turnId: string; text: string }) { + this.answers.push(input); + }, + async clarify(input: { turnId: string; userText: string; assistantText: string }) { + this.clarifications.push(input); + }, + async create(input: { + sessionId: string; + workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; + title: string; + }) { + this.creations.push(input); + }, + async submit(input: { sessionId: string; messageId: string; text: string }) { + this.submissions.push(input); + return { turnId: `turn-${input.sessionId}` }; + }, + } satisfies WorkHubActionGateEffects & { + sessions: WorkHubActionGateSession[]; + answers: Array<{ turnId: string; text: string }>; + clarifications: Array<{ turnId: string; userText: string; assistantText: string }>; + creations: Array<{ + sessionId: string; + workspace: { kind: 'project'; projectId: string } | { kind: 'host_path'; path: string }; + title: string; + }>; + submissions: Array<{ sessionId: string; messageId: string; text: string }>; + }; + return state; +} diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts index 18bdd5a793..d78d325d19 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-coordinator.test.ts @@ -30,6 +30,10 @@ import { } from '@maka/core/session'; import { createSessionStore, type SessionAuthorityStore } from '@maka/storage/session-store'; import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; +import { + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, + WORKHUB_COORDINATION_TEXT_MAX_BYTES, +} from '../protocol/index.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import type { RootTurnCoordinator } from '../server/root-turn-coordinator.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; @@ -431,10 +435,26 @@ describe('Host WorkHub Coordination coordinator', () => { ok: true, result: { turnId: 'summary-turn' }, }); + const maximumInput = { + turnId: 'maximum-summary-turn', + // Each NUL is one UTF-8 input byte but six bytes once JSON-escaped in + // the durable transcript record. Retry lookup must budget for that + // worst case, not only the decoded text sizes. + userText: '\0'.repeat(WORKHUB_COORDINATION_TEXT_MAX_BYTES), + assistantText: '\0'.repeat(WORKHUB_COORDINATION_SUMMARY_MAX_BYTES), + }; + assert.deepEqual( + await workhub.handlers['workhub.coordination.record'](maximumInput, CONTEXT), + { ok: true, result: { turnId: 'maximum-summary-turn' } }, + ); + assert.deepEqual( + await workhub.handlers['workhub.coordination.record'](maximumInput, CONTEXT), + { ok: true, result: { turnId: 'maximum-summary-turn' } }, + ); const messages = await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID); - assert.equal(messages.length, 3); + assert.equal(messages.length, 6); assert.deepEqual( - messages.map(({ type, turnId }) => ({ type, turnId })), + messages.slice(0, 3).map(({ type, turnId }) => ({ type, turnId })), [ { type: 'user', turnId: 'summary-turn' }, { type: 'assistant', turnId: 'summary-turn' }, @@ -447,7 +467,7 @@ describe('Host WorkHub Coordination coordinator', () => { ); assert.equal(conflict.ok, false); if (!conflict.ok) assert.equal(conflict.error.code, 'operation_conflict'); - assert.equal((await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).length, 3); + assert.equal((await store.readMessagesSnapshot(WORKHUB_COORDINATION_SESSION_ID)).length, 6); const empty = await workhub.handlers['workhub.coordination.record']( { ...input, turnId: 'empty-summary', assistantText: ' ' }, CONTEXT, @@ -601,6 +621,10 @@ function coordinator( admission, continuity: { refreshCanonical: async () => undefined }, executions, + sessionActions: { + create: async () => undefined, + submit: async ({ sessionId }) => ({ turnId: `turn-${sessionId}` }), + }, resolveCreateTarget: resolveCreateTarget ?? (async () => ({ diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index db90fb8d0c..b188f97284 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -21,7 +21,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { RuntimeHostProtocolError } from '../protocol/errors.js'; import { + decodeWorkHubCoordinationActInput, + decodeWorkHubCoordinationActResult, decodeWorkHubCoordinationAnswerInput, + decodeWorkHubCoordinationCandidatesResult, decodeWorkHubCoordinationRecordInput, decodeWorkHubCoordinationResolveInput, decodeWorkHubCoordinationResolveResult, @@ -82,3 +85,167 @@ test('WorkHub Coordination answer and summary inputs are closed and bounded', () (error) => error instanceof RuntimeHostProtocolError, ); }); + +test('WorkHub Coordination candidates are bounded and carry opaque proposal identities', () => { + const result = decodeWorkHubCoordinationCandidatesResult({ + candidateSetId: `sha256:${'a'.repeat(64)}`, + candidates: [ + { + candidateRef: 'candidate_a', + sessionId: 'session-a', + sessionName: 'Payments', + workspace: { + target: { kind: 'host_path', path: '/workspace/payments' }, + hostCwd: '/workspace/payments', + }, + state: 'active', + updatedAt: 7, + }, + ], + }); + assert.equal(result.candidates[0]?.candidateRef, 'candidate_a'); + assert.equal(HOST_OPERATION_SPECS['workhub.coordination.candidates'].mode, 'query'); + assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.candidates'), true); + assert.throws( + () => + decodeWorkHubCoordinationCandidatesResult({ + candidateSetId: 'caller-invented', + candidates: [], + }), + (error) => error instanceof RuntimeHostProtocolError, + ); +}); + +test('WorkHub Coordination action input is a closed disposition union', () => { + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-answer', + userText: 'What changed?', + proposal: { disposition: 'answer_here' }, + }), + { + actionId: 'action-answer', + userText: 'What changed?', + proposal: { disposition: 'answer_here' }, + }, + ); + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-delegate', + userText: 'Continue payments', + candidateSetId: `sha256:${'b'.repeat(64)}`, + proposal: { disposition: 'delegate_existing', candidateRef: 'candidate_payments' }, + }).proposal, + { disposition: 'delegate_existing', candidateRef: 'candidate_payments' }, + ); + assert.deepEqual( + decodeWorkHubCoordinationActInput({ + actionId: 'action-create', + userText: 'Create an accessibility audit', + proposal: { disposition: 'create_new', title: 'Accessibility audit' }, + create: { + workspace: { kind: 'host_path', path: '/workspace' }, + }, + }).create, + { + workspace: { kind: 'host_path', path: '/workspace' }, + }, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-create-with-identity', + userText: 'Create an accessibility audit', + proposal: { disposition: 'create_new', title: 'Accessibility audit' }, + create: { + sessionId: 'renderer-invented', + workspace: { kind: 'host_path', path: '/workspace' }, + }, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-bypass', + userText: 'Continue payments', + proposal: { + disposition: 'delegate_existing', + candidateRef: 'candidate_payments', + sessionId: 'invented-session', + }, + candidateSetId: `sha256:${'c'.repeat(64)}`, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-escalate', + userText: 'Continue payments', + proposal: { disposition: 'answer_here' }, + permissionMode: 'bypass', + tools: ['shell'], + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-implicit-create', + userText: 'Continue payments', + proposal: { disposition: 'delegate_existing', candidateRef: 'candidate_payments' }, + candidateSetId: `sha256:${'d'.repeat(64)}`, + create: { + workspace: { kind: 'host_path', path: '/workspace' }, + }, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.throws( + () => + decodeWorkHubCoordinationActInput({ + actionId: 'action-replace', + userText: 'No, use login instead', + candidateSetId: `sha256:${'e'.repeat(64)}`, + proposal: { + disposition: 'delegate_existing', + candidateRef: 'candidate_login', + replace: { + candidateRef: 'candidate_payments', + expectedTurnId: 'turn-payments', + }, + }, + }), + (error) => error instanceof RuntimeHostProtocolError, + ); + assert.equal(HOST_OPERATION_SPECS['workhub.coordination.act'].mode, 'command'); + assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.act'), true); +}); + +test('WorkHub Coordination action results preserve the admitted disposition', () => { + assert.deepEqual( + decodeWorkHubCoordinationActResult({ + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'turn-payments', + steered: true, + }), + { + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'turn-payments', + steered: true, + }, + ); + assert.throws( + () => + decodeWorkHubCoordinationActResult({ + disposition: 'delegate_existing', + targetSessionId: 'payments', + targetTurnId: 'turn-payments', + permissionMode: 'bypass', + }), + (error) => error instanceof RuntimeHostProtocolError, + ); +}); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 30f01b2cb7..414fec8c33 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; +// 51: WorkHub exposes bounded coordination candidates and admits only typed +// actions through the deterministic Runtime Host Action Gate. // 50: WorkHub can append durable coordination summaries and admit tool-free // answers through its reserved Coordination Session authority. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 9d95dec0bf..745d53e0ea 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -326,6 +326,8 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'usage.query', 'web-search.execute', 'workhub.coordination.answer', + 'workhub.coordination.act', + 'workhub.coordination.candidates', 'workhub.coordination.record', 'workhub.coordination.resolve', ] as const satisfies readonly OperationKey[]); diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index 66866c0945..1af2559f70 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -17,11 +17,28 @@ * under the License. */ -import { requireEntityId, requireExactRecord, requireUtf8String } from './codec.js'; +import { + requireCount, + requireEntityId, + requireExactRecord, + requireRecord, + requireShapedRecord, + requireUtf8String, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; import { defineOperation } from './operation-spec.js'; +import { + decodeWorkspaceProjection, + decodeWorkspaceTarget, + type WorkspaceProjection, + type WorkspaceTarget, +} from './workspace.js'; -const COORDINATION_TEXT_MAX_BYTES = 48 * 1024; -const COORDINATION_SUMMARY_MAX_BYTES = 8 * 1024; +export const WORKHUB_COORDINATION_TEXT_MAX_BYTES = 48 * 1024; +export const WORKHUB_COORDINATION_SUMMARY_MAX_BYTES = 8 * 1024; +const COORDINATION_TITLE_MAX_BYTES = 512; +const CANDIDATE_SET_ID_MAX_BYTES = 96; +export const WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS = 32; const RESOLVE_ERRORS = [ 'host_not_ready', @@ -46,6 +63,14 @@ const TURN_ERRORS = [ 'internal_failure', ] as const; +const CANDIDATE_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'persistence_failed', + 'internal_failure', +] as const; + export type WorkHubCoordinationResolveInput = Record; export interface WorkHubCoordinationResolveResult { @@ -67,6 +92,69 @@ export interface WorkHubCoordinationTurnResult { readonly turnId: string; } +export type WorkHubCoordinationCandidateState = + | 'active' + | 'running' + | 'waiting_for_user' + | 'blocked' + | 'aborted'; + +export interface WorkHubCoordinationCandidate { + /** Opaque strategy-facing identity. Proposals never carry a Session id. */ + readonly candidateRef: string; + /** Presentation/navigation identity; adapters must not expose it to a model strategy. */ + readonly sessionId: string; + readonly sessionName: string; + readonly workspace: WorkspaceProjection; + readonly state: WorkHubCoordinationCandidateState; + readonly updatedAt: number; +} + +export type WorkHubCoordinationCandidatesInput = Record; + +export interface WorkHubCoordinationCandidatesResult { + readonly candidateSetId: string; + readonly candidates: readonly WorkHubCoordinationCandidate[]; +} + +export type WorkHubCoordinationProposal = + | { readonly disposition: 'answer_here' } + | { readonly disposition: 'clarify'; readonly assistantText: string } + | { + readonly disposition: 'delegate_existing'; + readonly candidateRef: string; + } + | { readonly disposition: 'create_new'; readonly title: string }; + +export interface WorkHubCoordinationCreateContext { + /** Trusted desktop context. Model/strategy output never contains a workspace or identity. */ + readonly workspace: WorkspaceTarget; +} + +export interface WorkHubCoordinationActInput { + readonly actionId: string; + readonly userText: string; + readonly proposal: WorkHubCoordinationProposal; + readonly candidateSetId?: string; + readonly create?: WorkHubCoordinationCreateContext; +} + +export type WorkHubCoordinationActResult = + | { readonly disposition: 'answer_here'; readonly coordinationTurnId: string } + | { readonly disposition: 'clarify'; readonly coordinationTurnId: string } + | { + readonly disposition: 'delegate_existing'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; + } + | { + readonly disposition: 'create_new'; + readonly targetSessionId: string; + readonly targetTurnId: string; + readonly steered?: true; + }; + export const WORKHUB_COORDINATION_OPERATION_SPECS = { 'workhub.coordination.resolve': defineOperation< WorkHubCoordinationResolveInput, @@ -101,6 +189,28 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = { decodeInput: decodeWorkHubCoordinationRecordInput, decodeOutput: decodeWorkHubCoordinationTurnResult, }), + 'workhub.coordination.candidates': defineOperation< + WorkHubCoordinationCandidatesInput, + WorkHubCoordinationCandidatesResult, + (typeof CANDIDATE_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: CANDIDATE_ERRORS, + decodeInput: decodeWorkHubCoordinationCandidatesInput, + decodeOutput: decodeWorkHubCoordinationCandidatesResult, + }), + 'workhub.coordination.act': defineOperation< + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + (typeof TURN_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: TURN_ERRORS, + decodeInput: decodeWorkHubCoordinationActInput, + decodeOutput: decodeWorkHubCoordinationActResult, + }), } as const; export function decodeWorkHubCoordinationResolveInput( @@ -128,7 +238,7 @@ export function decodeWorkHubCoordinationAnswerInput( text: requireUtf8String( input.text, 'WorkHub Coordination answer text', - COORDINATION_TEXT_MAX_BYTES, + WORKHUB_COORDINATION_TEXT_MAX_BYTES, ), }; } @@ -146,12 +256,12 @@ export function decodeWorkHubCoordinationRecordInput( userText: requireUtf8String( input.userText, 'WorkHub Coordination user text', - COORDINATION_TEXT_MAX_BYTES, + WORKHUB_COORDINATION_TEXT_MAX_BYTES, ), assistantText: requireUtf8String( input.assistantText, 'WorkHub Coordination assistant text', - COORDINATION_SUMMARY_MAX_BYTES, + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, ), }; } @@ -162,3 +272,186 @@ export function decodeWorkHubCoordinationTurnResult(value: unknown): WorkHubCoor turnId: requireEntityId(result.turnId, 'WorkHub Coordination Turn id'), }; } + +export function decodeWorkHubCoordinationCandidatesInput( + value: unknown, +): WorkHubCoordinationCandidatesInput { + requireExactRecord(value, 'WorkHub Coordination candidates input', []); + return {}; +} + +export function decodeWorkHubCoordinationCandidatesResult( + value: unknown, +): WorkHubCoordinationCandidatesResult { + const result = requireExactRecord(value, 'WorkHub Coordination candidates result', [ + 'candidateSetId', + 'candidates', + ]); + if (!Array.isArray(result.candidates)) { + throw invalidProtocolFrame('Invalid WorkHub Coordination candidates'); + } + if (result.candidates.length > WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS) { + throw invalidProtocolFrame('Too many WorkHub Coordination candidates'); + } + return { + candidateSetId: candidateSetId(result.candidateSetId), + candidates: result.candidates.map(decodeWorkHubCoordinationCandidate), + }; +} + +export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordinationActInput { + const input = requireShapedRecord( + value, + 'WorkHub Coordination action input', + ['actionId', 'userText', 'proposal'], + ['candidateSetId', 'create'], + ); + const proposal = decodeWorkHubCoordinationProposal(input.proposal); + const base = { + actionId: requireEntityId(input.actionId, 'WorkHub Coordination action id'), + userText: requireUtf8String( + input.userText, + 'WorkHub Coordination action text', + WORKHUB_COORDINATION_TEXT_MAX_BYTES, + ), + proposal, + }; + if (proposal.disposition === 'delegate_existing') { + if (input.create !== undefined || input.candidateSetId === undefined) { + throw invalidProtocolFrame('Invalid WorkHub delegation context'); + } + return { ...base, candidateSetId: candidateSetId(input.candidateSetId) }; + } + if (proposal.disposition === 'create_new') { + if (input.candidateSetId !== undefined || input.create === undefined) { + throw invalidProtocolFrame('Invalid WorkHub creation context'); + } + return { ...base, create: decodeWorkHubCoordinationCreateContext(input.create) }; + } + if (input.candidateSetId !== undefined || input.create !== undefined) { + throw invalidProtocolFrame('Unexpected WorkHub action context'); + } + return base; +} + +export function decodeWorkHubCoordinationActResult(value: unknown): WorkHubCoordinationActResult { + const result = requireRecord(value, 'WorkHub Coordination action result'); + if (result.disposition === 'answer_here' || result.disposition === 'clarify') { + const exact = requireExactRecord(result, 'WorkHub Coordination local action result', [ + 'disposition', + 'coordinationTurnId', + ]); + return { + disposition: result.disposition, + coordinationTurnId: requireEntityId(exact.coordinationTurnId, 'WorkHub Coordination Turn id'), + }; + } + if (result.disposition === 'delegate_existing' || result.disposition === 'create_new') { + const exact = requireShapedRecord( + result, + 'WorkHub Coordination execution action result', + ['disposition', 'targetSessionId', 'targetTurnId'], + ['steered'], + ); + if (exact.steered !== undefined && exact.steered !== true) { + throw invalidProtocolFrame('Invalid WorkHub Coordination steering result'); + } + return { + disposition: result.disposition, + targetSessionId: requireEntityId(exact.targetSessionId, 'WorkHub target Session id'), + targetTurnId: requireEntityId(exact.targetTurnId, 'WorkHub target Turn id'), + ...(exact.steered === true ? { steered: true as const } : {}), + }; + } + throw invalidProtocolFrame('Invalid WorkHub Coordination action disposition'); +} + +function decodeWorkHubCoordinationCandidate(value: unknown): WorkHubCoordinationCandidate { + const candidate = requireExactRecord(value, 'WorkHub Coordination candidate', [ + 'candidateRef', + 'sessionId', + 'sessionName', + 'workspace', + 'state', + 'updatedAt', + ]); + return { + candidateRef: requireEntityId(candidate.candidateRef, 'WorkHub candidate ref'), + sessionId: requireEntityId(candidate.sessionId, 'WorkHub candidate Session id'), + sessionName: requireUtf8String(candidate.sessionName, 'WorkHub candidate name', 512), + workspace: decodeWorkspaceProjection(candidate.workspace), + state: candidateState(candidate.state), + updatedAt: requireCount(candidate.updatedAt, 'WorkHub candidate update time'), + }; +} + +function decodeWorkHubCoordinationProposal(value: unknown): WorkHubCoordinationProposal { + const proposal = requireRecord(value, 'WorkHub Coordination proposal'); + if (proposal.disposition === 'answer_here') { + requireExactRecord(proposal, 'WorkHub answer proposal', ['disposition']); + return { disposition: 'answer_here' }; + } + if (proposal.disposition === 'clarify') { + const exact = requireExactRecord(proposal, 'WorkHub clarification proposal', [ + 'disposition', + 'assistantText', + ]); + return { + disposition: 'clarify', + assistantText: requireUtf8String( + exact.assistantText, + 'WorkHub clarification text', + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, + ), + }; + } + if (proposal.disposition === 'delegate_existing') { + const exact = requireExactRecord(proposal, 'WorkHub delegation proposal', [ + 'disposition', + 'candidateRef', + ]); + return { + disposition: 'delegate_existing', + candidateRef: requireEntityId(exact.candidateRef, 'WorkHub candidate ref'), + }; + } + if (proposal.disposition === 'create_new') { + const exact = requireExactRecord(proposal, 'WorkHub creation proposal', [ + 'disposition', + 'title', + ]); + return { + disposition: 'create_new', + title: requireUtf8String(exact.title, 'WorkHub Session title', COORDINATION_TITLE_MAX_BYTES), + }; + } + throw invalidProtocolFrame('Invalid WorkHub Coordination proposal disposition'); +} + +function decodeWorkHubCoordinationCreateContext(value: unknown): WorkHubCoordinationCreateContext { + const context = requireExactRecord(value, 'WorkHub creation context', ['workspace']); + return { + workspace: decodeWorkspaceTarget(context.workspace), + }; +} + +function candidateSetId(value: unknown): string { + const id = requireUtf8String(value, 'WorkHub candidate set id', CANDIDATE_SET_ID_MAX_BYTES); + if (!/^sha256:[a-f0-9]{64}$/u.test(id)) { + throw invalidProtocolFrame('Invalid WorkHub candidate set id'); + } + return id; +} + +function candidateState(value: unknown): WorkHubCoordinationCandidateState { + if ( + value === 'active' || + value === 'running' || + value === 'waiting_for_user' || + value === 'blocked' || + value === 'aborted' + ) { + return value; + } + throw invalidProtocolFrame('Invalid WorkHub candidate state'); +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index ef0f62ba3d..cf46c22b9c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -18,6 +18,7 @@ */ import { createHash, randomUUID } from 'node:crypto'; +import { normalizeMessageContent } from '@maka/core/events'; import { describeChatConfigurationReason, NO_REAL_CONNECTION_CODE, @@ -166,6 +167,7 @@ import { HostTurnControlCoordinator } from './turn-control-coordinator.js'; import { HostUsagePricingCoordinator } from './usage-pricing-coordinator.js'; import { HostWebSearchCoordinator } from './web-search-coordinator.js'; import { HostWorkHubCoordinationCoordinator } from './workhub-coordination-coordinator.js'; +import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; import { createHostWebSearchService, createHostWebSearchToolFromService, @@ -1226,6 +1228,47 @@ export async function createExecutionRuntimeHostComposition( admission: sessionAdmission, continuity: continuityCoordinator, executions: coordinator, + sessionActions: { + create: async (input) => { + const outcome = await sessionCatalog.createForWorkHub({ + sessionId: input.sessionId, + workspace: input.workspace, + name: input.title, + modelTarget: { kind: 'default' }, + collaborationMode: 'agent', + orchestrationMode: 'default', + }); + if (!outcome.ok) { + throw new WorkHubActionEffectFailure( + outcome.error.code === 'invalid_request' ? 'operation_conflict' : outcome.error.code, + outcome.error.message, + ); + } + }, + submit: async (input, connection) => { + const outcome = await messages.handlers['turn.message.submit']( + { + originHostEpoch: connection.hostEpoch, + sessionId: input.sessionId, + messageId: input.messageId, + content: normalizeMessageContent({ text: input.text }), + placement: 'current_turn', + }, + connection, + ); + if (!outcome.ok) { + throw new WorkHubActionEffectFailure( + outcome.error.code === 'outcome_unknown' + ? 'commit_outcome_unknown' + : outcome.error.code, + outcome.error.message, + ); + } + return outcome.result.disposition === 'turn_started' + ? { turnId: outcome.result.turnId } + : { turnId: input.messageId, steered: true as const }; + }, + }, resolveCreateTarget: async () => { const { projectId: _projectId, ...target } = await sessionCatalog.resolveExternalSessionImportTarget(); diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 695536424a..01f7556259 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -199,6 +199,11 @@ export class HostSessionCatalogCoordinator { if (!outcome.ok) throw new Error(outcome.error.message); } + /** WorkHub Action Gate path; callers cannot bypass the typed operation outcome. */ + createForWorkHub(input: SessionCreateInput): Promise> { + return this.#create(input); + } + async #query( input: SessionCatalogQueryInput, ): Promise> { diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts new file mode 100644 index 0000000000..bf95b82ae8 --- /dev/null +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -0,0 +1,372 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import type { SessionHeader, SessionStatus } from '@maka/core/session'; +import { + WORKHUB_COORDINATION_SESSION_ID, + isWorkHubCoordinationSessionTarget, +} from '@maka/core/session'; +import type { + WorkHubCoordinationActInput, + WorkHubCoordinationActResult, + WorkHubCoordinationCandidate, + WorkHubCoordinationCandidatesResult, + WorkspaceTarget, + WorkspaceProjection, +} from '../protocol/index.js'; +import { WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS } from '../protocol/index.js'; +import type { ConnectionContext } from './operation-dispatcher.js'; + +const SIDE_CONVERSATION_LABEL = 'mode:side_conversation'; +const ACTION_REPLAY_MAX_ITEMS = 256; + +export type WorkHubActionGateSession = Pick< + SessionHeader, + | 'id' + | 'role' + | 'cwd' + | 'projectId' + | 'createdAt' + | 'lastMessageAt' + | 'name' + | 'labels' + | 'isArchived' + | 'status' + | 'statusUpdatedAt' + | 'subagentParent' +>; + +export interface WorkHubActionGateEffects { + listSessions(): Promise; + answer( + input: { readonly turnId: string; readonly text: string }, + context: ConnectionContext, + ): Promise; + clarify(input: { + readonly turnId: string; + readonly userText: string; + readonly assistantText: string; + }): Promise; + create(input: { + readonly sessionId: string; + readonly workspace: WorkspaceTarget; + readonly title: string; + }): Promise; + submit( + input: { + readonly sessionId: string; + readonly messageId: string; + readonly text: string; + }, + context: ConnectionContext, + ): Promise<{ readonly turnId: string; readonly steered?: true }>; +} + +export type WorkHubActionEffectFailureCode = + | 'host_not_ready' + | 'host_draining' + | 'operation_unavailable' + | 'not_found' + | 'session_archived' + | 'session_busy' + | 'operation_conflict' + | 'persistence_failed' + | 'commit_outcome_unknown' + | 'internal_failure' + | 'unauthorized'; + +export class WorkHubActionEffectFailure extends Error { + constructor( + readonly code: WorkHubActionEffectFailureCode, + message: string, + ) { + super(message); + this.name = 'WorkHubActionEffectFailure'; + } +} + +export type WorkHubActionGateFailureCode = + | 'candidate_set_stale' + | 'candidate_unavailable' + | 'target_waiting_for_user' + | 'self_route' + | 'action_conflict'; + +export class WorkHubActionGateFailure extends Error { + constructor( + readonly code: WorkHubActionGateFailureCode, + message: string, + ) { + super(message); + this.name = 'WorkHubActionGateFailure'; + } +} + +interface ActionReplay { + readonly fingerprint: string; + readonly result: Promise; +} + +/** + * The sole admission module between a WorkHub strategy proposal and Session effects. + * + * Candidate discovery and fresh-state validation deliberately live behind the + * same interface as execution. A caller cannot turn a model-selected Session id + * into a write because proposals carry only an opaque candidateRef. + */ +export class WorkHubCoordinationActionGate { + readonly #effects: WorkHubActionGateEffects; + readonly #actions = new Map(); + + constructor(effects: WorkHubActionGateEffects) { + this.#effects = effects; + } + + async candidates(): Promise { + return candidateSet(await this.#effects.listSessions()); + } + + act( + input: WorkHubCoordinationActInput, + context: ConnectionContext, + ): Promise { + const fingerprint = digest(input); + const replay = this.#actions.get(input.actionId); + if (replay) { + if (replay.fingerprint !== fingerprint) { + return Promise.reject( + new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub action identity belongs to a different proposal', + ), + ); + } + return replay.result; + } + + const result = this.#act(input, context); + const action = { fingerprint, result }; + this.#actions.set(input.actionId, action); + // Successful actions remain replayable. A rejected admission does not own + // the action identity forever: callers must be able to refresh stale + // candidates or satisfy an actionable precondition and retry safely. + void result.catch(() => { + if (this.#actions.get(input.actionId) === action) { + this.#actions.delete(input.actionId); + } + }); + this.#boundReplays(); + return result; + } + + async #act( + input: WorkHubCoordinationActInput, + context: ConnectionContext, + ): Promise { + const proposal = input.proposal; + if (proposal.disposition === 'answer_here') { + const turnId = coordinationTurnId(input.actionId, 'answer'); + await this.#effects.answer({ turnId, text: input.userText }, context); + return { disposition: 'answer_here', coordinationTurnId: turnId }; + } + if (proposal.disposition === 'clarify') { + const turnId = coordinationTurnId(input.actionId, 'clarify'); + await this.#effects.clarify({ + turnId, + userText: input.userText, + assistantText: proposal.assistantText, + }); + return { disposition: 'clarify', coordinationTurnId: turnId }; + } + if (proposal.disposition === 'create_new') { + if (!input.create) { + throw new WorkHubActionGateFailure( + 'action_conflict', + 'WorkHub creation context is unavailable', + ); + } + const sessionId = workHubCreatedSessionId(input.actionId); + await this.#effects.create({ + sessionId, + workspace: input.create.workspace, + title: proposal.title, + }); + const submitted = await this.#effects.submit( + { + sessionId, + messageId: actionMessageId(input.actionId), + text: input.userText, + }, + context, + ); + return executionResult('create_new', sessionId, submitted); + } + + const candidates = await this.candidates(); + if (candidates.candidateSetId !== input.candidateSetId) { + throw new WorkHubActionGateFailure( + 'candidate_set_stale', + 'WorkHub Session candidates changed; refresh before delegating', + ); + } + const target = candidates.candidates.find( + (candidate) => candidate.candidateRef === proposal.candidateRef, + ); + if (!target) { + throw new WorkHubActionGateFailure( + 'candidate_unavailable', + 'WorkHub target is not in the admitted candidate set', + ); + } + this.#assertTarget(target); + + return this.#submitExisting(input, target, context); + } + + async #submitExisting( + input: WorkHubCoordinationActInput, + target: WorkHubCoordinationCandidate, + context: ConnectionContext, + ): Promise { + const submitted = await this.#effects.submit( + { + sessionId: target.sessionId, + messageId: actionMessageId(input.actionId), + text: input.userText, + }, + context, + ); + return executionResult('delegate_existing', target.sessionId, submitted); + } + + #assertTarget(target: WorkHubCoordinationCandidate): void { + if (target.sessionId === WORKHUB_COORDINATION_SESSION_ID) { + throw new WorkHubActionGateFailure('self_route', 'WorkHub cannot delegate to itself'); + } + if (target.state === 'waiting_for_user') { + throw new WorkHubActionGateFailure( + 'target_waiting_for_user', + 'Target Session is waiting for user input', + ); + } + } + + #boundReplays(): void { + while (this.#actions.size > ACTION_REPLAY_MAX_ITEMS) { + const oldest = this.#actions.keys().next().value; + if (oldest === undefined) return; + this.#actions.delete(oldest); + } + } +} + +export function candidateSet( + sessions: readonly WorkHubActionGateSession[], +): WorkHubCoordinationCandidatesResult { + const eligible = sessions + .filter(isCandidateSession) + .sort((left, right) => updatedAt(right) - updatedAt(left) || left.id.localeCompare(right.id)) + .slice(0, WORKHUB_COORDINATION_CANDIDATE_MAX_ITEMS); + const candidateSetId = digest( + eligible.map((session) => ({ + id: session.id, + name: session.name, + workspace: workspaceProjection(session), + status: session.status, + updatedAt: updatedAt(session), + })), + ); + return { + candidateSetId, + candidates: eligible.map((session) => ({ + candidateRef: candidateRef(candidateSetId, session.id), + sessionId: session.id, + sessionName: session.name, + workspace: workspaceProjection(session), + state: candidateState(session.status), + updatedAt: updatedAt(session), + })), + }; +} + +function isCandidateSession(session: WorkHubActionGateSession): boolean { + return ( + !session.isArchived && + !isWorkHubCoordinationSessionTarget(session) && + session.role === undefined && + session.subagentParent === undefined && + !session.labels.includes(SIDE_CONVERSATION_LABEL) + ); +} + +function candidateRef(candidateSetId: string, sessionId: string): string { + return `whc_${hash(`${candidateSetId}\0${sessionId}`).slice(0, 48)}`; +} + +function coordinationTurnId(actionId: string, kind: 'answer' | 'clarify'): string { + return `wha_${hash(`${actionId}\0${kind}`).slice(0, 48)}`; +} + +function actionMessageId(actionId: string): string { + return `whm_${hash(actionId).slice(0, 48)}`; +} + +function workHubCreatedSessionId(actionId: string): string { + return `whs_${hash(`create\0${actionId}`).slice(0, 48)}`; +} + +function workspaceProjection(session: WorkHubActionGateSession): WorkspaceProjection { + return { + target: + typeof session.projectId === 'string' + ? { kind: 'project', projectId: session.projectId } + : { kind: 'host_path', path: session.cwd }, + hostCwd: session.cwd, + }; +} + +function candidateState(status: SessionStatus): WorkHubCoordinationCandidate['state'] { + return status; +} + +function updatedAt(session: WorkHubActionGateSession): number { + return session.lastMessageAt ?? session.statusUpdatedAt ?? session.createdAt; +} + +function executionResult( + disposition: 'delegate_existing' | 'create_new', + sessionId: string, + submitted: { readonly turnId: string; readonly steered?: true }, +): WorkHubCoordinationActResult { + return { + disposition, + targetSessionId: sessionId, + targetTurnId: submitted.turnId, + ...(submitted.steered ? { steered: true as const } : {}), + } as WorkHubCoordinationActResult; +} + +function digest(value: unknown): `sha256:${string}` { + return `sha256:${hash(JSON.stringify(value))}`; +} + +function hash(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index af08e6c419..a90e8449b9 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -33,9 +33,14 @@ import { import type { SessionAuthorityStore, SessionHeaderSnapshot } from '@maka/storage/session-store'; import type { OperationOutcome, + WorkHubCoordinationActInput, WorkHubCoordinationAnswerInput, WorkHubCoordinationRecordInput, } from '../protocol/index.js'; +import { + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES, + WORKHUB_COORDINATION_TEXT_MAX_BYTES, +} from '../protocol/index.js'; import type { ConnectionContext, WorkHubCoordinationOperationHandlerMap, @@ -44,6 +49,12 @@ import type { RootTurnCoordinator } from './root-turn-coordinator.js'; import { SessionAdmissionGate } from './session-admission-gate.js'; import { SessionOperationFailure } from './session-catalog-coordinator.js'; import type { SessionContinuityCoordinator } from './session-continuity-coordinator.js'; +import { + WorkHubActionEffectFailure, + WorkHubActionGateFailure, + WorkHubCoordinationActionGate, + type WorkHubActionGateEffects, +} from './workhub-coordination-action-gate.js'; const CREATE_FINGERPRINT = `sha256:${createHash('sha256') .update('maka:workhub-coordination-session:v1', 'utf8') @@ -57,11 +68,18 @@ const COORDINATION_ORCHESTRATION_MODE = 'default' as const; const COORDINATION_SUMMARY_MESSAGE_KINDS = ['user', 'assistant', 'state'] as const; const TURN_IDENTITY_CONFLICT_MESSAGE = 'WorkHub Coordination Turn identity belongs to a different operation'; +// A one-byte control character can occupy six bytes as a JSON `\u0000` escape. +const JSON_ESCAPE_MAX_BYTES_PER_INPUT_BYTE = 6; +const COORDINATION_SUMMARY_READ_MAX_BYTES = + JSON_ESCAPE_MAX_BYTES_PER_INPUT_BYTE * + (WORKHUB_COORDINATION_TEXT_MAX_BYTES + WORKHUB_COORDINATION_SUMMARY_MAX_BYTES) + + 16 * 1024; type CoordinationStores = Pick< SessionAuthorityStore, | 'appendMessages' | 'createStableSession' + | 'listHeaders' | 'probeStableSessionCreate' | 'readHeaderSnapshot' | 'readTranscriptHighWaterSnapshot' @@ -82,6 +100,7 @@ export interface HostWorkHubCoordinationCoordinatorOptions { readonly admission: SessionAdmissionGate; readonly continuity: Pick; readonly executions: CoordinationExecutions; + readonly sessionActions: Pick; readonly resolveCreateTarget: () => Promise; readonly requestDrain: () => void; } @@ -92,6 +111,8 @@ export class HostWorkHubCoordinationCoordinator { 'workhub.coordination.resolve': () => this.#resolve(), 'workhub.coordination.answer': (input, context) => this.#answer(input, context), 'workhub.coordination.record': (input) => this.#record(input), + 'workhub.coordination.candidates': () => this.#candidates(), + 'workhub.coordination.act': (input, context) => this.#act(input, context), }; readonly #coordinationCwd: string; @@ -101,6 +122,7 @@ export class HostWorkHubCoordinationCoordinator { readonly #executions: CoordinationExecutions; readonly #resolveCreateTarget: () => Promise; readonly #requestDrain: () => void; + readonly #actionGate: WorkHubCoordinationActionGate; constructor(options: HostWorkHubCoordinationCoordinatorOptions) { this.#coordinationCwd = join(options.stateRoot, COORDINATION_CWD_DIRECTORY); @@ -110,6 +132,76 @@ export class HostWorkHubCoordinationCoordinator { this.#executions = options.executions; this.#resolveCreateTarget = options.resolveCreateTarget; this.#requestDrain = options.requestDrain; + this.#actionGate = new WorkHubCoordinationActionGate({ + listSessions: () => this.#stores.listHeaders(), + answer: async (input, context) => { + const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); + if (!outcome.ok) { + throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); + } + }, + clarify: async (input) => { + const outcome = await this.#record({ + turnId: input.turnId, + userText: input.userText, + assistantText: input.assistantText, + }); + if (!outcome.ok) { + throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); + } + }, + create: options.sessionActions.create, + submit: options.sessionActions.submit, + }); + } + + async #candidates(): Promise> { + try { + return { ok: true, result: await this.#actionGate.candidates() }; + } catch { + return { + ok: false, + error: { + code: 'persistence_failed', + message: 'WorkHub Session candidates are unavailable', + }, + }; + } + } + + async #act( + input: WorkHubCoordinationActInput, + context: ConnectionContext, + ): Promise> { + try { + return { ok: true, result: await this.#actionGate.act(input, context) }; + } catch (error) { + if (error instanceof WorkHubActionEffectFailure) { + return { + ok: false, + error: { + code: error.code === 'unauthorized' ? 'operation_unavailable' : error.code, + message: error.message, + }, + }; + } + if (error instanceof WorkHubActionGateFailure) { + return { + ok: false, + error: { + code: error.code === 'target_waiting_for_user' ? 'session_busy' : 'operation_conflict', + message: error.message, + }, + }; + } + return { + ok: false, + error: { + code: 'persistence_failed', + message: 'WorkHub action authority is unavailable', + }, + }; + } } #resolve(): Promise> { @@ -311,7 +403,7 @@ export class HostWorkHubCoordinationCoordinator { coordinationSummaryMessageId(turnId, kind), ), throughSequence, - maxBytes: 32 * 1024, + maxBytes: COORDINATION_SUMMARY_READ_MAX_BYTES, maxMessages: COORDINATION_SUMMARY_MESSAGE_KINDS.length, }); }