mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
refactor(runtime): split runtime-rpc.ts into a schema-validated method registry (#980)
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Why: this is the single boundary between raw RPC frames and the OrcaRuntimeService.
|
||||
// Keeping the schema, handler, and result type attached to one object makes the
|
||||
// CLI-facing contract greppable and lets the dispatcher verify every payload
|
||||
// against the same shape the handler consumed during development.
|
||||
import { ZodError, type ZodType } from 'zod'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
export type RpcEnvelopeMeta = {
|
||||
runtimeId: string
|
||||
}
|
||||
|
||||
export type RpcSuccess = {
|
||||
id: string
|
||||
ok: true
|
||||
result: unknown
|
||||
_meta: RpcEnvelopeMeta
|
||||
}
|
||||
|
||||
export type RpcFailure = {
|
||||
id: string
|
||||
ok: false
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
_meta: RpcEnvelopeMeta
|
||||
}
|
||||
|
||||
export type RpcResponse = RpcSuccess | RpcFailure
|
||||
|
||||
export type RpcRequest = {
|
||||
id: string
|
||||
authToken: string
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export type RpcContext = {
|
||||
runtime: OrcaRuntimeService
|
||||
}
|
||||
|
||||
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
|
||||
|
||||
// Why: defineMethod preserves the inferred param type locally so each handler
|
||||
// is fully typed, but the erased `RpcMethod` form is what the dispatcher
|
||||
// actually stores. The erasure lives in one cast inside defineMethod rather
|
||||
// than in every method file, which is the tradeoff for the variance problem
|
||||
// of `RpcHandler` being contravariant in its param type.
|
||||
export type RpcMethod = {
|
||||
readonly name: string
|
||||
readonly params: ZodType | null
|
||||
readonly handler: (params: unknown, ctx: RpcContext) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
type DefineMethodSpec<TSchema extends ZodType | null> = {
|
||||
name: string
|
||||
params: TSchema
|
||||
handler: RpcHandler<TSchema extends ZodType ? TSchema['_output'] : void>
|
||||
}
|
||||
|
||||
export function defineMethod<TSchema extends ZodType | null>(
|
||||
spec: DefineMethodSpec<TSchema>
|
||||
): RpcMethod {
|
||||
return {
|
||||
name: spec.name,
|
||||
params: spec.params,
|
||||
handler: spec.handler as RpcMethod['handler']
|
||||
}
|
||||
}
|
||||
|
||||
export type RpcRegistry = ReadonlyMap<string, RpcMethod>
|
||||
|
||||
export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry {
|
||||
const registry = new Map<string, RpcMethod>()
|
||||
for (const method of methods) {
|
||||
if (registry.has(method.name)) {
|
||||
throw new Error(`duplicate_rpc_method:${method.name}`)
|
||||
}
|
||||
registry.set(method.name, method)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
export class InvalidArgumentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'InvalidArgumentError'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: zod aggregates all failing fields into `issues`, but the CLI surfaces
|
||||
// a single string to users. Pick the first issue's message so callers see a
|
||||
// message that matches the original handler's `Missing terminal handle`-style
|
||||
// phrasing (each schema supplies that literal message on its own constraint).
|
||||
export function formatZodError(error: ZodError): string {
|
||||
const first = error.issues[0]
|
||||
return first?.message ?? 'invalid_argument'
|
||||
}
|
||||
|
||||
export { ZodError }
|
||||
@@ -0,0 +1,78 @@
|
||||
// Why: the dispatcher is the one place that knows how to turn a validated
|
||||
// RPC request into a response envelope. Splitting it from the transport
|
||||
// makes it unit-testable without spinning up a socket, and keeps
|
||||
// runtime-rpc.ts focused on framing/auth/connection bookkeeping.
|
||||
import {
|
||||
ZodError,
|
||||
buildRegistry,
|
||||
formatZodError,
|
||||
type RpcEnvelopeMeta,
|
||||
type RpcMethod,
|
||||
type RpcRegistry,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from './core'
|
||||
import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors'
|
||||
import { ALL_RPC_METHODS } from './methods'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
export type DispatcherOptions = {
|
||||
runtime: OrcaRuntimeService
|
||||
methods?: readonly RpcMethod[]
|
||||
}
|
||||
|
||||
export class RpcDispatcher {
|
||||
private readonly runtime: OrcaRuntimeService
|
||||
private readonly registry: RpcRegistry
|
||||
|
||||
constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) {
|
||||
this.runtime = runtime
|
||||
this.registry = buildRegistry(methods)
|
||||
}
|
||||
|
||||
async dispatch(request: RpcRequest): Promise<RpcResponse> {
|
||||
const meta = this.meta()
|
||||
const method = this.registry.get(request.method)
|
||||
if (!method) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'method_not_found',
|
||||
`Unknown method: ${request.method}`
|
||||
)
|
||||
}
|
||||
|
||||
let parsedParams: unknown
|
||||
if (method.params === null) {
|
||||
parsedParams = undefined
|
||||
} else {
|
||||
const rawParams = request.params ?? {}
|
||||
const result = method.params.safeParse(rawParams)
|
||||
if (!result.success) {
|
||||
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error))
|
||||
}
|
||||
parsedParams = result.data
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await method.handler(parsedParams, { runtime: this.runtime })
|
||||
return successResponse(request.id, meta, result)
|
||||
} catch (error) {
|
||||
// Why: browser methods throw BrowserError with a structured `code`;
|
||||
// every other runtime error has a plain-message code. Routing by method
|
||||
// prefix keeps the mapping a single decision rather than a per-method
|
||||
// flag callers must remember to set.
|
||||
if (request.method.startsWith('browser.')) {
|
||||
return mapBrowserError(request.id, meta, error)
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error))
|
||||
}
|
||||
return mapRuntimeError(request.id, meta, error)
|
||||
}
|
||||
}
|
||||
|
||||
private meta(): RpcEnvelopeMeta {
|
||||
return { runtimeId: this.runtime.getRuntimeId() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Why: every RPC response needs the same runtimeId envelope, and the
|
||||
// runtime/browser error allowlists define the contract the CLI relies on to
|
||||
// format human-facing messages. Centralizing this mapping keeps the allowlist
|
||||
// auditable in one place instead of spread across per-method branches.
|
||||
import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core'
|
||||
|
||||
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
|
||||
return {
|
||||
id,
|
||||
ok: true,
|
||||
result,
|
||||
_meta: meta
|
||||
}
|
||||
}
|
||||
|
||||
export function errorResponse(
|
||||
id: string,
|
||||
meta: RpcEnvelopeMeta,
|
||||
code: string,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): RpcFailure {
|
||||
return {
|
||||
id,
|
||||
ok: false,
|
||||
error: data === undefined ? { code, message } : { code, message, data },
|
||||
_meta: meta
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the OrcaRuntimeService throws plain Error objects whose `message` is
|
||||
// actually a stable error code. This allowlist is the contract the CLI relies
|
||||
// on — expanding or renaming entries without updating the CLI would silently
|
||||
// change user-visible error codes.
|
||||
const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'runtime_unavailable',
|
||||
'selector_not_found',
|
||||
'selector_ambiguous',
|
||||
'terminal_handle_stale',
|
||||
'terminal_not_writable',
|
||||
'terminal_exited',
|
||||
'terminal_gone',
|
||||
'no_active_terminal',
|
||||
'repo_not_found',
|
||||
'timeout',
|
||||
'invalid_limit'
|
||||
])
|
||||
|
||||
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (RUNTIME_PASSTHROUGH_CODES.has(message)) {
|
||||
return errorResponse(id, meta, message, message)
|
||||
}
|
||||
if (message === 'invalid_terminal_send') {
|
||||
return errorResponse(id, meta, 'invalid_argument', 'Missing terminal send payload')
|
||||
}
|
||||
return errorResponse(id, meta, 'runtime_error', message)
|
||||
}
|
||||
|
||||
// Why: browser errors carry a structured .code property (BrowserError from
|
||||
// cdp-bridge.ts) that maps directly to agent-facing error codes. We forward
|
||||
// that code rather than falling back to the runtime allowlist, because the
|
||||
// browser surface area uses its own code namespace (browser_no_tab, etc.).
|
||||
export function mapBrowserError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
typeof (error as { code: unknown }).code === 'string'
|
||||
) {
|
||||
return errorResponse(id, meta, (error as { code: string }).code, error.message)
|
||||
}
|
||||
return mapRuntimeError(id, meta, error)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { BrowserTarget } from '../schemas'
|
||||
import {
|
||||
Check,
|
||||
Drag,
|
||||
Element,
|
||||
Eval,
|
||||
Exec,
|
||||
Fill,
|
||||
Find,
|
||||
FullScreenshot,
|
||||
Get,
|
||||
Goto,
|
||||
Highlight,
|
||||
Is,
|
||||
KeyboardInsert,
|
||||
Keypress,
|
||||
LimitParam,
|
||||
Screenshot,
|
||||
Scroll,
|
||||
Select,
|
||||
SelectorPath,
|
||||
TabClose,
|
||||
TabCreate,
|
||||
TabList,
|
||||
TabSwitch,
|
||||
Type,
|
||||
Upload,
|
||||
Wait
|
||||
} from './browser-schemas'
|
||||
|
||||
export const BROWSER_CORE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'browser.snapshot',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserSnapshot(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.click',
|
||||
params: Element,
|
||||
handler: async (params, { runtime }) => runtime.browserClick(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.goto',
|
||||
params: Goto,
|
||||
handler: async (params, { runtime }) => runtime.browserGoto(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.fill',
|
||||
params: Fill,
|
||||
handler: async (params, { runtime }) => runtime.browserFill(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.type',
|
||||
params: Type,
|
||||
handler: async (params, { runtime }) => runtime.browserType(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.select',
|
||||
params: Select,
|
||||
handler: async (params, { runtime }) => runtime.browserSelect(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.scroll',
|
||||
params: Scroll,
|
||||
handler: async (params, { runtime }) => runtime.browserScroll(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.back',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserBack(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.reload',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserReload(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.screenshot',
|
||||
params: Screenshot,
|
||||
handler: async (params, { runtime }) => runtime.browserScreenshot(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.eval',
|
||||
params: Eval,
|
||||
handler: async (params, { runtime }) => runtime.browserEval(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.tabList',
|
||||
params: TabList,
|
||||
handler: async (params, { runtime }) => runtime.browserTabList(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.tabSwitch',
|
||||
params: TabSwitch,
|
||||
handler: async (params, { runtime }) => runtime.browserTabSwitch(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.tabCreate',
|
||||
params: TabCreate,
|
||||
handler: async (params, { runtime }) => runtime.browserTabCreate(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.tabClose',
|
||||
params: TabClose,
|
||||
handler: async (params, { runtime }) => runtime.browserTabClose(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.hover',
|
||||
params: Element,
|
||||
handler: async (params, { runtime }) => runtime.browserHover(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.drag',
|
||||
params: Drag,
|
||||
handler: async (params, { runtime }) => runtime.browserDrag(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.upload',
|
||||
params: Upload,
|
||||
handler: async (params, { runtime }) => runtime.browserUpload(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.wait',
|
||||
params: Wait,
|
||||
handler: async (params, { runtime }) => runtime.browserWait(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.check',
|
||||
params: Check,
|
||||
handler: async (params, { runtime }) => runtime.browserCheck(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.focus',
|
||||
params: Element,
|
||||
handler: async (params, { runtime }) => runtime.browserFocus(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.clear',
|
||||
params: Element,
|
||||
handler: async (params, { runtime }) => runtime.browserClear(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.selectAll',
|
||||
params: Element,
|
||||
handler: async (params, { runtime }) => runtime.browserSelectAll(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.keypress',
|
||||
params: Keypress,
|
||||
handler: async (params, { runtime }) => runtime.browserKeypress(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.pdf',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserPdf(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.fullScreenshot',
|
||||
params: FullScreenshot,
|
||||
handler: async (params, { runtime }) => runtime.browserFullScreenshot(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.dblclick',
|
||||
params: Element,
|
||||
handler: async (params, { runtime }) => runtime.browserDblclick(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.forward',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserForward(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.scrollIntoView',
|
||||
params: Element,
|
||||
handler: async (params, { runtime }) => runtime.browserScrollIntoView(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.get',
|
||||
params: Get,
|
||||
handler: async (params, { runtime }) => runtime.browserGet(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.is',
|
||||
params: Is,
|
||||
handler: async (params, { runtime }) => runtime.browserIs(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.keyboardInsertText',
|
||||
params: KeyboardInsert,
|
||||
handler: async (params, { runtime }) => runtime.browserKeyboardInsertText(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.find',
|
||||
params: Find,
|
||||
handler: async (params, { runtime }) => runtime.browserFind(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.console',
|
||||
params: LimitParam,
|
||||
handler: async (params, { runtime }) => runtime.browserConsoleLog(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.network',
|
||||
params: LimitParam,
|
||||
handler: async (params, { runtime }) => runtime.browserNetworkLog(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.exec',
|
||||
params: Exec,
|
||||
handler: async (params, { runtime }) => runtime.browserExec(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.capture.start',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserCaptureStart(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.capture.stop',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserCaptureStop(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.download',
|
||||
params: SelectorPath,
|
||||
handler: async (params, { runtime }) => runtime.browserDownload(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.highlight',
|
||||
params: Highlight,
|
||||
handler: async (params, { runtime }) => runtime.browserHighlight(params)
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { BrowserTarget } from '../schemas'
|
||||
import {
|
||||
ClipboardWrite,
|
||||
CookieDelete,
|
||||
CookieGet,
|
||||
CookieSet,
|
||||
DialogAccept,
|
||||
Geolocation,
|
||||
InterceptEnable,
|
||||
MouseButton,
|
||||
MouseWheel,
|
||||
MouseXY,
|
||||
SetCredentials,
|
||||
SetDevice,
|
||||
SetHeaders,
|
||||
SetMedia,
|
||||
SetOffline,
|
||||
StorageKey,
|
||||
StorageKeyValue,
|
||||
Viewport
|
||||
} from './browser-schemas'
|
||||
|
||||
export const BROWSER_EXTRA_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'browser.cookie.get',
|
||||
params: CookieGet,
|
||||
handler: async (params, { runtime }) => runtime.browserCookieGet(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.cookie.set',
|
||||
params: CookieSet,
|
||||
handler: async (params, { runtime }) => runtime.browserCookieSet(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.cookie.delete',
|
||||
params: CookieDelete,
|
||||
handler: async (params, { runtime }) => runtime.browserCookieDelete(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.viewport',
|
||||
params: Viewport,
|
||||
handler: async (params, { runtime }) => runtime.browserSetViewport(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.geolocation',
|
||||
params: Geolocation,
|
||||
handler: async (params, { runtime }) => runtime.browserSetGeolocation(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.intercept.enable',
|
||||
params: InterceptEnable,
|
||||
handler: async (params, { runtime }) => runtime.browserInterceptEnable(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.intercept.disable',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserInterceptDisable(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.intercept.list',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserInterceptList(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.mouseMove',
|
||||
params: MouseXY,
|
||||
handler: async (params, { runtime }) => runtime.browserMouseMove(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.mouseDown',
|
||||
params: MouseButton,
|
||||
handler: async (params, { runtime }) => runtime.browserMouseDown(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.mouseUp',
|
||||
params: MouseButton,
|
||||
handler: async (params, { runtime }) => runtime.browserMouseUp(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.mouseWheel',
|
||||
params: MouseWheel,
|
||||
handler: async (params, { runtime }) => runtime.browserMouseWheel(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.setDevice',
|
||||
params: SetDevice,
|
||||
handler: async (params, { runtime }) => runtime.browserSetDevice(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.setOffline',
|
||||
params: SetOffline,
|
||||
handler: async (params, { runtime }) => runtime.browserSetOffline(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.setHeaders',
|
||||
params: SetHeaders,
|
||||
handler: async (params, { runtime }) => runtime.browserSetHeaders(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.setCredentials',
|
||||
params: SetCredentials,
|
||||
handler: async (params, { runtime }) => runtime.browserSetCredentials(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.setMedia',
|
||||
params: SetMedia,
|
||||
handler: async (params, { runtime }) => runtime.browserSetMedia(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.clipboardRead',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserClipboardRead(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.clipboardWrite',
|
||||
params: ClipboardWrite,
|
||||
handler: async (params, { runtime }) => runtime.browserClipboardWrite(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.dialogAccept',
|
||||
params: DialogAccept,
|
||||
handler: async (params, { runtime }) => runtime.browserDialogAccept(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.dialogDismiss',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserDialogDismiss(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.storage.local.get',
|
||||
params: StorageKey,
|
||||
handler: async (params, { runtime }) => runtime.browserStorageLocalGet(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.storage.local.set',
|
||||
params: StorageKeyValue,
|
||||
handler: async (params, { runtime }) => runtime.browserStorageLocalSet(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.storage.local.clear',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserStorageLocalClear(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.storage.session.get',
|
||||
params: StorageKey,
|
||||
handler: async (params, { runtime }) => runtime.browserStorageSessionGet(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.storage.session.set',
|
||||
params: StorageKeyValue,
|
||||
handler: async (params, { runtime }) => runtime.browserStorageSessionSet(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.storage.session.clear',
|
||||
params: BrowserTarget,
|
||||
handler: async (params, { runtime }) => runtime.browserStorageSessionClear(params)
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
// Why: the browser method surface area is large enough that keeping every
|
||||
// schema in the same file as its handler registration pushes the file past
|
||||
// the 300-line lint cap. Grouping all browser schemas here keeps each
|
||||
// handler file focused on dispatch wiring.
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
BrowserTarget,
|
||||
OptionalBoolean,
|
||||
OptionalFiniteNumber,
|
||||
OptionalPlainString,
|
||||
OptionalString,
|
||||
requiredString
|
||||
} from '../schemas'
|
||||
|
||||
export const Element = BrowserTarget.extend({
|
||||
element: requiredString('Missing required --element')
|
||||
})
|
||||
|
||||
export const Goto = BrowserTarget.extend({
|
||||
url: requiredString('Missing required --url')
|
||||
})
|
||||
|
||||
export const Fill = BrowserTarget.extend({
|
||||
element: requiredString('Missing required --element'),
|
||||
value: z.custom<string>((v) => typeof v === 'string', {
|
||||
message: 'Missing required --value'
|
||||
})
|
||||
})
|
||||
|
||||
export const Type = BrowserTarget.extend({
|
||||
input: requiredString('Missing required --input')
|
||||
})
|
||||
|
||||
export const Select = BrowserTarget.extend({
|
||||
element: requiredString('Missing required --element'),
|
||||
value: z.custom<string>((v) => typeof v === 'string', {
|
||||
message: 'Missing required --value'
|
||||
})
|
||||
})
|
||||
|
||||
export const Scroll = BrowserTarget.extend({
|
||||
direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', {
|
||||
message: 'Missing required --direction (up or down)'
|
||||
}),
|
||||
amount: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'number' && v > 0 ? v : undefined))
|
||||
.pipe(z.number().optional())
|
||||
})
|
||||
|
||||
export const Screenshot = BrowserTarget.extend({
|
||||
format: z
|
||||
.unknown()
|
||||
.transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined))
|
||||
.pipe(z.enum(['png', 'jpeg']).optional())
|
||||
})
|
||||
|
||||
export const FullScreenshot = BrowserTarget.extend({
|
||||
format: z
|
||||
.unknown()
|
||||
.transform((v) => (v === 'jpeg' ? 'jpeg' : 'png'))
|
||||
.pipe(z.enum(['png', 'jpeg']))
|
||||
})
|
||||
|
||||
export const Eval = BrowserTarget.extend({
|
||||
expression: requiredString('Missing required --expression')
|
||||
})
|
||||
|
||||
export const TabList = z.object({
|
||||
worktree: OptionalString
|
||||
})
|
||||
|
||||
// Why: --index xor --page must be present. The refine guards that invariant
|
||||
// so the dispatcher surfaces a single legible error instead of either shape
|
||||
// leaking into the runtime.
|
||||
export const TabSwitch = BrowserTarget.extend({
|
||||
index: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'number' ? v : undefined))
|
||||
.pipe(z.number().optional())
|
||||
}).refine(
|
||||
(val) => {
|
||||
if (val.page !== undefined) {
|
||||
return true
|
||||
}
|
||||
return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0
|
||||
},
|
||||
{ message: 'Missing required --index (non-negative integer) or --page' }
|
||||
)
|
||||
|
||||
export const TabCreate = z.object({
|
||||
url: OptionalString,
|
||||
worktree: OptionalString
|
||||
})
|
||||
|
||||
export const TabClose = z.object({
|
||||
index: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'number' ? v : undefined))
|
||||
.pipe(z.number().optional()),
|
||||
page: OptionalString,
|
||||
worktree: OptionalString
|
||||
})
|
||||
|
||||
export const Drag = BrowserTarget.extend({
|
||||
from: requiredString('Missing required --from and --to element refs'),
|
||||
to: requiredString('Missing required --from and --to element refs')
|
||||
})
|
||||
|
||||
export const Upload = BrowserTarget.extend({
|
||||
element: requiredString('Missing required --element and --files'),
|
||||
files: z.custom<string[]>(
|
||||
(v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'),
|
||||
{ message: 'Missing required --element and --files' }
|
||||
)
|
||||
})
|
||||
|
||||
export const Wait = BrowserTarget.extend({
|
||||
selector: OptionalPlainString,
|
||||
timeout: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'number' && v > 0 ? v : undefined))
|
||||
.pipe(z.number().optional()),
|
||||
text: OptionalPlainString,
|
||||
url: OptionalPlainString,
|
||||
load: OptionalPlainString,
|
||||
fn: OptionalPlainString,
|
||||
state: OptionalPlainString
|
||||
})
|
||||
|
||||
export const Check = BrowserTarget.extend({
|
||||
element: requiredString('Missing required --element'),
|
||||
checked: z
|
||||
.unknown()
|
||||
.transform((v) => v !== false)
|
||||
.pipe(z.boolean())
|
||||
})
|
||||
|
||||
export const Keypress = BrowserTarget.extend({
|
||||
key: requiredString('Missing required --key')
|
||||
})
|
||||
|
||||
export const SelectorPath = BrowserTarget.extend({
|
||||
selector: requiredString('Missing required --selector and --path'),
|
||||
path: requiredString('Missing required --selector and --path')
|
||||
})
|
||||
|
||||
export const Highlight = BrowserTarget.extend({
|
||||
selector: requiredString('Missing required --selector')
|
||||
})
|
||||
|
||||
export const Exec = BrowserTarget.extend({
|
||||
command: requiredString('Missing required --command')
|
||||
})
|
||||
|
||||
export const Get = BrowserTarget.extend({
|
||||
what: requiredString('Missing required --what'),
|
||||
selector: OptionalString
|
||||
})
|
||||
|
||||
export const Is = BrowserTarget.extend({
|
||||
what: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
|
||||
message: 'Missing required --what and --element'
|
||||
}),
|
||||
selector: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
|
||||
message: 'Missing required --what and --element'
|
||||
})
|
||||
})
|
||||
|
||||
export const KeyboardInsert = BrowserTarget.extend({
|
||||
text: requiredString('Missing required --text')
|
||||
})
|
||||
|
||||
export const LimitParam = BrowserTarget.extend({
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
export const Find = BrowserTarget.extend({
|
||||
locator: requiredString('Missing required --locator, --value, and --action'),
|
||||
value: requiredString('Missing required --locator, --value, and --action'),
|
||||
action: requiredString('Missing required --locator, --value, and --action'),
|
||||
text: OptionalString
|
||||
})
|
||||
|
||||
export const CookieGet = BrowserTarget.extend({
|
||||
url: OptionalPlainString
|
||||
})
|
||||
|
||||
export const CookieSet = BrowserTarget.extend({
|
||||
name: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
|
||||
message: 'Missing name or value'
|
||||
}),
|
||||
value: z.custom<string>((v) => typeof v === 'string', {
|
||||
message: 'Missing name or value'
|
||||
}),
|
||||
domain: OptionalPlainString,
|
||||
path: OptionalPlainString,
|
||||
secure: OptionalBoolean,
|
||||
httpOnly: OptionalBoolean,
|
||||
sameSite: OptionalPlainString,
|
||||
expires: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
export const CookieDelete = BrowserTarget.extend({
|
||||
name: requiredString('Missing cookie name'),
|
||||
domain: OptionalPlainString,
|
||||
url: OptionalPlainString
|
||||
})
|
||||
|
||||
export const Viewport = BrowserTarget.extend({
|
||||
width: z.custom<number>((v) => typeof v === 'number' && v > 0, {
|
||||
message: 'Width and height must be positive numbers'
|
||||
}),
|
||||
height: z.custom<number>((v) => typeof v === 'number' && v > 0, {
|
||||
message: 'Width and height must be positive numbers'
|
||||
}),
|
||||
deviceScaleFactor: OptionalFiniteNumber,
|
||||
mobile: OptionalBoolean
|
||||
})
|
||||
|
||||
export const Geolocation = BrowserTarget.extend({
|
||||
latitude: z.custom<number>((v) => typeof v === 'number', {
|
||||
message: 'Missing latitude or longitude'
|
||||
}),
|
||||
longitude: z.custom<number>((v) => typeof v === 'number', {
|
||||
message: 'Missing latitude or longitude'
|
||||
}),
|
||||
accuracy: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
export const InterceptEnable = BrowserTarget.extend({
|
||||
patterns: z
|
||||
.unknown()
|
||||
.transform((v) => (Array.isArray(v) ? (v as string[]) : undefined))
|
||||
.pipe(z.array(z.string()).optional())
|
||||
})
|
||||
|
||||
export const MouseXY = BrowserTarget.extend({
|
||||
x: z.custom<number>((v) => typeof v === 'number', {
|
||||
message: 'Missing required x and y coordinates'
|
||||
}),
|
||||
y: z.custom<number>((v) => typeof v === 'number', {
|
||||
message: 'Missing required x and y coordinates'
|
||||
})
|
||||
})
|
||||
|
||||
export const MouseButton = BrowserTarget.extend({
|
||||
button: OptionalPlainString
|
||||
})
|
||||
|
||||
export const MouseWheel = BrowserTarget.extend({
|
||||
dy: z.custom<number>((v) => typeof v === 'number', {
|
||||
message: 'Missing required --dy'
|
||||
}),
|
||||
dx: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
export const SetDevice = BrowserTarget.extend({
|
||||
name: requiredString('Missing required --name')
|
||||
})
|
||||
|
||||
export const SetOffline = BrowserTarget.extend({
|
||||
state: OptionalPlainString
|
||||
})
|
||||
|
||||
export const SetHeaders = BrowserTarget.extend({
|
||||
headers: requiredString('Missing required --headers (JSON string)')
|
||||
})
|
||||
|
||||
export const SetCredentials = BrowserTarget.extend({
|
||||
user: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
|
||||
message: 'Missing required --user and --pass'
|
||||
}),
|
||||
pass: z.custom<string>((v) => typeof v === 'string', {
|
||||
message: 'Missing required --user and --pass'
|
||||
})
|
||||
})
|
||||
|
||||
export const SetMedia = BrowserTarget.extend({
|
||||
colorScheme: OptionalPlainString,
|
||||
reducedMotion: OptionalPlainString
|
||||
})
|
||||
|
||||
export const ClipboardWrite = BrowserTarget.extend({
|
||||
text: requiredString('Missing required --text')
|
||||
})
|
||||
|
||||
export const DialogAccept = BrowserTarget.extend({
|
||||
text: OptionalPlainString
|
||||
})
|
||||
|
||||
export const StorageKey = BrowserTarget.extend({
|
||||
key: requiredString('Missing required --key')
|
||||
})
|
||||
|
||||
export const StorageKeyValue = BrowserTarget.extend({
|
||||
key: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
|
||||
message: 'Missing required --key and --value'
|
||||
}),
|
||||
value: z.custom<string>((v) => typeof v === 'string', {
|
||||
message: 'Missing required --key and --value'
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RpcMethod } from '../core'
|
||||
import { STATUS_METHODS } from './status'
|
||||
import { REPO_METHODS } from './repo'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
import { TERMINAL_METHODS } from './terminal'
|
||||
import { BROWSER_CORE_METHODS } from './browser-core'
|
||||
import { BROWSER_EXTRA_METHODS } from './browser-extras'
|
||||
|
||||
// Why: a flat manifest keeps registration order explicit and provides one
|
||||
// grep-point for "what methods does the RPC server expose?" — useful when
|
||||
// auditing the security boundary or wiring new CLI commands.
|
||||
export const ALL_RPC_METHODS: readonly RpcMethod[] = [
|
||||
...STATUS_METHODS,
|
||||
...REPO_METHODS,
|
||||
...WORKTREE_METHODS,
|
||||
...TERMINAL_METHODS,
|
||||
...BROWSER_CORE_METHODS,
|
||||
...BROWSER_EXTRA_METHODS
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, requiredString } from '../schemas'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
repo: requiredString('Missing repo selector')
|
||||
})
|
||||
|
||||
const RepoPath = z.object({
|
||||
path: requiredString('Missing repo path')
|
||||
})
|
||||
|
||||
const RepoSetBaseRef = z.object({
|
||||
repo: requiredString('Missing repo selector'),
|
||||
ref: requiredString('Missing base ref')
|
||||
})
|
||||
|
||||
const RepoSearchRefs = z.object({
|
||||
repo: requiredString('Missing repo selector'),
|
||||
query: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : undefined))
|
||||
.pipe(z.string({ message: 'Missing query' })),
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
export const REPO_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'repo.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.add',
|
||||
params: RepoPath,
|
||||
handler: async (params, { runtime }) => ({ repo: await runtime.addRepo(params.path) })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.show',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => ({ repo: await runtime.showRepo(params.repo) })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.setBaseRef',
|
||||
params: RepoSetBaseRef,
|
||||
handler: async (params, { runtime }) => ({
|
||||
repo: await runtime.setRepoBaseRef(params.repo, params.ref)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.searchRefs',
|
||||
params: RepoSearchRefs,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.searchRepoRefs(params.repo, params.query, params.limit)
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
export const STATUS_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'status.get',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => runtime.getStatus()
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,174 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const TerminalHandle = z.object({
|
||||
terminal: requiredString('Missing terminal handle')
|
||||
})
|
||||
|
||||
const TerminalListParams = z.object({
|
||||
worktree: OptionalString,
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const TerminalResolveActive = z.object({
|
||||
worktree: OptionalString
|
||||
})
|
||||
|
||||
const TerminalRead = TerminalHandle.extend({
|
||||
cursor: z
|
||||
.unknown()
|
||||
.transform((value) => {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
||||
return Number.NaN
|
||||
}
|
||||
return value
|
||||
})
|
||||
.pipe(
|
||||
z
|
||||
.number()
|
||||
.optional()
|
||||
.refine((v) => v === undefined || Number.isFinite(v), {
|
||||
message: 'Cursor must be a non-negative integer'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the legacy handler allowed `title: string | null` and rejected every
|
||||
// other shape (including `undefined`) with a specific message, which is how
|
||||
// the CLI signals an intentional "reset". Preserve that distinction exactly.
|
||||
const TerminalRename = TerminalHandle.extend({
|
||||
title: z.custom<string | null>((value) => value === null || typeof value === 'string', {
|
||||
message: 'Missing --title (pass empty string or null to reset)'
|
||||
})
|
||||
})
|
||||
|
||||
const TerminalSend = TerminalHandle.extend({
|
||||
text: OptionalString,
|
||||
enter: z.unknown().optional(),
|
||||
interrupt: z.unknown().optional()
|
||||
})
|
||||
|
||||
const TerminalWait = TerminalHandle.extend({
|
||||
for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', {
|
||||
message: 'Invalid --for value. Supported: exit, tui-idle'
|
||||
}),
|
||||
timeoutMs: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const TerminalCreateParams = z.object({
|
||||
worktree: OptionalString,
|
||||
command: OptionalString,
|
||||
title: OptionalString
|
||||
})
|
||||
|
||||
const TerminalSplit = TerminalHandle.extend({
|
||||
direction: z
|
||||
.unknown()
|
||||
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
|
||||
.pipe(z.enum(['vertical', 'horizontal']).optional()),
|
||||
command: OptionalString
|
||||
})
|
||||
|
||||
const TerminalStop = z.object({
|
||||
worktree: requiredString('Missing worktree selector')
|
||||
})
|
||||
|
||||
export const TERMINAL_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'terminal.list',
|
||||
params: TerminalListParams,
|
||||
handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.resolveActive',
|
||||
params: TerminalResolveActive,
|
||||
handler: async (params, { runtime }) => ({
|
||||
handle: await runtime.resolveActiveTerminal(params.worktree)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.show',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => ({
|
||||
terminal: await runtime.showTerminal(params.terminal)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.read',
|
||||
params: TerminalRead,
|
||||
handler: async (params, { runtime }) => ({
|
||||
terminal: await runtime.readTerminal(params.terminal, { cursor: params.cursor })
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.rename',
|
||||
params: TerminalRename,
|
||||
handler: async (params, { runtime }) => ({
|
||||
rename: await runtime.renameTerminal(params.terminal, params.title || null)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.send',
|
||||
params: TerminalSend,
|
||||
handler: async (params, { runtime }) => ({
|
||||
send: await runtime.sendTerminal(params.terminal, {
|
||||
text: params.text,
|
||||
enter: params.enter === true,
|
||||
interrupt: params.interrupt === true
|
||||
})
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.wait',
|
||||
params: TerminalWait,
|
||||
handler: async (params, { runtime }) => ({
|
||||
wait: await runtime.waitForTerminal(params.terminal, {
|
||||
condition: params.for,
|
||||
timeoutMs: params.timeoutMs
|
||||
})
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.create',
|
||||
params: TerminalCreateParams,
|
||||
handler: async (params, { runtime }) => ({
|
||||
terminal: await runtime.createTerminal(params.worktree, {
|
||||
command: params.command,
|
||||
title: params.title
|
||||
})
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.split',
|
||||
params: TerminalSplit,
|
||||
handler: async (params, { runtime }) => ({
|
||||
split: await runtime.splitTerminal(params.terminal, {
|
||||
direction: params.direction,
|
||||
command: params.command
|
||||
})
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.stop',
|
||||
params: TerminalStop,
|
||||
handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.focus',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => ({
|
||||
focus: await runtime.focusTerminal(params.terminal)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.close',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => ({
|
||||
close: await runtime.closeTerminal(params.terminal)
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import {
|
||||
OptionalBoolean,
|
||||
OptionalFiniteNumber,
|
||||
OptionalString,
|
||||
TriStateLinkedIssue
|
||||
} from '../schemas'
|
||||
|
||||
const WorktreeListParams = z.object({
|
||||
repo: OptionalString,
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const WorktreePsParams = z.object({
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const WorktreeSelector = z.object({
|
||||
worktree: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree selector'))
|
||||
})
|
||||
|
||||
const WorktreeCreate = z.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
name: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing worktree name')),
|
||||
baseBranch: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString
|
||||
})
|
||||
|
||||
const WorktreeSet = WorktreeSelector.extend({
|
||||
displayName: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString
|
||||
})
|
||||
|
||||
const WorktreeRemove = WorktreeSelector.extend({
|
||||
force: OptionalBoolean
|
||||
})
|
||||
|
||||
export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'worktree.ps',
|
||||
params: WorktreePsParams,
|
||||
handler: async (params, { runtime }) => runtime.getWorktreePs(params.limit)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.list',
|
||||
params: WorktreeListParams,
|
||||
handler: async (params, { runtime }) => runtime.listManagedWorktrees(params.repo, params.limit)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.show',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => ({
|
||||
worktree: await runtime.showManagedWorktree(params.worktree)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.create',
|
||||
params: WorktreeCreate,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: params.repo,
|
||||
name: params.name,
|
||||
baseBranch: params.baseBranch,
|
||||
linkedIssue: params.linkedIssue,
|
||||
comment: params.comment
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.set',
|
||||
params: WorktreeSet,
|
||||
handler: async (params, { runtime }) => ({
|
||||
worktree: await runtime.updateManagedWorktreeMeta(params.worktree, {
|
||||
displayName: params.displayName,
|
||||
linkedIssue: params.linkedIssue,
|
||||
comment: params.comment
|
||||
})
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.rm',
|
||||
params: WorktreeRemove,
|
||||
handler: async (params, { runtime }) => {
|
||||
await runtime.removeManagedWorktree(params.worktree, params.force === true)
|
||||
return { removed: true }
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
// Why: the RPC boundary ingests loosely-typed JSON from a CLI that grew
|
||||
// organically, so these reusable pieces capture the validation shapes that
|
||||
// recur across domains (optional worktree selector, bounded limit, browser
|
||||
// target envelope, etc.). Methods compose these to declare their real
|
||||
// contract without repeating the same `typeof` gymnastics 90 times.
|
||||
import { z } from 'zod'
|
||||
|
||||
// Why: the original handlers treated non-numeric/NaN limit values as "no
|
||||
// limit" rather than as errors. Preserve that forgiving behavior so CLI
|
||||
// callers passing stringified numbers or Infinity still reach the runtime.
|
||||
export const OptionalFiniteNumber = z
|
||||
.unknown()
|
||||
.transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined))
|
||||
.pipe(z.number().optional())
|
||||
|
||||
export const OptionalPositiveInt = z
|
||||
.unknown()
|
||||
.transform((value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
|
||||
)
|
||||
.pipe(z.number().optional())
|
||||
|
||||
export const OptionalString = z
|
||||
.unknown()
|
||||
.transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined))
|
||||
.pipe(z.string().optional())
|
||||
|
||||
export const OptionalPlainString = z
|
||||
.unknown()
|
||||
.transform((value) => (typeof value === 'string' ? value : undefined))
|
||||
.pipe(z.string().optional())
|
||||
|
||||
export const OptionalBoolean = z
|
||||
.unknown()
|
||||
.transform((value) => (typeof value === 'boolean' ? value : undefined))
|
||||
.pipe(z.boolean().optional())
|
||||
|
||||
// Why: runtime handlers accept `linkedIssue: number | null | undefined` with
|
||||
// distinct meanings — undefined means "no update", null means "clear", number
|
||||
// means "set". The ambient JSON decode produces all three shapes as-is.
|
||||
export const TriStateLinkedIssue = z
|
||||
.unknown()
|
||||
.transform((value) => {
|
||||
if (value === null) {
|
||||
return null
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.pipe(z.union([z.number(), z.null(), z.undefined()]))
|
||||
|
||||
// Why: the legacy extractBrowserTarget treated worktree as a plain-string
|
||||
// passthrough (empty string preserved) but `page` as non-empty-string. The
|
||||
// browser bridge uses worktree-as-empty-string to mean "any worktree", so
|
||||
// keep that asymmetry intact to avoid widening scope unexpectedly.
|
||||
export const BrowserTarget = z.object({
|
||||
worktree: OptionalPlainString,
|
||||
page: OptionalString
|
||||
})
|
||||
|
||||
export type BrowserTargetInput = z.infer<typeof BrowserTarget>
|
||||
|
||||
export function requiredString(message: string) {
|
||||
return z
|
||||
.unknown()
|
||||
.transform((value) => (typeof value === 'string' ? value : ''))
|
||||
.pipe(z.string().min(1, message))
|
||||
}
|
||||
|
||||
export function requiredNumber(message: string) {
|
||||
return z
|
||||
.unknown()
|
||||
.transform((value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
|
||||
)
|
||||
.pipe(z.number().refine((v) => Number.isFinite(v), { message }))
|
||||
}
|
||||
+22
-1947
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user