feat: export batch orchestration helpers (#472)

This commit is contained in:
Michał Pierzchała
2026-04-29 10:48:17 -04:00
committed by GitHub
parent a9e8d902dd
commit 6a2bf9f626
11 changed files with 317 additions and 173 deletions
+4
View File
@@ -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"
+1
View File
@@ -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',
+66
View File
@@ -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<DaemonRequest, 'token'> = {
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' },
);
});
+17
View File
@@ -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';
+1 -2
View File
@@ -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,
+1 -1
View File
@@ -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';
+1 -2
View File
@@ -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(
+193 -7
View File
@@ -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<string> = 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<string, unknown>;
runtime?: unknown;
};
export type BatchFlags = Record<string, unknown> & {
batchOnError?: 'stop';
batchMaxSteps?: number;
batchSteps?: BatchStep[];
};
export type BatchRequest = Omit<DaemonRequest, 'flags'> & {
flags?: BatchFlags | Record<string, unknown>;
};
export type BatchInvoke = (req: BatchRequest) => Promise<DaemonResponse>;
export type NormalizedBatchStep = {
command: string;
positionals: string[];
flags: Partial<CommandFlags>;
flags: Record<string, unknown>;
runtime?: unknown;
};
@@ -20,6 +48,68 @@ export type BatchStepResult = {
durationMs: number;
};
export async function runBatch(
req: BatchRequest,
sessionName: string,
invoke: BatchInvoke,
): Promise<DaemonResponse> {
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<BatchStep>;
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<CommandFlags>,
flags: (step.flags ?? {}) as Record<string, unknown>,
runtime: step.runtime,
});
}
return normalized;
}
export function buildBatchStepFlags(
parentFlags: BatchFlags | Record<string, unknown> | undefined,
stepFlags: BatchStep['flags'] | Record<string, unknown> | undefined,
): BatchFlags {
const {
batchSteps: _batchSteps,
batchOnError: _batchOnError,
batchMaxSteps: _batchMaxSteps,
...merged
} = stepFlags ?? {};
return mergeParentFlags(readBatchFlags(parentFlags), merged as BatchFlags);
}
export function mergeParentFlags<TFlags extends Record<string, unknown>>(
parentFlags: BatchFlags | Record<string, unknown> | undefined,
childFlags: TFlags,
): TFlags {
const parentRecord = readBatchFlags(parentFlags) ?? {};
const childRecord = childFlags as Record<string, unknown>;
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<string, unknown>;
};
}
> {
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<string, unknown> | undefined,
): BatchFlags | undefined {
return flags as BatchFlags | undefined;
}
function batchErrorResponse(
code: string,
message: string,
details?: Record<string, unknown>,
): Extract<DaemonResponse, { ok: false }> {
return {
ok: false,
error: { code, message, ...(details ? { details } : {}) },
};
}
+2 -31
View File
@@ -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<keyof CommandFlags> = [
'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<string, unknown>;
const childRecord = childFlags as Record<string, unknown>;
for (const key of INHERITED_PARENT_FLAG_KEYS) {
if (childRecord[key] === undefined && parentRecord[key] !== undefined) {
childRecord[key] = parentRecord[key];
}
}
return childFlags;
}
+2 -129
View File
@@ -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<DaemonResponse>,
): Promise<DaemonResponse> {
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<DaemonResponse>,
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<string, unknown>;
};
}
> {
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);
}
+29 -1
View File
@@ -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<DaemonResponse> {
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