From 6a2bf9f626c1f006aab573e53a5fd8dc082a2ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 29 Apr 2026 10:48:17 -0400 Subject: [PATCH] feat: export batch orchestration helpers (#472) --- package.json | 4 + rslib.config.ts | 1 + src/__tests__/batch-public.test.ts | 66 ++++++++ src/batch.ts | 17 +++ src/cli.ts | 3 +- src/cli/commands/connection-runtime.ts | 2 +- src/core/__tests__/batch.test.ts | 3 +- src/core/batch.ts | 200 ++++++++++++++++++++++++- src/daemon/handlers/handler-utils.ts | 33 +--- src/daemon/handlers/session-batch.ts | 131 +--------------- website/docs/docs/client-api.md | 30 +++- 11 files changed, 317 insertions(+), 173 deletions(-) create mode 100644 src/__tests__/batch-public.test.ts create mode 100644 src/batch.ts diff --git a/package.json b/package.json index 7487f9ac9..597a31081 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ "import": "./dist/src/metro.js", "types": "./dist/src/metro.d.ts" }, + "./batch": { + "import": "./dist/src/batch.js", + "types": "./dist/src/batch.d.ts" + }, "./remote-config": { "import": "./dist/src/remote-config.js", "types": "./dist/src/remote-config.d.ts" diff --git a/rslib.config.ts b/rslib.config.ts index ac02e4330..1713d9307 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ index: 'src/index.ts', io: 'src/io.ts', artifacts: 'src/artifacts.ts', + batch: 'src/batch.ts', metro: 'src/metro.ts', 'remote-config': 'src/remote-config.ts', 'install-source': 'src/install-source.ts', diff --git a/src/__tests__/batch-public.test.ts b/src/__tests__/batch-public.test.ts new file mode 100644 index 000000000..3b3509768 --- /dev/null +++ b/src/__tests__/batch-public.test.ts @@ -0,0 +1,66 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + BATCH_BLOCKED_COMMANDS, + DEFAULT_BATCH_MAX_STEPS, + INHERITED_PARENT_FLAG_KEYS, + buildBatchStepFlags, + runBatch, + validateAndNormalizeBatchSteps, + type BatchStepResult, +} from '../batch.ts'; +import type { DaemonRequest } from '../contracts.ts'; + +test('public batch entrypoint exports daemon-compatible orchestration helpers', async () => { + const seenCommands: string[] = []; + const req: Omit = { + command: 'batch', + positionals: [], + flags: { + platform: 'ios', + udid: 'sim-1', + batchSteps: [ + { command: 'open', positionals: ['settings'] }, + { command: 'wait', positionals: ['100'], flags: { platform: 'android' } }, + ], + }, + }; + + const response = await runBatch(req, 'resolved-session', async (stepReq) => { + seenCommands.push(stepReq.command); + assert.equal(stepReq.session, 'resolved-session'); + assert.equal(stepReq.flags?.session, 'resolved-session'); + if (stepReq.command === 'open') { + assert.equal(stepReq.flags?.platform, 'ios'); + assert.equal(stepReq.flags?.udid, 'sim-1'); + } + if (stepReq.command === 'wait') { + assert.equal(stepReq.flags?.platform, 'android'); + } + return { ok: true, data: { command: stepReq.command } }; + }); + + assert.equal(response.ok, true); + assert.deepEqual(seenCommands, ['open', 'wait']); + if (response.ok) { + assert.equal(response.data?.total, 2); + const results = response.data?.results as BatchStepResult[]; + assert.equal(results[0]?.command, 'open'); + } +}); + +test('public batch helpers expose validation and flag policy', () => { + assert.equal(DEFAULT_BATCH_MAX_STEPS, 100); + assert.equal(BATCH_BLOCKED_COMMANDS.has('replay'), true); + assert.equal(INHERITED_PARENT_FLAG_KEYS.includes('udid'), true); + assert.deepEqual(validateAndNormalizeBatchSteps([{ command: 'WAIT', positionals: ['100'] }], 1), [ + { command: 'wait', positionals: ['100'], flags: {}, runtime: undefined }, + ]); + assert.deepEqual( + buildBatchStepFlags( + { platform: 'ios', udid: 'sim-1', batchSteps: [{ command: 'open' }] }, + { batchMaxSteps: 10, platform: 'android' }, + ), + { platform: 'android', udid: 'sim-1' }, + ); +}); diff --git a/src/batch.ts b/src/batch.ts new file mode 100644 index 000000000..5de128059 --- /dev/null +++ b/src/batch.ts @@ -0,0 +1,17 @@ +export { + BATCH_BLOCKED_COMMANDS, + DEFAULT_BATCH_MAX_STEPS, + INHERITED_PARENT_FLAG_KEYS, + buildBatchStepFlags, + runBatch, + validateAndNormalizeBatchSteps, +} from './core/batch.ts'; + +export type { + BatchFlags, + BatchInvoke, + BatchRequest, + BatchStep, + BatchStepResult, + NormalizedBatchStep, +} from './core/batch.ts'; diff --git a/src/cli.ts b/src/cli.ts index 868b2e8c4..b340f6600 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,8 +5,7 @@ import { readVersion } from './utils/version.ts'; import { pathToFileURL } from 'node:url'; import { sendToDaemon } from './daemon-client.ts'; import fs from 'node:fs'; -import type { BatchStep } from './core/dispatch.ts'; -import { parseBatchStepsJson } from './core/batch.ts'; +import { parseBatchStepsJson, type BatchStep } from './core/batch.ts'; import { createAgentDeviceClient, type AgentDeviceClientConfig, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 5e5b7be24..2b0c86bdb 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -11,7 +11,7 @@ import { type RemoteConnectionState, } from '../../remote-connection-state.ts'; import { REMOTE_CONFIG_FIELD_SPECS, type RemoteConfigProfile } from '../../remote-config-schema.ts'; -import type { BatchStep } from '../../core/dispatch.ts'; +import type { BatchStep } from '../../core/batch.ts'; import { AppError } from '../../utils/errors.ts'; import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; import type { CliFlags } from '../../utils/command-schema.ts'; diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index de062bf62..0b33d7c3d 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -1,7 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { validateAndNormalizeBatchSteps } from '../batch.ts'; -import type { BatchStep } from '../dispatch.ts'; +import { validateAndNormalizeBatchSteps, type BatchStep } from '../batch.ts'; test('validateAndNormalizeBatchSteps rejects unknown top-level step fields', () => { assert.throws( diff --git a/src/core/batch.ts b/src/core/batch.ts index f40bac0c0..23c742e1b 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -1,14 +1,42 @@ -import { AppError } from '../utils/errors.ts'; -import type { BatchStep, CommandFlags } from './dispatch.ts'; +import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; +import { AppError, asAppError } from '../utils/errors.ts'; export const DEFAULT_BATCH_MAX_STEPS = 100; -const BATCH_BLOCKED_COMMANDS = new Set(['batch', 'replay']); +export const BATCH_BLOCKED_COMMANDS: ReadonlySet = new Set(['batch', 'replay']); const BATCH_ALLOWED_STEP_KEYS = new Set(['command', 'positionals', 'flags', 'runtime']); +export const INHERITED_PARENT_FLAG_KEYS = [ + 'platform', + 'target', + 'device', + 'udid', + 'serial', + 'verbose', + 'out', +] as const; + +export type BatchStep = { + command: string; + positionals?: string[]; + flags?: Record; + runtime?: unknown; +}; + +export type BatchFlags = Record & { + batchOnError?: 'stop'; + batchMaxSteps?: number; + batchSteps?: BatchStep[]; +}; + +export type BatchRequest = Omit & { + flags?: BatchFlags | Record; +}; + +export type BatchInvoke = (req: BatchRequest) => Promise; export type NormalizedBatchStep = { command: string; positionals: string[]; - flags: Partial; + flags: Record; runtime?: unknown; }; @@ -20,6 +48,68 @@ export type BatchStepResult = { durationMs: number; }; +export async function runBatch( + req: BatchRequest, + sessionName: string, + invoke: BatchInvoke, +): Promise { + const flags = readBatchFlags(req.flags); + const batchOnError = flags?.batchOnError ?? 'stop'; + if (batchOnError !== 'stop') { + return batchErrorResponse('INVALID_ARGS', `Unsupported batch on-error mode: ${batchOnError}.`); + } + const batchMaxSteps = flags?.batchMaxSteps ?? DEFAULT_BATCH_MAX_STEPS; + if (!Number.isInteger(batchMaxSteps) || batchMaxSteps < 1 || batchMaxSteps > 1000) { + return batchErrorResponse( + 'INVALID_ARGS', + `Invalid batch max-steps: ${String(flags?.batchMaxSteps)}`, + ); + } + try { + const steps = validateAndNormalizeBatchSteps(flags?.batchSteps, batchMaxSteps); + const startedAt = Date.now(); + const partialResults: BatchStepResult[] = []; + for (let index = 0; index < steps.length; index += 1) { + const step = steps[index]; + const stepResponse = await runBatchStep(req, sessionName, step, invoke, index + 1); + if (!stepResponse.ok) { + return { + ok: false, + error: { + code: stepResponse.error.code, + message: `Batch failed at step ${stepResponse.step} (${step.command}): ${stepResponse.error.message}`, + hint: stepResponse.error.hint, + diagnosticId: stepResponse.error.diagnosticId, + logPath: stepResponse.error.logPath, + details: { + ...(stepResponse.error.details ?? {}), + step: stepResponse.step, + command: step.command, + positionals: step.positionals, + executed: index, + total: steps.length, + partialResults, + }, + }, + }; + } + partialResults.push(stepResponse.result); + } + return { + ok: true, + data: { + total: steps.length, + executed: steps.length, + totalDurationMs: Date.now() - startedAt, + results: partialResults, + }, + }; + } catch (error) { + const appErr = asAppError(error); + return batchErrorResponse(appErr.code, appErr.message, appErr.details); + } +} + export function parseBatchStepsJson(raw: string): BatchStep[] { let parsed: unknown; try { @@ -34,7 +124,7 @@ export function parseBatchStepsJson(raw: string): BatchStep[] { } export function validateAndNormalizeBatchSteps( - steps: CommandFlags['batchSteps'], + steps: unknown, maxSteps: number, ): NormalizedBatchStep[] { if (!Array.isArray(steps) || steps.length === 0) { @@ -49,7 +139,7 @@ export function validateAndNormalizeBatchSteps( const normalized: NormalizedBatchStep[] = []; for (let index = 0; index < steps.length; index += 1) { - const step = steps[index]; + const step = steps[index] as Partial; if (!step || typeof step !== 'object') { throw new AppError('INVALID_ARGS', `Invalid batch step at index ${index}.`); } @@ -93,9 +183,105 @@ export function validateAndNormalizeBatchSteps( normalized.push({ command, positionals: positionals as string[], - flags: (step.flags ?? {}) as Partial, + flags: (step.flags ?? {}) as Record, runtime: step.runtime, }); } return normalized; } + +export function buildBatchStepFlags( + parentFlags: BatchFlags | Record | undefined, + stepFlags: BatchStep['flags'] | Record | undefined, +): BatchFlags { + const { + batchSteps: _batchSteps, + batchOnError: _batchOnError, + batchMaxSteps: _batchMaxSteps, + ...merged + } = stepFlags ?? {}; + return mergeParentFlags(readBatchFlags(parentFlags), merged as BatchFlags); +} + +export function mergeParentFlags>( + parentFlags: BatchFlags | Record | undefined, + childFlags: TFlags, +): TFlags { + const parentRecord = readBatchFlags(parentFlags) ?? {}; + const childRecord = childFlags as Record; + for (const key of INHERITED_PARENT_FLAG_KEYS) { + if (childRecord[key] === undefined && parentRecord[key] !== undefined) { + childRecord[key] = parentRecord[key]; + } + } + return childFlags; +} + +async function runBatchStep( + req: BatchRequest, + sessionName: string, + step: NormalizedBatchStep, + invoke: BatchInvoke, + stepNumber: number, +): Promise< + | { ok: true; step: number; result: BatchStepResult } + | { + ok: false; + step: number; + error: { + code: string; + message: string; + hint?: string; + diagnosticId?: string; + logPath?: string; + details?: Record; + }; + } +> { + const stepStartedAt = Date.now(); + const stepFlags = buildBatchStepFlags(req.flags, step.flags); + if (stepFlags.session === undefined) { + stepFlags.session = sessionName; + } + const response = await invoke({ + token: req.token, + session: sessionName, + command: step.command, + positionals: step.positionals, + flags: stepFlags, + runtime: (step.runtime === undefined ? req.runtime : step.runtime) as DaemonRequest['runtime'], + meta: req.meta, + }); + const durationMs = Date.now() - stepStartedAt; + if (!response.ok) { + return { ok: false, step: stepNumber, error: response.error }; + } + return { + ok: true, + step: stepNumber, + result: { + step: stepNumber, + command: step.command, + ok: true, + data: response.data ?? {}, + durationMs, + }, + }; +} + +function readBatchFlags( + flags: BatchFlags | Record | undefined, +): BatchFlags | undefined { + return flags as BatchFlags | undefined; +} + +function batchErrorResponse( + code: string, + message: string, + details?: Record, +): Extract { + return { + ok: false, + error: { code, message, ...(details ? { details } : {}) }, + }; +} diff --git a/src/daemon/handlers/handler-utils.ts b/src/daemon/handlers/handler-utils.ts index c386479f6..06a5654d7 100644 --- a/src/daemon/handlers/handler-utils.ts +++ b/src/daemon/handlers/handler-utils.ts @@ -2,6 +2,8 @@ import type { CommandFlags } from '../../core/dispatch.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; +export { mergeParentFlags } from '../../core/batch.ts'; + /** * Record a session action if a session is active. No-op when session is undefined. */ @@ -20,34 +22,3 @@ export function recordSessionAction( result: result ?? {}, }); } - -/** - * Flag keys inherited from a parent request (batch/replay) into child step flags. - * Shared between batch and replay so the inheritance rules stay in sync. - */ -const INHERITED_PARENT_FLAG_KEYS: ReadonlyArray = [ - 'platform', - 'target', - 'device', - 'udid', - 'serial', - 'verbose', - 'out', -]; - -/** - * Merge parent flag values into child flags for keys that are undefined in the child. - */ -export function mergeParentFlags( - parentFlags: CommandFlags | undefined, - childFlags: CommandFlags, -): CommandFlags { - const parentRecord = (parentFlags ?? {}) as Record; - const childRecord = childFlags as Record; - for (const key of INHERITED_PARENT_FLAG_KEYS) { - if (childRecord[key] === undefined && parentRecord[key] !== undefined) { - childRecord[key] = parentRecord[key]; - } - } - return childFlags; -} diff --git a/src/daemon/handlers/session-batch.ts b/src/daemon/handlers/session-batch.ts index 46025af4f..62224e3a2 100644 --- a/src/daemon/handlers/session-batch.ts +++ b/src/daemon/handlers/session-batch.ts @@ -1,137 +1,10 @@ -import { - DEFAULT_BATCH_MAX_STEPS, - type BatchStepResult, - type NormalizedBatchStep, - validateAndNormalizeBatchSteps, -} from '../../core/batch.ts'; -import type { BatchStep, CommandFlags } from '../../core/dispatch.ts'; -import { asAppError } from '../../utils/errors.ts'; +import { type BatchInvoke, runBatch } from '../../core/batch.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; -import { mergeParentFlags } from './handler-utils.ts'; -import { errorResponse } from './response.ts'; export async function runBatchCommands( req: DaemonRequest, sessionName: string, invoke: (req: DaemonRequest) => Promise, ): Promise { - const batchOnError = req.flags?.batchOnError ?? 'stop'; - if (batchOnError !== 'stop') { - return errorResponse('INVALID_ARGS', `Unsupported batch on-error mode: ${batchOnError}.`); - } - const batchMaxSteps = req.flags?.batchMaxSteps ?? DEFAULT_BATCH_MAX_STEPS; - if (!Number.isInteger(batchMaxSteps) || batchMaxSteps < 1 || batchMaxSteps > 1000) { - return errorResponse( - 'INVALID_ARGS', - `Invalid batch max-steps: ${String(req.flags?.batchMaxSteps)}`, - ); - } - try { - const steps = validateAndNormalizeBatchSteps(req.flags?.batchSteps, batchMaxSteps); - const startedAt = Date.now(); - const partialResults: BatchStepResult[] = []; - for (let index = 0; index < steps.length; index += 1) { - const step = steps[index]; - const stepResponse = await runBatchStep(req, sessionName, step, invoke, index + 1); - if (!stepResponse.ok) { - return { - ok: false, - error: { - code: stepResponse.error.code, - message: `Batch failed at step ${stepResponse.step} (${step.command}): ${stepResponse.error.message}`, - hint: stepResponse.error.hint, - diagnosticId: stepResponse.error.diagnosticId, - logPath: stepResponse.error.logPath, - details: { - ...(stepResponse.error.details ?? {}), - step: stepResponse.step, - command: step.command, - positionals: step.positionals, - executed: index, - total: steps.length, - partialResults, - }, - }, - }; - } - partialResults.push(stepResponse.result); - } - return { - ok: true, - data: { - total: steps.length, - executed: steps.length, - totalDurationMs: Date.now() - startedAt, - results: partialResults, - }, - }; - } catch (error) { - const appErr = asAppError(error); - return errorResponse(appErr.code, appErr.message, appErr.details); - } -} - -async function runBatchStep( - req: DaemonRequest, - sessionName: string, - step: NormalizedBatchStep, - invoke: (req: DaemonRequest) => Promise, - stepNumber: number, -): Promise< - | { ok: true; step: number; result: BatchStepResult } - | { - ok: false; - step: number; - error: { - code: string; - message: string; - hint?: string; - diagnosticId?: string; - logPath?: string; - details?: Record; - }; - } -> { - const stepStartedAt = Date.now(); - const stepFlags = buildBatchStepFlags(req.flags, step.flags); - if (stepFlags.session === undefined) { - stepFlags.session = sessionName; - } - const response = await invoke({ - token: req.token, - session: sessionName, - command: step.command, - positionals: step.positionals, - flags: stepFlags, - runtime: (step.runtime === undefined ? req.runtime : step.runtime) as DaemonRequest['runtime'], - meta: req.meta, - }); - const durationMs = Date.now() - stepStartedAt; - if (!response.ok) { - return { ok: false, step: stepNumber, error: response.error }; - } - return { - ok: true, - step: stepNumber, - result: { - step: stepNumber, - command: step.command, - ok: true, - data: response.data ?? {}, - durationMs, - }, - }; -} - -function buildBatchStepFlags( - parentFlags: CommandFlags | undefined, - stepFlags: BatchStep['flags'] | undefined, -): CommandFlags { - const { - batchSteps: _batchSteps, - batchOnError: _batchOnError, - batchMaxSteps: _batchMaxSteps, - ...merged - } = stepFlags ?? {}; - return mergeParentFlags(parentFlags, merged as CommandFlags); + return await runBatch(req, sessionName, invoke as BatchInvoke); } diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index e3d279e55..03f10bb73 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -20,6 +20,14 @@ Public subpath API exposed for Node consumers: - `buildIosRuntimeHints(baseUrl)` - `buildAndroidRuntimeHints(baseUrl)` - types: `PrepareRemoteMetroOptions`, `PrepareRemoteMetroResult`, `EnsureMetroTunnelOptions`, `EnsureMetroTunnelResult`, `ReloadRemoteMetroOptions`, `ReloadRemoteMetroResult`, `StopMetroTunnelOptions`, `MetroRuntimeHints`, `MetroBridgeResult` +- `agent-device/batch` + - `runBatch(req, sessionName, invoke)` + - `validateAndNormalizeBatchSteps(steps, maxSteps)` + - `buildBatchStepFlags(parentFlags, stepFlags)` + - `DEFAULT_BATCH_MAX_STEPS` + - `BATCH_BLOCKED_COMMANDS` + - `INHERITED_PARENT_FLAG_KEYS` + - types: `BatchInvoke`, `BatchRequest`, `BatchStep`, `BatchStepResult`, `NormalizedBatchStep` - `agent-device/remote-config` - `resolveRemoteConfigPath(options)` - `resolveRemoteConfigProfile(options)` @@ -61,7 +69,7 @@ Public subpath API exposed for Node consumers: - `verifyAndroidSnapshotHelperArtifact(artifact)` - types: `AndroidAdbExecutor`, `AndroidSnapshotHelperArtifact`, `AndroidSnapshotHelperManifest`, `AndroidSnapshotHelperOutput`, `AndroidSnapshotHelperParsedSnapshot` -The `contracts`, `selectors`, `finders`, `install-source`, `android-apps`, `artifacts`, `metro`, `remote-config`, and `io` subpaths remain available for compatibility. The former hosted-runtime subpaths `agent-device/commands`, `agent-device/backend`, `agent-device/testing/conformance`, and `agent-device/observability` are no longer published. +The `contracts`, `selectors`, `finders`, `install-source`, `android-apps`, `artifacts`, `batch`, `metro`, `remote-config`, and `io` subpaths remain available for compatibility. The former hosted-runtime subpaths `agent-device/commands`, `agent-device/backend`, `agent-device/testing/conformance`, and `agent-device/observability` are no longer published. ## Basic usage @@ -188,6 +196,26 @@ Additional CLI-backed methods are exposed on their domain groups with typed opti `client.recording.record({ action: 'start', path, quality: 5 })` starts a smaller 50% resolution video; omit `quality` to keep native/current resolution. +## Batch orchestration for custom transports + +Use `agent-device/batch` when a bridge or in-process runner receives daemon-shaped requests but owns command dispatch itself. The helper keeps validation, inherited flags, serial execution, partial results, and error envelopes aligned with the daemon batch command. + +```ts +import { runBatch } from 'agent-device/batch'; +import type { BatchRequest } from 'agent-device/batch'; +import type { DaemonResponse } from 'agent-device/contracts'; + +async function handleBatch(req: BatchRequest): Promise { + return await runBatch(req, req.session ?? 'default', async (stepReq) => { + try { + return { ok: true, data: await dispatch(stepReq) }; + } catch (error) { + return bridgeErrorToDaemonResponse(error); + } + }); +} +``` + ## Android `installFromSource()` ```ts