Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/preload/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1835,6 +1836,12 @@ export type PreloadApi = {
installWsl: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
removeWsl: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
}
// 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<AgentHookInstallStatus>
openClaudeStatus: () => Promise<AgentHookInstallStatus>
Expand Down
33 changes: 33 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<TResult>(method: string, params: unknown): Promise<TResult> {
const response = (await ipcRenderer.invoke('runtime:call', {
method,
params
})) as RuntimeRpcResponse<TResult>
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result
}

// Custom APIs for renderer
const api = {
app: {
Expand Down Expand Up @@ -1687,6 +1710,16 @@ const api = {
ipcRenderer.invoke('cli:removeWsl', args)
},

orchestration: {
run: (params: OrchestrationRunParams): Promise<OrchestrationRunResult> =>
callOrchestrationRpc<OrchestrationRunResult>(ORCHESTRATION_RUN_RPC_METHOD, params),
taskCreate: (params: OrchestrationTaskCreateParams): Promise<OrchestrationTaskCreateResult> =>
callOrchestrationRpc<OrchestrationTaskCreateResult>(
ORCHESTRATION_TASK_CREATE_RPC_METHOD,
params
)
},

agentHooks: {
claudeStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:claudeStatus'),
Expand Down
125 changes: 125 additions & 0 deletions src/renderer/src/web/web-preload-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeRpcResponse<unknown>> {
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<RuntimeRpcResponse<unknown>> {
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<RuntimeRpcResponse<unknown>> {
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()
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/src/web/web-preload-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -629,6 +635,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
codexAccounts: createAccountsApi(),
claudeAccounts: createAccountsApi(),
cli: createCliApi(),
orchestration: createOrchestrationApi(),
agentHooks: createAgentHooksApi(),
developerPermissions: createDeveloperPermissionsApi(),
computerUsePermissions: createComputerUsePermissionsApi(),
Expand Down Expand Up @@ -2212,6 +2219,18 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
} as NonNullable<Partial<PreloadApi>['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<Partial<PreloadApi>['orchestration']> {
return {
run: (params) =>
callRuntimeResult<OrchestrationRunResult>(ORCHESTRATION_RUN_RPC_METHOD, params),
taskCreate: (params) =>
callRuntimeResult<OrchestrationTaskCreateResult>(ORCHESTRATION_TASK_CREATE_RPC_METHOD, params)
}
}

function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
const status = (
agent:
Expand Down
93 changes: 93 additions & 0 deletions src/shared/orchestration-binding.ts
Original file line number Diff line number Diff line change
@@ -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<OrchestrationRunResult>
taskCreate: (params: OrchestrationTaskCreateParams) => Promise<OrchestrationTaskCreateResult>
}