diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e57c86aa041..e040e007cb7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -16,6 +16,7 @@ import type { TaskSourceContext } from '../shared/task-source-context' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { OrchestrationPreloadApi } from '../shared/orchestration-binding' import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest @@ -1835,6 +1836,12 @@ export type PreloadApi = { installWsl: (args?: { distro?: string | null }) => Promise removeWsl: (args?: { distro?: string | null }) => Promise } + // Why (#15): exposes the already-registered orchestration RPC methods to the + // renderer so a recipe director can start a coordinator run (or create a task) + // from the app, over the same runtime channel the CLI uses. Additive plumbing — + // no UI calls it yet (recipe backend, #9). Routes through runtime.call, so it + // works for local and remote/SSH runtimes alike. + orchestration: OrchestrationPreloadApi agentHooks: { claudeStatus: () => Promise openClaudeStatus: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 7852bd91f31..387ada566b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,14 @@ import type { RuntimeTerminalDriverState } from '../shared/runtime-types' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import { + ORCHESTRATION_RUN_RPC_METHOD, + ORCHESTRATION_TASK_CREATE_RPC_METHOD, + type OrchestrationRunParams, + type OrchestrationRunResult, + type OrchestrationTaskCreateParams, + type OrchestrationTaskCreateResult +} from '../shared/orchestration-binding' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RemoteWorkspaceChangedEvent } from '../shared/remote-workspace-types' import type { @@ -407,6 +415,21 @@ document.addEventListener( true ) +// Why (#15): orchestration methods are registered as runtime RPC methods (not +// dedicated IPC channels), so the binding forwards through the existing +// `runtime:call` envelope and unwraps it — the same path local/remote runtimes +// already use, keeping the renderer binding SSH-safe without a new main handler. +async function callOrchestrationRpc(method: string, params: unknown): Promise { + const response = (await ipcRenderer.invoke('runtime:call', { + method, + params + })) as RuntimeRpcResponse + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result +} + // Custom APIs for renderer const api = { app: { @@ -1687,6 +1710,16 @@ const api = { ipcRenderer.invoke('cli:removeWsl', args) }, + orchestration: { + run: (params: OrchestrationRunParams): Promise => + callOrchestrationRpc(ORCHESTRATION_RUN_RPC_METHOD, params), + taskCreate: (params: OrchestrationTaskCreateParams): Promise => + callOrchestrationRpc( + ORCHESTRATION_TASK_CREATE_RPC_METHOD, + params + ) + }, + agentHooks: { claudeStatus: (): Promise => ipcRenderer.invoke('agentHooks:claudeStatus'), diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 08b3d91f78a..87b7f047c9d 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -1477,6 +1477,131 @@ describe('web repos preload API', () => { ) }) +describe('web orchestration preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('forwards orchestration.run params (incl. worktree-backed pass-throughs) and unwraps the result', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { runId: 'run-1', status: 'running' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const runParams = { + spec: 'ship the recipe', + from: 'coordinator-abc', + pollIntervalMs: 1000, + maxConcurrent: 3, + worktree: 'repo-1::/work/dir', + worktreeBacked: true, + workerAgent: 'claude' + } + await expect(globals.window.api.orchestration.run(runParams)).resolves.toEqual({ + runId: 'run-1', + status: 'running' + }) + expect(runtimeCalls).toEqual([{ method: 'orchestration.run', params: runParams }]) + }) + + it('forwards orchestration.taskCreate params (incl. callerTerminalHandle) and unwraps the task', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + const task = { + id: 'task-1', + parent_id: null, + created_by_terminal_handle: 'terminal-7', + coordinator_run_id: null, + target_key: 'repo-1::/work/dir', + task_title: 'Recipe step', + display_name: null, + spec: 'do the thing', + status: 'ready', + deps: '[]', + result: null, + created_at: '2026-01-01T00:00:00.000Z', + completed_at: null + } + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { task }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const taskParams = { + spec: 'do the thing', + taskTitle: 'Recipe step', + deps: '["task-0"]', + parent: 'task-root', + callerTerminalHandle: 'terminal-7' + } + await expect(globals.window.api.orchestration.taskCreate(taskParams)).resolves.toEqual({ task }) + expect(runtimeCalls).toEqual([{ method: 'orchestration.taskCreate', params: taskParams }]) + }) + + it('rejects when the runtime reports an orchestration failure', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: false, + error: { code: 'runtime_error', message: 'no resolvable worktree' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.orchestration.run({ spec: 'x' })).rejects.toThrow( + 'no resolvable worktree' + ) + }) +}) + describe('web worktree preload API', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4eaf43372f0..d2740a3295b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -97,6 +97,12 @@ import { type FeatureInteractionState } from '../../../shared/feature-interactions' import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours' +import { + ORCHESTRATION_RUN_RPC_METHOD, + ORCHESTRATION_TASK_CREATE_RPC_METHOD, + type OrchestrationRunResult, + type OrchestrationTaskCreateResult +} from '../../../shared/orchestration-binding' import { translate } from '@/i18n/i18n' import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' @@ -629,6 +635,7 @@ function createWebPreloadApi(): Partial { codexAccounts: createAccountsApi(), claudeAccounts: createAccountsApi(), cli: createCliApi(), + orchestration: createOrchestrationApi(), agentHooks: createAgentHooksApi(), developerPermissions: createDeveloperPermissionsApi(), computerUsePermissions: createComputerUsePermissionsApi(), @@ -2212,6 +2219,18 @@ function createCliApi(): NonNullable['cli']> { } as NonNullable['cli']> } +// Why (#15): orchestration RPC methods run on the paired runtime, so the web +// binding forwards through callRuntimeResult — the same unwrap path every other +// runtime-backed namespace uses — keeping it correct for remote/SSH hosts. +function createOrchestrationApi(): NonNullable['orchestration']> { + return { + run: (params) => + callRuntimeResult(ORCHESTRATION_RUN_RPC_METHOD, params), + taskCreate: (params) => + callRuntimeResult(ORCHESTRATION_TASK_CREATE_RPC_METHOD, params) + } +} + function createAgentHooksApi(): NonNullable['agentHooks']> { const status = ( agent: diff --git a/src/shared/orchestration-binding.ts b/src/shared/orchestration-binding.ts new file mode 100644 index 00000000000..36f7e00d497 --- /dev/null +++ b/src/shared/orchestration-binding.ts @@ -0,0 +1,93 @@ +// Renderer-facing param/return contract for the orchestration preload binding +// (`window.api.orchestration.*`). Mirrors the shapes the already-registered RPC +// methods define in src/main/runtime/rpc/methods/orchestration{,-gates}.ts so the +// renderer can start a coordinator run (or create a task) over the same runtime +// channel the CLI uses. Kept in `shared` because preload, renderer, and any +// future client need one contract without importing main internals across the +// process/layer boundary (preload never depends on main). + +/** RPC method names this binding forwards to — kept as constants so the preload + * implementations and tests reference one source of truth, not stringly-typed + * literals that can drift from the registered method names. */ +export const ORCHESTRATION_RUN_RPC_METHOD = 'orchestration.run' +export const ORCHESTRATION_TASK_CREATE_RPC_METHOD = 'orchestration.taskCreate' + +/** Lifecycle status of a coordinator run. Mirrors CoordinatorStatus in + * src/main/runtime/orchestration/types.ts. */ +export type OrchestrationCoordinatorStatus = 'idle' | 'running' | 'completed' | 'failed' + +/** Lifecycle status of a task. Mirrors TaskStatus in the orchestration types. */ +export type OrchestrationTaskStatus = + | 'pending' + | 'ready' + | 'dispatched' + | 'completed' + | 'failed' + | 'blocked' + +/** A persisted orchestration task row as returned by orchestration.taskCreate. + * Mirrors TaskRow in src/main/runtime/orchestration/types.ts; the RPC boundary + * serializes to JSON, so the snake_case DB column names are preserved as-is. */ +export type OrchestrationTask = { + id: string + parent_id: string | null + created_by_terminal_handle: string | null + coordinator_run_id: string | null + target_key: string | null + task_title: string | null + display_name: string | null + spec: string + status: OrchestrationTaskStatus + deps: string + result: string | null + created_at: string + completed_at: string | null +} + +/** Params for orchestration.run. Field names match the RPC Zod schema (RunParams) + * so they pass straight through unchanged. `worktree`/target-key handling (F1) + * and `worktreeBacked`/`workerAgent` (F2 slice 1) are forwarded verbatim — the + * semantics live in the main handler, not here. */ +export type OrchestrationRunParams = { + spec: string + from?: string + pollIntervalMs?: number + maxConcurrent?: number + worktree?: string + worktreeBacked?: boolean + workerAgent?: string +} + +/** Result of orchestration.run. The run starts in the background and is polled + * via orchestration.taskList; `status` is 'running' on a successful start and is + * typed to the run-status union for forward compatibility. */ +export type OrchestrationRunResult = { + runId: string + status: OrchestrationCoordinatorStatus +} + +/** Params for orchestration.taskCreate. Field names match the RPC Zod schema + * (TaskCreateParams). `deps` is a JSON-encoded array of task IDs (the handler + * parses it); `callerTerminalHandle` stamps the task's own target so adoption + * binds it only to a same-target run. */ +export type OrchestrationTaskCreateParams = { + spec: string + taskTitle?: string + displayName?: string + deps?: string + parent?: string + callerTerminalHandle?: string +} + +/** Result of orchestration.taskCreate. */ +export type OrchestrationTaskCreateResult = { + task: OrchestrationTask +} + +/** The renderer-facing orchestration namespace exposed at + * `window.api.orchestration`. Additive plumbing — nothing in the UI calls it yet + * (the recipe backend, #9, is the first consumer). */ +export type OrchestrationPreloadApi = { + run: (params: OrchestrationRunParams) => Promise + taskCreate: (params: OrchestrationTaskCreateParams) => Promise +}