From eddeb431274801b4652cd535ab7a37bd3929c3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 20:08:15 -0500 Subject: [PATCH 1/6] refactor: add ios runner lifecycle protocol --- .../RunnerTests+CommandExecution.swift | 22 +++ .../RunnerTests+CommandJournal.swift | 135 ++++++++++++++++++ .../RunnerTests+Models.swift | 30 ++++ .../RunnerTests.swift | 1 + .../ios/__tests__/runner-client.test.ts | 2 + .../ios/__tests__/runner-session.test.ts | 60 +++++++- src/platforms/ios/runner-client.ts | 8 +- src/platforms/ios/runner-contract.ts | 14 ++ src/platforms/ios/runner-session.ts | 48 +++++-- .../provider-scenarios/providers.ts | 10 +- 10 files changed, 308 insertions(+), 22 deletions(-) create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 0541a82e4..9757a8b47 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -26,6 +26,19 @@ extension RunnerTests { } func execute(command: Command) throws -> Response { + commandJournal.accept(command: command) + commandJournal.start(command: command) + do { + let response = try executeDispatched(command: command) + commandJournal.finish(command: command, response: response) + return response + } catch { + commandJournal.fail(command: command, error: error) + throw error + } + } + + private func executeDispatched(command: Command) throws -> Response { if Thread.isMainThread { return try executeOnMainSafely(command: command) } @@ -183,6 +196,15 @@ extension RunnerTests { } switch command.command { + case .status: + guard + let statusCommandId = command.statusCommandId? + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusCommandId.isEmpty + else { + return Response(ok: false, error: ErrorPayload(message: "status requires statusCommandId")) + } + return Response(ok: true, data: commandJournal.status(commandId: statusCommandId)) case .shutdown: stopRecordingIfNeeded() return Response(ok: true, data: DataPayload(message: "shutdown")) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift new file mode 100644 index 000000000..f8025d0f2 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -0,0 +1,135 @@ +import Foundation + +enum RunnerCommandLifecycleState: String { + case accepted + case started + case completed + case failed +} + +struct RunnerCommandJournalEntry { + let commandId: String + let command: String + var state: RunnerCommandLifecycleState + var response: Response? + var error: ErrorPayload? + var updatedAtMs: Double +} + +final class RunnerCommandJournal { + private let lock = NSLock() + private let maxEntries = 64 + private var entries: [String: RunnerCommandJournalEntry] = [:] + private var order: [String] = [] + + func accept(command: Command) { + guard let commandId = normalizedCommandId(command.commandId) else { return } + lock.lock() + defer { lock.unlock() } + entries[commandId] = RunnerCommandJournalEntry( + commandId: commandId, + command: command.command.rawValue, + state: .accepted, + response: nil, + error: nil, + updatedAtMs: currentTimeMs() + ) + order.removeAll { $0 == commandId } + order.append(commandId) + pruneIfNeeded() + } + + func start(command: Command) { + update(command: command, state: .started, response: nil, error: nil) + } + + func finish(command: Command, response: Response) { + update( + command: command, + state: response.ok ? .completed : .failed, + response: response, + error: response.error + ) + } + + func fail(command: Command, error: Error) { + update( + command: command, + state: .failed, + response: nil, + error: ErrorPayload(message: error.localizedDescription) + ) + } + + func status(commandId: String) -> DataPayload { + guard let normalized = normalizedCommandId(commandId) else { + return DataPayload(lifecycleState: "notAccepted") + } + lock.lock() + let entry = entries[normalized] + lock.unlock() + guard let entry else { + return DataPayload(commandId: normalized, lifecycleState: "notAccepted") + } + return DataPayload( + commandId: entry.commandId, + lifecycleState: entry.state.rawValue, + lifecycleCommand: entry.command, + lifecycleResponseOk: entry.response?.ok, + lifecycleResponseJson: encodeResponseJson(entry.response), + lifecycleErrorCode: entry.error?.code, + lifecycleErrorMessage: entry.error?.message, + lifecycleErrorHint: entry.error?.hint + ) + } + + private func update( + command: Command, + state: RunnerCommandLifecycleState, + response: Response?, + error: ErrorPayload? + ) { + guard let commandId = normalizedCommandId(command.commandId) else { return } + lock.lock() + defer { lock.unlock() } + var entry = entries[commandId] ?? RunnerCommandJournalEntry( + commandId: commandId, + command: command.command.rawValue, + state: .accepted, + response: nil, + error: nil, + updatedAtMs: currentTimeMs() + ) + entry.state = state + entry.response = response + entry.error = error + entry.updatedAtMs = currentTimeMs() + entries[commandId] = entry + order.removeAll { $0 == commandId } + order.append(commandId) + pruneIfNeeded() + } + + private func pruneIfNeeded() { + while order.count > maxEntries { + let removed = order.removeFirst() + entries.removeValue(forKey: removed) + } + } + + private func normalizedCommandId(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func currentTimeMs() -> Double { + Date().timeIntervalSince1970 * 1000 + } + + private func encodeResponseJson(_ response: Response?) -> String? { + guard let response else { return nil } + guard let data = try? JSONEncoder().encode(response) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 6424b0363..5c3f4b448 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -30,6 +30,7 @@ enum CommandType: String, Codable { case transformGesture case recordStart case recordStop + case status case uptime case shutdown } @@ -91,6 +92,9 @@ extension CommandType { case .recordStop, .uptime, .shutdown: return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true) + case .status: + return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true) + // Normal preflight, not retried. // NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground // guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke @@ -104,6 +108,8 @@ extension CommandType { struct Command: Codable { let command: CommandType + let commandId: String? + let statusCommandId: String? let appBundleId: String? let text: String? let selectorKey: String? @@ -171,6 +177,14 @@ struct DataPayload: Codable { let referenceWidth: Double? let referenceHeight: Double? let currentUptimeMs: Double? + let commandId: String? + let lifecycleState: String? + let lifecycleCommand: String? + let lifecycleResponseOk: Bool? + let lifecycleResponseJson: String? + let lifecycleErrorCode: String? + let lifecycleErrorMessage: String? + let lifecycleErrorHint: String? let visible: Bool? let wasVisible: Bool? let dismissed: Bool? @@ -192,6 +206,14 @@ struct DataPayload: Codable { referenceWidth: Double? = nil, referenceHeight: Double? = nil, currentUptimeMs: Double? = nil, + commandId: String? = nil, + lifecycleState: String? = nil, + lifecycleCommand: String? = nil, + lifecycleResponseOk: Bool? = nil, + lifecycleResponseJson: String? = nil, + lifecycleErrorCode: String? = nil, + lifecycleErrorMessage: String? = nil, + lifecycleErrorHint: String? = nil, visible: Bool? = nil, wasVisible: Bool? = nil, dismissed: Bool? = nil, @@ -212,6 +234,14 @@ struct DataPayload: Codable { self.referenceWidth = referenceWidth self.referenceHeight = referenceHeight self.currentUptimeMs = currentUptimeMs + self.commandId = commandId + self.lifecycleState = lifecycleState + self.lifecycleCommand = lifecycleCommand + self.lifecycleResponseOk = lifecycleResponseOk + self.lifecycleResponseJson = lifecycleResponseJson + self.lifecycleErrorCode = lifecycleErrorCode + self.lifecycleErrorMessage = lifecycleErrorMessage + self.lifecycleErrorHint = lifecycleErrorHint self.visible = visible self.wasVisible = wasVisible self.dismissed = dismissed diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 0155b245c..810ec413d 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -53,6 +53,7 @@ final class RunnerTests: XCTestCase { var needsPostSnapshotInteractionDelay = false var needsFirstInteractionDelay = false var activeRecording: ScreenRecorder? + let commandJournal = RunnerCommandJournal() let interactiveTypes: Set = [ .button, .cell, diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index aaef32893..7a176070c 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -159,6 +159,7 @@ const runnerProtocolCommandFixtures: Record { + const session = makeRunnerSession({ ready: true }); + mockWaitForRunner.mockResolvedValueOnce( + runnerResponse({ + commandId: 'runner-command-1', + lifecycleState: 'completed', + lifecycleResponseOk: true, + }), + ); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'status', statusCommandId: 'runner-command-1' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { + commandId: 'runner-command-1', + lifecycleState: 'completed', + lifecycleResponseOk: true, + }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { + command: 'status', + statusCommandId: 'runner-command-1', + }); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); +}); + test('runner session probes readiness before mutating commands', async () => { const session = makeRunnerSession({ ready: false }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); @@ -151,9 +182,9 @@ test('runner session probes readiness before mutating commands', async () => { assert.deepEqual(result, { tapped: true }); assert.equal(session.ready, true); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); - assert.deepEqual(mockSendRunnerCommandOnce.mock.calls[0]?.[2], { + assertRunnerCommand(mockSendRunnerCommandOnce.mock.calls[0]?.[2], { command: 'tap', x: 120, y: 240, @@ -239,7 +270,7 @@ test('runner session keeps readiness preflight for tap commands when ready but n assert.deepEqual(result, { tapped: true }); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); }); @@ -261,7 +292,7 @@ test('runner session keeps readiness preflight for tap commands when marked read assert.deepEqual(result, { tapped: true }); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); }); @@ -280,7 +311,7 @@ test('runner session keeps readiness preflight for non-tap mutating commands whe assert.deepEqual(result, { pressed: true }); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); }); @@ -371,7 +402,7 @@ test('runner session stop sends shutdown, cleans temporary runner files, and rel mockIsProcessAlive.mockReturnValue(false); await stopRunnerSession(session); - assert.deepEqual(mockWaitForRunner.mock.calls.at(-1)?.[2], { command: 'shutdown' }); + assertRunnerCommand(mockWaitForRunner.mock.calls.at(-1)?.[2], { command: 'shutdown' }); assert.deepEqual(mockCleanupTempFile.mock.calls, [ ['/tmp/session-runner.xctestrun'], ['/tmp/session-runner.json'], @@ -453,3 +484,18 @@ function runnerResponse(data: Record): Response { function runnerError(error: { code: string; message: string }): Response { return new Response(JSON.stringify({ ok: false, error })); } + +function assertRunnerCommand( + actual: unknown, + expected: Record, +): asserts actual is Record { + assert.equal(typeof actual, 'object'); + assert.notEqual(actual, null); + const command = actual as Record; + const commandId = command.commandId; + if (typeof commandId !== 'string') { + assert.fail('expected runner commandId'); + } + assert.match(commandId, /^runner-/); + assert.deepEqual({ ...command, commandId: undefined }, { ...expected, commandId: undefined }); +} diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index b1e788d89..cc8b52125 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -18,6 +18,7 @@ import { isReadOnlyRunnerCommand, isRetryableRunnerError, shouldRetryRunnerConnectError, + withRunnerCommandId, type RunnerCommand, } from './runner-contract.ts'; import { @@ -43,17 +44,18 @@ export async function runIosRunnerCommand( ): Promise> { validateRunnerDevice(device); assertRunnerRequestActive(options.requestId); + const runnerCommand = withRunnerCommandId(command); const provider = resolveAppleRunnerProvider( device, createLocalAppleRunnerProvider(executeRunnerCommand), undefined, { requestId: options.requestId }, ); - if (isReadOnlyRunnerCommand(command.command)) { + if (isReadOnlyRunnerCommand(runnerCommand.command)) { return withRetry( () => { assertRunnerRequestActive(options.requestId); - return provider.runCommand(device, command, options); + return provider.runCommand(device, runnerCommand, options); }, { shouldRetry: (error) => { @@ -63,7 +65,7 @@ export async function runIosRunnerCommand( }, ); } - return provider.runCommand(device, command, options); + return provider.runCommand(device, runnerCommand, options); } export function prewarmIosRunnerSession( diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index c7958a099..39459dd54 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { AppError } from '../../utils/errors.ts'; import type { ClickButton } from '../../core/click-button.ts'; import type { DeviceRotation } from '../../core/device-rotation.ts'; @@ -39,8 +40,11 @@ export type RunnerCommand = { | 'pinch' | 'recordStart' | 'recordStop' + | 'status' | 'uptime' | 'shutdown'; + commandId?: string; + statusCommandId?: string; appBundleId?: string; text?: string; selectorKey?: 'id' | 'label' | 'text' | 'value'; @@ -199,10 +203,20 @@ export function isReadOnlyRunnerCommand(command: RunnerCommand['command']): bool command === 'querySelector' || command === 'readText' || command === 'alert' || + command === 'status' || command === 'uptime' ); } +export function withRunnerCommandId(command: RunnerCommand): RunnerCommand { + if (command.commandId) return command; + return { ...command, commandId: createRunnerCommandId() }; +} + +function createRunnerCommandId(): string { + return `runner-${crypto.randomUUID()}`; +} + export function assertRunnerRequestActive(requestId: string | undefined): void { if (!isRequestCanceled(requestId)) return; throw createRequestCanceledError(); diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 40099971d..239970ea7 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -25,7 +25,11 @@ import { resolveRunnerMaxConcurrentDestinationsFlag, runnerPrepProcesses, } from './runner-xctestrun.ts'; -import { isReadOnlyRunnerCommand, type RunnerCommand } from './runner-contract.ts'; +import { + isReadOnlyRunnerCommand, + withRunnerCommandId, + type RunnerCommand, +} from './runner-contract.ts'; import type { RunnerSession } from './runner-session-types.ts'; export type { RunnerSession } from './runner-session-types.ts'; @@ -259,9 +263,9 @@ async function stopRunnerSessionInternal( await waitForRunner( session.device, session.port, - { + withRunnerCommandId({ command: 'shutdown', - } as RunnerCommand, + } as RunnerCommand), undefined, RUNNER_SHUTDOWN_TIMEOUT_MS, ); @@ -432,19 +436,34 @@ export async function executeRunnerCommandWithSession( signal?: AbortSignal, ): Promise> { emitRunnerStartupTimings(session, command.command); - const readOnlyCommand = isReadOnlyRunnerCommand(command.command); + const runnerCommand = withRunnerCommandId(command); + const readOnlyCommand = isReadOnlyRunnerCommand(runnerCommand.command); if (readOnlyCommand) { const response = await withDiagnosticTimer( 'ios_runner_command_send', async () => - await waitForRunner(device, session.port, command, logPath, timeoutMs, session, signal), - { command: command.command, readOnly: true, sessionReady: session.ready, timeoutMs }, + await waitForRunner( + device, + session.port, + runnerCommand, + logPath, + timeoutMs, + session, + signal, + ), + { + command: runnerCommand.command, + commandId: runnerCommand.commandId, + readOnly: true, + sessionReady: session.ready, + timeoutMs, + }, ); return await parseRunnerResponse(response, session, logPath); } const deadline = Deadline.fromTimeoutMs(timeoutMs); - const shouldPreflight = shouldPreflightMutatingRunnerCommand(session, command); + const shouldPreflight = shouldPreflightMutatingRunnerCommand(session, runnerCommand); if (shouldPreflight) { const readinessTimeoutMs = session.ready ? Math.min(RUNNER_READY_PREFLIGHT_TIMEOUT_MS, deadline.remainingMs()) @@ -456,13 +475,18 @@ export async function executeRunnerCommandWithSession( await waitForRunner( device, session.port, - { command: 'uptime' }, + withRunnerCommandId({ command: 'uptime' }), logPath, readinessTimeoutMs, session, signal, ), - { command: command.command, sessionReady: session.ready, timeoutMs: readinessTimeoutMs }, + { + command: runnerCommand.command, + commandId: runnerCommand.commandId, + sessionReady: session.ready, + timeoutMs: readinessTimeoutMs, + }, ); await parseRunnerResponse(readinessResponse, session, logPath); } catch (error) { @@ -474,6 +498,7 @@ export async function executeRunnerCommandWithSession( phase: 'ios_runner_readiness_preflight_skipped', data: { command: command.command, + commandId: runnerCommand.commandId, lastSuccessfulRunnerResponseAgeMs: session.lastSuccessfulRunnerResponseAtMs === undefined ? undefined @@ -488,8 +513,9 @@ export async function executeRunnerCommandWithSession( } const response = await withDiagnosticTimer( 'ios_runner_command_send', - async () => await sendRunnerCommandOnce(device, session.port, command, remainingMs, signal), - { command: command.command }, + async () => + await sendRunnerCommandOnce(device, session.port, runnerCommand, remainingMs, signal), + { command: runnerCommand.command, commandId: runnerCommand.commandId }, ); return await parseRunnerResponse(response, session, logPath); } diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index 84a9dd83b..262911849 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -1,4 +1,5 @@ import type { AppleRunnerProvider } from '../../../src/platforms/ios/runner-provider.ts'; +import type { RunnerCommand } from '../../../src/platforms/ios/runner-contract.ts'; import type { AppleMacOsHostProvider, ApplePlistProvider, @@ -24,13 +25,20 @@ export function createAppleRunnerProviderFromTranscript( ): AppleRunnerProvider { return { runCommand: async (device, command) => - transcript.next(`${commandPrefix}.${command.command}`, command, { + transcript.next(`${commandPrefix}.${command.command}`, stripRunnerCommandId(command), { deviceId: device.id, platform: device.platform, }) as Record, }; } +function stripRunnerCommandId(command: RunnerCommand): RunnerCommand { + if (command.commandId === undefined) return command; + const normalized = { ...command }; + delete normalized.commandId; + return normalized; +} + export function createRecordingAppleToolProvider(handlers: RecordingAppleToolHandlers = {}): { provider: AppleToolProvider; calls: FlatToolCall[]; From b0328ecf4db9e7646b24702ae38f61e61df5eac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 20:16:24 -0500 Subject: [PATCH 2/6] fix: avoid journaling ios runner status probes --- .../RunnerTests+CommandExecution.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 9757a8b47..a0371fefa 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -26,6 +26,9 @@ extension RunnerTests { } func execute(command: Command) throws -> Response { + if command.command == .status { + return try executeDispatched(command: command) + } commandJournal.accept(command: command) commandJournal.start(command: command) do { From 38dc7a2d5082312adf774ab4e9add9578ea51735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 20:20:36 -0500 Subject: [PATCH 3/6] fix: cap ios runner journal responses --- .../RunnerTests+CommandJournal.swift | 32 ++++++++++++------- .../ios/__tests__/runner-client.test.ts | 7 ++++ src/platforms/ios/runner-contract.ts | 2 +- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift index f8025d0f2..704c6f24c 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -11,7 +11,8 @@ struct RunnerCommandJournalEntry { let commandId: String let command: String var state: RunnerCommandLifecycleState - var response: Response? + var responseOk: Bool? + var responseJson: String? var error: ErrorPayload? var updatedAtMs: Double } @@ -19,6 +20,7 @@ struct RunnerCommandJournalEntry { final class RunnerCommandJournal { private let lock = NSLock() private let maxEntries = 64 + private let maxResponseJsonBytes = 16 * 1024 private var entries: [String: RunnerCommandJournalEntry] = [:] private var order: [String] = [] @@ -30,7 +32,8 @@ final class RunnerCommandJournal { commandId: commandId, command: command.command.rawValue, state: .accepted, - response: nil, + responseOk: nil, + responseJson: nil, error: nil, updatedAtMs: currentTimeMs() ) @@ -40,14 +43,15 @@ final class RunnerCommandJournal { } func start(command: Command) { - update(command: command, state: .started, response: nil, error: nil) + update(command: command, state: .started, responseOk: nil, responseJson: nil, error: nil) } func finish(command: Command, response: Response) { update( command: command, state: response.ok ? .completed : .failed, - response: response, + responseOk: response.ok, + responseJson: encodeResponseJson(response), error: response.error ) } @@ -56,7 +60,8 @@ final class RunnerCommandJournal { update( command: command, state: .failed, - response: nil, + responseOk: nil, + responseJson: nil, error: ErrorPayload(message: error.localizedDescription) ) } @@ -75,8 +80,8 @@ final class RunnerCommandJournal { commandId: entry.commandId, lifecycleState: entry.state.rawValue, lifecycleCommand: entry.command, - lifecycleResponseOk: entry.response?.ok, - lifecycleResponseJson: encodeResponseJson(entry.response), + lifecycleResponseOk: entry.responseOk, + lifecycleResponseJson: entry.responseJson, lifecycleErrorCode: entry.error?.code, lifecycleErrorMessage: entry.error?.message, lifecycleErrorHint: entry.error?.hint @@ -86,7 +91,8 @@ final class RunnerCommandJournal { private func update( command: Command, state: RunnerCommandLifecycleState, - response: Response?, + responseOk: Bool?, + responseJson: String?, error: ErrorPayload? ) { guard let commandId = normalizedCommandId(command.commandId) else { return } @@ -96,12 +102,14 @@ final class RunnerCommandJournal { commandId: commandId, command: command.command.rawValue, state: .accepted, - response: nil, + responseOk: nil, + responseJson: nil, error: nil, updatedAtMs: currentTimeMs() ) entry.state = state - entry.response = response + entry.responseOk = responseOk + entry.responseJson = responseJson entry.error = error entry.updatedAtMs = currentTimeMs() entries[commandId] = entry @@ -127,9 +135,9 @@ final class RunnerCommandJournal { Date().timeIntervalSince1970 * 1000 } - private func encodeResponseJson(_ response: Response?) -> String? { - guard let response else { return nil } + private func encodeResponseJson(_ response: Response) -> String? { guard let data = try? JSONEncoder().encode(response) else { return nil } + guard data.count <= maxResponseJsonBytes else { return nil } return String(data: data, encoding: .utf8) } } diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 7a176070c..d57a1df4d 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -32,6 +32,7 @@ vi.mock('../runner-macos-products.ts', async () => { import type { DeviceInfo } from '../../../utils/device.ts'; import { AppError } from '../../../utils/errors.ts'; import type { RunnerCommand } from '../runner-contract.ts'; +import { withRunnerCommandId } from '../runner-contract.ts'; import { assertSafeDerivedCleanup, isRetryableRunnerError, @@ -382,6 +383,12 @@ test('runner protocol fixtures cover every runner command with JSON-safe samples assert.equal(roundTrip.recordStart!.quality, 7); }); +test('withRunnerCommandId replaces blank command ids', () => { + const command = withRunnerCommandId({ command: 'uptime', commandId: ' ' }); + + assert.match(command.commandId ?? '', /^runner-/); +}); + test('resolveRunnerDestination uses device destination for physical devices', () => { assert.equal(resolveRunnerDestination(iosDevice), 'platform=iOS,id=00008110-000E12341234002E'); }); diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index 39459dd54..1e3ff09e6 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -209,7 +209,7 @@ export function isReadOnlyRunnerCommand(command: RunnerCommand['command']): bool } export function withRunnerCommandId(command: RunnerCommand): RunnerCommand { - if (command.commandId) return command; + if (command.commandId?.trim()) return command; return { ...command, commandId: createRunnerCommandId() }; } From fb6b06da8aeff39e227204051240ec2768efcb41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 20:23:28 -0500 Subject: [PATCH 4/6] perf: avoid snapshot journal serialization --- .../RunnerTests+CommandJournal.swift | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift index 704c6f24c..ec2735065 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -14,7 +14,6 @@ struct RunnerCommandJournalEntry { var responseOk: Bool? var responseJson: String? var error: ErrorPayload? - var updatedAtMs: Double } final class RunnerCommandJournal { @@ -34,8 +33,7 @@ final class RunnerCommandJournal { state: .accepted, responseOk: nil, responseJson: nil, - error: nil, - updatedAtMs: currentTimeMs() + error: nil ) order.removeAll { $0 == commandId } order.append(commandId) @@ -104,14 +102,12 @@ final class RunnerCommandJournal { state: .accepted, responseOk: nil, responseJson: nil, - error: nil, - updatedAtMs: currentTimeMs() + error: nil ) entry.state = state entry.responseOk = responseOk entry.responseJson = responseJson entry.error = error - entry.updatedAtMs = currentTimeMs() entries[commandId] = entry order.removeAll { $0 == commandId } order.append(commandId) @@ -131,11 +127,8 @@ final class RunnerCommandJournal { return trimmed.isEmpty ? nil : trimmed } - private func currentTimeMs() -> Double { - Date().timeIntervalSince1970 * 1000 - } - private func encodeResponseJson(_ response: Response) -> String? { + guard response.data?.nodes == nil else { return nil } guard let data = try? JSONEncoder().encode(response) else { return nil } guard data.count <= maxResponseJsonBytes else { return nil } return String(data: data, encoding: .utf8) From 0469f3cd674e1b4f788953fb32cbe4f0b7ef2e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 20:27:36 -0500 Subject: [PATCH 5/6] perf: skip ids for ios runner status probes --- .../ios/__tests__/runner-client.test.ts | 9 +++++++++ .../ios/__tests__/runner-session.test.ts | 18 ++++++++++++++---- src/platforms/ios/runner-contract.ts | 1 + 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index d57a1df4d..057fe4d2c 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -389,6 +389,15 @@ test('withRunnerCommandId replaces blank command ids', () => { assert.match(command.commandId ?? '', /^runner-/); }); +test('withRunnerCommandId does not add command ids to status probes', () => { + const command = withRunnerCommandId({ + command: 'status', + statusCommandId: 'runner-command-1', + }); + + assert.deepEqual(command, { command: 'status', statusCommandId: 'runner-command-1' }); +}); + test('resolveRunnerDestination uses device destination for physical devices', () => { assert.equal(resolveRunnerDestination(iosDevice), 'platform=iOS,id=00008110-000E12341234002E'); }); diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index 9ff9c75b4..3ad170e1d 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -159,10 +159,14 @@ test('runner session executes status command as read-only lifecycle command', as lifecycleResponseOk: true, }); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { - command: 'status', - statusCommandId: 'runner-command-1', - }); + assertRunnerCommand( + mockWaitForRunner.mock.calls[0]?.[2], + { + command: 'status', + statusCommandId: 'runner-command-1', + }, + { commandId: false }, + ); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); }); @@ -488,11 +492,17 @@ function runnerError(error: { code: string; message: string }): Response { function assertRunnerCommand( actual: unknown, expected: Record, + options: { commandId?: boolean } = {}, ): asserts actual is Record { assert.equal(typeof actual, 'object'); assert.notEqual(actual, null); const command = actual as Record; const commandId = command.commandId; + if (options.commandId === false) { + assert.equal(commandId, undefined); + assert.deepEqual(command, expected); + return; + } if (typeof commandId !== 'string') { assert.fail('expected runner commandId'); } diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index 1e3ff09e6..e6af965f3 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -209,6 +209,7 @@ export function isReadOnlyRunnerCommand(command: RunnerCommand['command']): bool } export function withRunnerCommandId(command: RunnerCommand): RunnerCommand { + if (command.command === 'status') return command; if (command.commandId?.trim()) return command; return { ...command, commandId: createRunnerCommandId() }; } From da2c3e14ec4c5e1ec956b146dc1f17f1232ea681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 1 Jun 2026 20:35:37 -0500 Subject: [PATCH 6/6] refactor: keep ios runner status off main thread --- .../RunnerTests+CommandExecution.swift | 22 +++++++++++-------- .../RunnerTests+CommandJournal.swift | 8 +++++-- .../ios/__tests__/runner-client.test.ts | 6 +++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index a0371fefa..8730053d4 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -27,7 +27,7 @@ extension RunnerTests { func execute(command: Command) throws -> Response { if command.command == .status { - return try executeDispatched(command: command) + return executeStatus(command: command) } commandJournal.accept(command: command) commandJournal.start(command: command) @@ -41,6 +41,17 @@ extension RunnerTests { } } + private func executeStatus(command: Command) -> Response { + guard + let statusCommandId = command.statusCommandId? + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusCommandId.isEmpty + else { + return Response(ok: false, error: ErrorPayload(message: "status requires statusCommandId")) + } + return Response(ok: true, data: commandJournal.status(commandId: statusCommandId)) + } + private func executeDispatched(command: Command) throws -> Response { if Thread.isMainThread { return try executeOnMainSafely(command: command) @@ -200,14 +211,7 @@ extension RunnerTests { switch command.command { case .status: - guard - let statusCommandId = command.statusCommandId? - .trimmingCharacters(in: .whitespacesAndNewlines), - !statusCommandId.isEmpty - else { - return Response(ok: false, error: ErrorPayload(message: "status requires statusCommandId")) - } - return Response(ok: true, data: commandJournal.status(commandId: statusCommandId)) + return executeStatus(command: command) case .shutdown: stopRecordingIfNeeded() return Response(ok: true, data: DataPayload(message: "shutdown")) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift index ec2735065..ac0c3f448 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -1,6 +1,7 @@ import Foundation enum RunnerCommandLifecycleState: String { + case notAccepted case accepted case started case completed @@ -66,13 +67,16 @@ final class RunnerCommandJournal { func status(commandId: String) -> DataPayload { guard let normalized = normalizedCommandId(commandId) else { - return DataPayload(lifecycleState: "notAccepted") + return DataPayload(lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue) } lock.lock() let entry = entries[normalized] lock.unlock() guard let entry else { - return DataPayload(commandId: normalized, lifecycleState: "notAccepted") + return DataPayload( + commandId: normalized, + lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue + ) } return DataPayload( commandId: entry.commandId, diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 057fe4d2c..79b732e01 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -389,6 +389,12 @@ test('withRunnerCommandId replaces blank command ids', () => { assert.match(command.commandId ?? '', /^runner-/); }); +test('withRunnerCommandId preserves existing command ids', () => { + const command = withRunnerCommandId({ command: 'uptime', commandId: 'runner-existing' }); + + assert.deepEqual(command, { command: 'uptime', commandId: 'runner-existing' }); +}); + test('withRunnerCommandId does not add command ids to status probes', () => { const command = withRunnerCommandId({ command: 'status',