diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 9386b05380..683bb3372f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -1537,7 +1537,11 @@ extension RunnerTests { } case .recordStop: guard let recorder = activeRecording else { - return Response(ok: false, error: ErrorPayload(message: "no active recording")) + // The runner protocol is the durable cleanup primitive. A daemon may crash after the + // native stop succeeds but before it commits the resource transition, so exact-owner + // recovery must be able to repeat this command safely. Public `record stop` still owns + // its user-facing no-active validation through the daemon session manifest. + return Response(ok: true, data: DataPayload(message: "recording already stopped")) } do { try recorder.stop() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+RecordingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+RecordingTests.swift new file mode 100644 index 0000000000..037e33b7b3 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+RecordingTests.swift @@ -0,0 +1,19 @@ +import XCTest + +extension RunnerTests { +#if AGENT_DEVICE_RUNNER_UNIT_TESTS + func testRecordStopIsIdempotentAfterNativeRecorderAlreadyStopped() throws { + activeRecording = nil + + for commandId in ["record-stop-recovery-one", "record-stop-recovery-two"] { + let json = #"{"command":"recordStop","commandId":"\#(commandId)"}"# + let command = try JSONDecoder().decode(Command.self, from: Data(json.utf8)) + let response = try execute(command: command) + + XCTAssertTrue(response.ok) + XCTAssertEqual(response.data?.message, "recording already stopped") + XCTAssertNil(activeRecording) + } + } +#endif +} diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 75ce8787fa..7a5c72a9c3 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -10,8 +10,9 @@ original baseline `44c298d7f3a0ef84bc47f34c54d88b6c9eeb0df2`, through merged `de `457fafe6399a95a4ddbfac57f02b3a7fe4157a54`. The earlier checkpoints at `99f5af1b7` and `d73bdb4ae` are superseded and were not behavior-passing: later review found correctness failures and the first budget decision still used the unrevised +3% limit. The required cleanup package, explicit budget -decision, and clean rerun are now complete. The next authorized command unit is recordings onto the -durable-capture substrate; this decision does not authorize an unbounded platform migration. +decision, and clean rerun are now complete. The authorized recordings command unit moves onto the +durable-capture substrate under its separately reviewed cumulative bound below; this decision does +not authorize another command unit or an unbounded platform migration. During the `devices` unit, doctor discovery, replay-test sharding, Apple simulator hints, and Android emulator lifecycle keep their existing command execution owners while consuming the same injected, @@ -570,6 +571,24 @@ The controlled 15-run startup medians showed no regression (`--version` 94.5 ms emission; the distribution cost is the accepted reliability/cloud/substrate decision above. Future units must define and review their own cumulative budget rather than inheriting this headroom. +The recordings command unit has its own reviewed budget (2026-08-11). The cumulative denominator +remains the original `44c298d7f` baseline; rebasing onto the completed checkpoint does not reset it. +The immediate stack-base delta from durable-capture head `e3b0956b` is reported separately so the +cost of this command unit stays visible. The exact #1724 head that reproduces this table is recorded +in the acceptance comment before readiness. The increase pays for runtime-owned screen-recording +transports, fenced artifact finalization and cross-daemon recovery, and provider parity. It is not +unused checkpoint headroom and is not an allowance for a later command: + +| Metric | Original baseline | Recording bound | Cumulative change | Recording-only change | +| --- | ---: | ---: | ---: | ---: | +| Raw JavaScript | 2,036,067 B | 2,166,159 B | +130,092 B (+6.389%) | +32,026 B (+1.501%) | +| Gzipped JavaScript | 659,646 B | 708,776 B | +49,130 B (+7.448%) | +12,902 B (+1.854%) | +| npm tarball | 797,027 B | 836,426 B | +39,399 B (+4.943%) | +8,987 B (+1.086%) | +| npm unpacked | 2,781,186 B | 2,913,430 B | +132,244 B (+4.755%) | +32,494 B (+1.128%) | + +These bounds admit only the completed recordings cutover. Every subsequent command unit must define +and review both its original-baseline cumulative bound and its immediate stack-base delta. + The tracking issue owns command order, PR/file lists, test-only compatibility fixtures, exact benchmark commands and thresholds, raw evidence, and reviewers. Temporary fixtures never authorize a production bridge, duplicate route, or recorded package back-import. After the checkpoint, this diff --git a/package.json b/package.json index c121936361..955ae014db 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/run.test.ts", "check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts", "check:coverage-changed:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts", - "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/logs-runtime-cutover-policy.test.ts scripts/layering/network-runtime-cutover-policy.test.ts scripts/layering/contracts-implementation-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts", + "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/logs-runtime-cutover-policy.test.ts scripts/layering/network-runtime-cutover-policy.test.ts scripts/layering/record-runtime-cutover-policy.test.ts scripts/layering/contracts-implementation-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts", "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", "depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index 8eb8eda266..93a15c26da 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -9,6 +9,9 @@ export { decodeAppLogProcessMarker, } from './app-log-runtime.ts'; export { createAppLogLiveHandle, createAppLogLiveHandleFromFinish } from './app-log-live-handle.ts'; +export { createScreenRecordingLiveHandle } from './screen-recording-live-handle.ts'; +export { createScreenRecordingCompletion } from './screen-recording-completion.ts'; +export { assertScreenRecordingOptionsSupported } from './screen-recording-options.ts'; export { cleanupManagedAppLogProcess, reattachCleanupOnlyAppLogProcess, diff --git a/packages/capture-kit/src/platform-runtime-unavailable.test.ts b/packages/capture-kit/src/platform-runtime-unavailable.test.ts index 71e40139bc..2ffd1864c3 100644 --- a/packages/capture-kit/src/platform-runtime-unavailable.test.ts +++ b/packages/capture-kit/src/platform-runtime-unavailable.test.ts @@ -34,6 +34,9 @@ test('builds one complete combined unavailable owner without fake operations', a 'appLogReattach', 'appLogStart', 'networkDump', + 'screenRecordingCleanup', + 'screenRecordingReattach', + 'screenRecordingStart', ]); assert.deepEqual(binding.operations, {}); await binding[Symbol.asyncDispose](); diff --git a/packages/capture-kit/src/platform-runtime-unavailable.ts b/packages/capture-kit/src/platform-runtime-unavailable.ts index b81bdf2df6..c306f835af 100644 --- a/packages/capture-kit/src/platform-runtime-unavailable.ts +++ b/packages/capture-kit/src/platform-runtime-unavailable.ts @@ -11,9 +11,16 @@ import { type RuntimeOwnerRef, } from '@agent-device/contracts/platform'; -export type UnavailablePlatformRuntimeFacts = Readonly<{ +type UnavailablePlatformRuntimeFacts = Readonly<{ appLog: RuntimeOperationUnavailability; network: RuntimeOperationUnavailability; + screenRecording?: RuntimeOperationUnavailability; +}>; + +type FrozenUnavailablePlatformRuntimeFacts = Readonly<{ + appLog: RuntimeOperationUnavailability; + network: RuntimeOperationUnavailability; + screenRecording: RuntimeOperationUnavailability; }>; /** Builds one honest combined owner for a family with no app-log or network mechanics. */ @@ -22,8 +29,7 @@ export function createUnavailablePlatformRuntimeOwner( unavailable: UnavailablePlatformRuntimeFacts, ): PlatformRuntimeOwner { const owner = localRuntimeOwner(family); - const appLog = Object.freeze({ ...unavailable.appLog }); - const network = Object.freeze({ ...unavailable.network }); + const facts = freezeUnavailableFacts(unavailable); return Object.freeze({ owner, ownsDevice: (device) => device.platform === family, @@ -40,10 +46,7 @@ export function createUnavailablePlatformRuntimeOwner( `${family} platform runtime cannot bind ${request.device.platform}`, ); } - return createUnavailablePlatformRuntimeBinding(request.device, owner, { - appLog, - network, - }); + return createUnavailablePlatformRuntimeBinding(request.device, owner, facts); }, shutdown: async () => undefined, }); @@ -54,8 +57,7 @@ export function createUnavailablePlatformRuntimeBinding( owner: RuntimeOwnerRef, unavailable: UnavailablePlatformRuntimeFacts, ): DeviceBinding { - const appLog = Object.freeze({ ...unavailable.appLog }); - const network = Object.freeze({ ...unavailable.network }); + const { appLog, network, screenRecording } = freezeUnavailableFacts(unavailable); const facts: RuntimeFacts = Object.freeze({ device: { ...deviceShape(device), @@ -68,6 +70,9 @@ export function createUnavailablePlatformRuntimeBinding( appLogReattach: appLog, appLogCleanup: appLog, networkDump: network, + screenRecordingStart: screenRecording, + screenRecordingReattach: screenRecording, + screenRecordingCleanup: screenRecording, }, }); return Object.freeze({ @@ -78,3 +83,15 @@ export function createUnavailablePlatformRuntimeBinding( [Symbol.asyncDispose]: async () => undefined, }); } + +function freezeUnavailableFacts( + unavailable: UnavailablePlatformRuntimeFacts, +): FrozenUnavailablePlatformRuntimeFacts { + return Object.freeze({ + appLog: Object.freeze({ ...unavailable.appLog }), + network: Object.freeze({ ...unavailable.network }), + screenRecording: Object.freeze({ + ...(unavailable.screenRecording ?? unavailable.network), + }), + }); +} diff --git a/packages/capture-kit/src/screen-recording-completion.test.ts b/packages/capture-kit/src/screen-recording-completion.test.ts new file mode 100644 index 0000000000..80aeaf2547 --- /dev/null +++ b/packages/capture-kit/src/screen-recording-completion.test.ts @@ -0,0 +1,36 @@ +import { expect, test, vi } from 'vitest'; +import { createScreenRecordingCompletion } from './screen-recording-completion.ts'; + +test('builds the common terminal recording result without dropping finalizer metadata', () => { + vi.setSystemTime(200); + expect( + createScreenRecordingCompletion( + { + backend: 'backend', + outPath: '/tmp/capture.mp4', + clientOutPath: '/client/capture.mp4', + startedAt: 100, + scope: 'device', + showTouches: true, + recordOnlySession: false, + gestureEvents: [], + }, + { telemetryPath: '/tmp/capture.telemetry.json' }, + false, + ), + ).toEqual({ + status: 'completed', + result: { + backend: 'backend', + outPath: '/tmp/capture.mp4', + clientOutPath: '/client/capture.mp4', + startedAt: 100, + completedAt: 200, + scope: 'device', + showTouches: false, + recordOnlySession: false, + telemetryPath: '/tmp/capture.telemetry.json', + }, + }); + vi.useRealTimers(); +}); diff --git a/packages/capture-kit/src/screen-recording-completion.ts b/packages/capture-kit/src/screen-recording-completion.ts new file mode 100644 index 0000000000..828a8968fc --- /dev/null +++ b/packages/capture-kit/src/screen-recording-completion.ts @@ -0,0 +1,29 @@ +import type { + ScreenRecordingCompletion, + ScreenRecordingFinalizer, + ScreenRecordingLiveSnapshot, +} from '@agent-device/contracts/platform'; + +export function createScreenRecordingCompletion( + snapshot: ScreenRecordingLiveSnapshot, + finalization: Awaited>, + showTouches = snapshot.showTouches, +): Readonly<{ status: 'completed'; result: ScreenRecordingCompletion }> { + return Object.freeze({ + status: 'completed', + result: Object.freeze({ + backend: snapshot.backend, + outPath: snapshot.outPath, + ...(snapshot.clientOutPath === undefined ? {} : { clientOutPath: snapshot.clientOutPath }), + startedAt: snapshot.startedAt, + completedAt: Date.now(), + scope: snapshot.scope, + showTouches, + recordOnlySession: snapshot.recordOnlySession, + ...(snapshot.activeSessionApp === undefined + ? {} + : { activeSessionApp: snapshot.activeSessionApp }), + ...finalization, + }), + }); +} diff --git a/packages/capture-kit/src/screen-recording-live-handle.test.ts b/packages/capture-kit/src/screen-recording-live-handle.test.ts new file mode 100644 index 0000000000..3456e245b5 --- /dev/null +++ b/packages/capture-kit/src/screen-recording-live-handle.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import { createScreenRecordingLiveHandle } from './screen-recording-live-handle.ts'; + +test('keeps mutable gesture evidence on the live handle and settles cleanup once', async () => { + let cleanupCalls = 0; + const handle = createScreenRecordingLiveHandle( + { + backend: 'fixture', + outPath: '/tmp/recording.mp4', + startedAt: 1, + scope: 'app', + showTouches: true, + recordOnlySession: false, + gestureEvents: [], + }, + { + finish: async () => ({ + status: 'completed', + result: { + backend: 'fixture', + outPath: '/tmp/recording.mp4', + startedAt: 1, + completedAt: 2, + scope: 'app', + showTouches: true, + recordOnlySession: false, + }, + }), + forceCleanup: async () => { + cleanupCalls += 1; + return { status: 'cleaned' } as const; + }, + }, + ); + handle.appendGestureEvents([{ kind: 'tap', tMs: 3, x: 4, y: 5 }]); + handle.setRunnerSessionId('runner-1'); + handle.invalidate('runner restarted'); + assert.deepEqual(handle.inspect().gestureEvents, [{ kind: 'tap', tMs: 3, x: 4, y: 5 }]); + assert.equal(handle.inspect().invalidatedReason, 'runner restarted'); + assert.equal(handle.inspect().runnerSessionId, 'runner-1'); + await handle.forceCleanup(); + await handle.forceCleanup(); + assert.equal(cleanupCalls, 1); +}); + +test('successful finish makes concurrent disposal inert', async () => { + let resolveFinish: ((outcome: ReturnType) => void) | undefined; + const finish = vi.fn( + async () => + await new Promise>((resolve) => { + resolveFinish = resolve; + }), + ); + const cleanup = vi.fn(async () => ({ status: 'cleaned' }) as const); + const handle = createScreenRecordingLiveHandle(snapshot(), { finish, forceCleanup: cleanup }); + + const finishing = handle.finish(); + const disposing = handle[Symbol.asyncDispose](); + resolveFinish?.(completed()); + + await assert.doesNotReject(async () => await disposing); + assert.deepEqual(await finishing, completed()); + assert.equal(finish.mock.calls.length, 1); + assert.equal(cleanup.mock.calls.length, 0); +}); + +test('failed finish permits one forced cleanup', async () => { + const finish = vi.fn(async () => { + throw new Error('finalization failed'); + }); + const cleanup = vi.fn(async () => ({ status: 'cleaned' }) as const); + const handle = createScreenRecordingLiveHandle(snapshot(), { finish, forceCleanup: cleanup }); + + await assert.rejects(async () => await handle.finish(), /finalization failed/); + await handle[Symbol.asyncDispose](); + await handle[Symbol.asyncDispose](); + + assert.equal(finish.mock.calls.length, 1); + assert.equal(cleanup.mock.calls.length, 1); +}); + +function snapshot() { + return { + backend: 'fixture', + outPath: '/tmp/recording.mp4', + startedAt: 1, + scope: 'app' as const, + showTouches: false, + recordOnlySession: false, + gestureEvents: [], + }; +} + +function completed() { + return { + status: 'completed' as const, + result: { + backend: 'fixture', + outPath: '/tmp/recording.mp4', + startedAt: 1, + completedAt: 2, + scope: 'app' as const, + showTouches: false, + recordOnlySession: false, + }, + }; +} diff --git a/packages/capture-kit/src/screen-recording-live-handle.ts b/packages/capture-kit/src/screen-recording-live-handle.ts new file mode 100644 index 0000000000..f0e3f0058e --- /dev/null +++ b/packages/capture-kit/src/screen-recording-live-handle.ts @@ -0,0 +1,89 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { + isConfirmedCleanup, + type CleanupOutcome, + type FinishOutcome, + type RecordingGestureEvent, + type ScreenRecordingCompletion, + type ScreenRecordingLiveHandle, + type ScreenRecordingLiveSnapshot, +} from '@agent-device/contracts/platform'; +import type { GestureReferenceFrame } from '@agent-device/contracts/interaction'; + +type ScreenRecordingLiveHandleImplementation = Readonly<{ + finish(snapshot: ScreenRecordingLiveSnapshot): Promise>; + forceCleanup(snapshot: ScreenRecordingLiveSnapshot): Promise; +}>; + +/** Owns mutable recording telemetry without leaking it into daemon session state. */ +export function createScreenRecordingLiveHandle( + initial: ScreenRecordingLiveSnapshot, + implementation: ScreenRecordingLiveHandleImplementation, +): ScreenRecordingLiveHandle { + let snapshot = freezeSnapshot(initial); + let finish: Promise> | undefined; + let cleanup: Promise | undefined; + let disposal: Promise | undefined; + const finishRecording = () => (finish ??= implementation.finish(snapshot)); + const forceCleanup = () => + (cleanup ??= finish + ? finish.then( + async (outcome) => + outcome.status === 'completed' + ? ({ status: 'cleaned' } as const) + : await implementation.forceCleanup(snapshot), + async () => await implementation.forceCleanup(snapshot), + ) + : implementation.forceCleanup(snapshot)); + return Object.freeze({ + inspect: () => snapshot, + appendGestureEvents: (events: readonly RecordingGestureEvent[]) => { + if (events.length === 0 || finish || cleanup) return; + snapshot = freezeSnapshot({ + ...snapshot, + gestureEvents: [...snapshot.gestureEvents, ...events], + }); + }, + setTouchReferenceFrame: (touchReferenceFrame: GestureReferenceFrame | undefined) => { + if (finish || cleanup) return; + snapshot = freezeSnapshot({ + ...snapshot, + ...(touchReferenceFrame ? { touchReferenceFrame } : {}), + }); + }, + setRunnerSessionId: (runnerSessionId: string) => { + if (finish || cleanup || runnerSessionId.trim().length === 0) return; + snapshot = freezeSnapshot({ ...snapshot, runnerSessionId }); + }, + invalidate: (invalidatedReason: string) => { + if (finish || cleanup || snapshot.invalidatedReason) return; + snapshot = freezeSnapshot({ ...snapshot, invalidatedReason }); + }, + finish: finishRecording, + forceCleanup, + [Symbol.asyncDispose]: async () => { + disposal ??= forceCleanup().then(assertConfirmedCleanup); + await disposal; + }, + }); +} + +function freezeSnapshot(snapshot: ScreenRecordingLiveSnapshot): ScreenRecordingLiveSnapshot { + return Object.freeze({ ...snapshot, gestureEvents: Object.freeze([...snapshot.gestureEvents]) }); +} + +function assertConfirmedCleanup(outcome: CleanupOutcome): void { + if (isConfirmedCleanup(outcome)) return; + throw new AppError( + 'COMMAND_FAILED', + outcome.message ?? 'Screen recording cleanup could not be confirmed', + { + reason: outcome.reason, + retriable: outcome.reason !== 'ownership-fence-lost', + hint: + outcome.reason === 'ownership-fence-lost' + ? 'Use the current recording owner or recovery record before retrying cleanup.' + : 'Keep the recovery record and retry cleanup through the exact runtime owner.', + }, + ); +} diff --git a/packages/capture-kit/src/screen-recording-options.test.ts b/packages/capture-kit/src/screen-recording-options.test.ts new file mode 100644 index 0000000000..c633cd28b6 --- /dev/null +++ b/packages/capture-kit/src/screen-recording-options.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest'; +import { assertScreenRecordingOptionsSupported } from './screen-recording-options.ts'; + +test('reports only requested options outside a runtime support declaration', () => { + expect(() => + assertScreenRecordingOptionsSupported( + { + sessionId: 'one', + outputPath: '/tmp/capture.webm', + scope: 'device', + showTouches: false, + hideTouchesRequested: true, + recordOnlySession: false, + fps: 30, + fence: { token: 'fence', generation: 1 }, + }, + { scopes: ['app'], fps: false, exportQuality: false, hideTouches: false }, + (unsupported) => `unsupported: ${unsupported.join(', ')}`, + ), + ).toThrow('unsupported: --scope, --fps, --hide-touches'); +}); diff --git a/packages/capture-kit/src/screen-recording-options.ts b/packages/capture-kit/src/screen-recording-options.ts new file mode 100644 index 0000000000..8efdee631e --- /dev/null +++ b/packages/capture-kit/src/screen-recording-options.ts @@ -0,0 +1,33 @@ +import type { RecordingScope } from '@agent-device/contracts/recording'; +import type { ScreenRecordingStartInput } from '@agent-device/contracts/platform'; +import { AppError } from '@agent-device/kernel/errors'; + +type ScreenRecordingOptionSupport = Readonly<{ + scopes: readonly RecordingScope[]; + fps: boolean; + exportQuality: boolean; + hideTouches: boolean; +}>; + +export function assertScreenRecordingOptionsSupported( + input: ScreenRecordingStartInput, + support: ScreenRecordingOptionSupport, + message: (unsupported: readonly string[]) => string, +): void { + const unsupported = unsupportedScreenRecordingOptions(input, support); + if (unsupported.length > 0) { + throw new AppError('INVALID_ARGS', message(unsupported)); + } +} + +function unsupportedScreenRecordingOptions( + input: ScreenRecordingStartInput, + support: ScreenRecordingOptionSupport, +): readonly string[] { + return Object.freeze([ + ...(support.scopes.includes(input.scope) ? [] : ['--scope']), + ...(support.fps || input.fps === undefined ? [] : ['--fps']), + ...(support.exportQuality || input.exportQuality === undefined ? [] : ['--quality']), + ...(support.hideTouches || !input.hideTouchesRequested ? [] : ['--hide-touches']), + ]); +} diff --git a/packages/contracts/src/app-log-runtime.ts b/packages/contracts/src/app-log-runtime.ts index bb657c903b..35dcf7f4b8 100644 --- a/packages/contracts/src/app-log-runtime.ts +++ b/packages/contracts/src/app-log-runtime.ts @@ -6,6 +6,8 @@ import type { HostCommandResult, HostCommandRunner, HostToolchainPreparer, + ManagedProcessIdentity, + ManagedProcessOwnership, } from './platform-runtime-host.ts'; import type { ResourceOwnershipFence, RuntimeProviderMode } from './platform-runtime.ts'; import type { PendingTransferGuard } from './async-lifecycle.ts'; @@ -89,18 +91,14 @@ export type AppLogOutputSink = AsyncDisposable & write(chunk: string | Uint8Array): Promise; }>; -export type AppLogProcessMarker = Readonly<{ - pid: number; - startTime: string; - command: string; -}>; +export type AppLogProcessMarker = ManagedProcessIdentity; export type AppLogProcessMarkerReadOutcome = | Readonly<{ status: 'missing' }> | Readonly<{ status: 'invalid'; message: string }> | Readonly<{ status: 'decoded'; marker: AppLogProcessMarker }>; -export type AppLogProcessOwnership = 'missing' | 'owned-alive' | 'ownership-lost'; +export type AppLogProcessOwnership = ManagedProcessOwnership; export type AppLogBackgroundProcess = AsyncDisposable & Readonly<{ diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 9cfa8fa860..af56ea6dc2 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -115,6 +115,50 @@ export type { NetworkRuntimeOperations, NetworkTransport, } from '../network-runtime.ts'; +export { SCREEN_RECORDING_RESOURCE_KIND } from '../screen-recording-runtime.ts'; +export type { + RecordingGestureEvent, + ScreenRecordingChunk, + ScreenRecordingCompletion, + ScreenRecordingLiveHandle, + ScreenRecordingLiveSnapshot, + ScreenRecordingReattachInput, + ScreenRecordingRuntimeOperations, + ScreenRecordingStartInput, + ScreenRecordingStartResult, +} from '../screen-recording-runtime.ts'; +export type { + AndroidScreenRecordingHost, + AndroidScreenRecordingManifestReadOutcome, + AndroidScreenRecordingProcessIdentity, + AndroidScreenRecordingProcessOwnership, + AndroidScreenRecordingStopOutcome, + AndroidScreenRecordingTransport, + AppleScreenRecordingAvailability, + AppleScreenRecordingClockAnchor, + AppleScreenRecordingHost, + AppleScreenRecordingRunnerRequest, + AppleScreenRecordingRunnerResult, + HarmonyScreenRecordingHost, + ScreenRecordingBackgroundProcess, + ScreenRecordingFinalizer, + ScreenRecordingOutputHost, + ScreenRecordingRuntimeHost, + WebScreenRecordingHost, + WebScreenRecordingTransport, +} from '../screen-recording-runtime-host.ts'; +export { + screenRecordingAdmissionUse, + screenRecordingRuntimePlanUses, + screenRecordingStartUse, + screenRecordingRecoveryUse, + resolveScreenRecordingRuntimePlan, +} from '../screen-recording-runtime-plan.ts'; +export type { + ScreenRecordingRuntimePlan, + ScreenRecordingRuntimePlanInput, +} from '../screen-recording-runtime-plan.ts'; +export { assertRecordRuntimeExecution } from '../record-runtime-cutover.ts'; export { networkAdmissionUse, networkDumpUse, @@ -150,6 +194,8 @@ export type { HostToolchainPreparer, HostOperatingSystem, HostTemporaryTextFile, + ManagedProcessIdentity, + ManagedProcessOwnership, DeviceInventoryFileHost, DeviceInventoryHost, DeviceInventoryHostByFamily, diff --git a/packages/contracts/src/facades/recording.ts b/packages/contracts/src/facades/recording.ts index 795b13b7fb..0175a29c10 100644 --- a/packages/contracts/src/facades/recording.ts +++ b/packages/contracts/src/facades/recording.ts @@ -9,7 +9,6 @@ export { RECORDING_SCOPE_VALUES, isWholeScreenRecordingScope } from '../recordin export type { RecordingScope } from '../recording-scope.ts'; export type { RecordingAppIdentity, - RecordingBackendTag, RecordingCommandResult, RecordingStartCommandResult, RecordingStopCommandResult, diff --git a/packages/contracts/src/perf.ts b/packages/contracts/src/perf.ts index d1ebd97469..44fdf2390b 100644 --- a/packages/contracts/src/perf.ts +++ b/packages/contracts/src/perf.ts @@ -46,7 +46,7 @@ export const isPerfMemoryKind = PERF_MEMORY_KINDS.is; * naming which family owns a device's `perf metrics` sampler; the daemon maps it back to * the concrete sampler via {@link PERF_METRICS_SAMPLERS_BY_TAG}. The * {@link PlatformPlugin.perf} facet returns this tag (type-only in the plugin, exactly - * like {@link RecordingBackendTag} for recording), so core/platforms never carry the + * as a type-only value), so core/platforms never carry the * daemon-owned sampling composition. Only families that expose perf metrics carry the tag * (Apple, Android, and HarmonyOS); it is consulted solely after the support gate admits the platform. */ diff --git a/packages/contracts/src/platform-plugin.ts b/packages/contracts/src/platform-plugin.ts index efb86badbb..dec31342cb 100644 --- a/packages/contracts/src/platform-plugin.ts +++ b/packages/contracts/src/platform-plugin.ts @@ -1,5 +1,4 @@ import type { DeviceInfo, Platform, PlatformSelector } from '@agent-device/kernel/device'; -import type { RecordingBackendTag } from './recording.ts'; import type { PerfMetricsSamplerTag } from './perf.ts'; import type { PlatformGatedProviderResolverKey } from './platform-providers.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; @@ -28,9 +27,6 @@ type CapabilityBucket = 'apple' | 'android' | 'harmonyos' | 'vega' | 'linux' | ' * (wraps `supportsPlatformPerfMetrics`) plus the neutral {@link PerfMetricsSamplerTag} * resolver (wraps the per-platform metrics-sampling branch formerly open-coded in * `buildPerfResponseData`), both pinned by the daemon perf routing parity test; - * {@link PlatformPlugin.recording} carries the neutral - * {@link RecordingBackendTag} resolver (wraps the per-platform branch of - * `resolveRecordingBackendForDevice`, pinned by the recording routing parity test); * {@link PlatformPlugin.providers} carries the per-family platform-gated request * provider resolver list (replaces the hand `device.platform === …` gate in * `request-platform-providers.ts`, pinned by the providers routing parity test). The @@ -95,21 +91,6 @@ export type PlatformPlugin = { supportsMetrics(device: DeviceInfo): boolean; metricsSamplerTag(device: DeviceInfo): PerfMetricsSamplerTag; }; - /** - * The daemon recording facet (issue #974). `resolveBackendTag` wraps the - * per-platform branch of `resolveRecordingBackendForDevice` - * (src/daemon/handlers/record-trace-recording-backends.ts), returning the neutral - * {@link RecordingBackendTag} for `device` (a DATA-ONLY string, type-only in the - * plugin). The daemon maps the tag - * back to its own {@link RecordingBackend} instance, so core/platforms never construct - * the daemon-owned backend objects. Present on families with a recording backend - * (Apple + Android + web); left `undefined` for linux, where the hand branch fell - * through to the unsupported backend — the daemon lookup preserves that fallthrough - * (`?? 'unsupported'`), and the recording routing parity test pins the equivalence. - */ - readonly recording?: { - resolveBackendTag(device: DeviceInfo): RecordingBackendTag; - }; /** * The daemon request-scope provider facet (issue #974). `platformGatedResolvers` * declares which PLATFORM-GATED request provider resolvers apply to this family's @@ -118,8 +99,8 @@ export type PlatformPlugin = { * src/daemon/request-platform-providers.ts. The daemon still OWNS the resolver * functions, their wrapper composition, and the request-scope concurrency isolation; * this facet supplies only the per-family gate (a plain string list, the keys - * type-only in the plugin). The ungated `recordingProvider`, which applies on every - * platform, is intentionally NOT part of the facet and stays ungated in the daemon. + * type-only in the plugin). Focused command transports that are not family-gated + * are intentionally NOT part of the facet and stay ungated in the daemon. * Every family carries this facet (each * owns at least one platform-specific resolver); a device on an unregistered platform * resolves to no gated resolvers, matching the former hand gate. Pinned by the diff --git a/packages/contracts/src/platform-providers.ts b/packages/contracts/src/platform-providers.ts index b6fd00343b..5f6eb48173 100644 --- a/packages/contracts/src/platform-providers.ts +++ b/packages/contracts/src/platform-providers.ts @@ -14,7 +14,7 @@ * still OWNS the resolver invocation, wrapper composition, and request-scope * concurrency isolation — only the platform GATE moved to data. * - * `appLogProvider` / `recordingProvider` are deliberately ABSENT: they carry no + * App-log and screen-recording transports are deliberately ABSENT: they carry no * platform gate (they apply on every platform), so they stay ungated in the daemon and * are not part of the facet. */ diff --git a/packages/contracts/src/platform-runtime-host.ts b/packages/contracts/src/platform-runtime-host.ts index aedddb9817..72f7958147 100644 --- a/packages/contracts/src/platform-runtime-host.ts +++ b/packages/contracts/src/platform-runtime-host.ts @@ -17,6 +17,15 @@ export type HostCommandResult = Readonly<{ signal?: string; }>; +/** Exact host-process identity used by durable process-backed capabilities. */ +export type ManagedProcessIdentity = Readonly<{ + pid: number; + startTime: string; + command: string; +}>; + +export type ManagedProcessOwnership = 'missing' | 'owned-alive' | 'ownership-lost'; + /** Generic process-execution port; focused Apple foreground tools use AppleToolHost. */ export type HostCommandRunner = Readonly<{ which(executable: string): Promise; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index f5d1b099b9..9663ecd57e 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -1,14 +1,20 @@ import type { AppLogRuntimeHost, AppLogRuntimeOperations } from './app-log-runtime.ts'; import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-runtime.ts'; +import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts'; +import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts'; import type { DeviceRuntimeOwner, RuntimeOwnerRef, RuntimePlatformModule, } from './platform-runtime.ts'; -export type PlatformRuntimeOperations = AppLogRuntimeOperations & NetworkRuntimeOperations; +export type PlatformRuntimeOperations = AppLogRuntimeOperations & + NetworkRuntimeOperations & + ScreenRecordingRuntimeOperations; -export type PlatformRuntimeHost = AppLogRuntimeHost & NetworkRuntimeHost; +export type PlatformRuntimeHost = AppLogRuntimeHost & + NetworkRuntimeHost & + Readonly<{ screenRecording: ScreenRecordingRuntimeHost }>; export type PlatformRuntimeOwner = DeviceRuntimeOwner; diff --git a/packages/contracts/src/record-runtime-cutover.test.ts b/packages/contracts/src/record-runtime-cutover.test.ts new file mode 100644 index 0000000000..911990c4cc --- /dev/null +++ b/packages/contracts/src/record-runtime-cutover.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { assertRecordRuntimeExecution } from './record-runtime-cutover.ts'; +import { + screenRecordingRuntimePlanUses, + resolveScreenRecordingRuntimePlan, +} from './screen-recording-runtime-plan.ts'; + +test('record descriptor execution joins exactly to the two runtime-bearing plan uses', () => { + assert.doesNotThrow(() => + assertRecordRuntimeExecution({ kind: 'device-runtime', uses: screenRecordingRuntimePlanUses }), + ); +}); + +test('rejects incomplete and widened record descriptor declarations', () => { + const startUse = resolveScreenRecordingRuntimePlan({ action: 'start' }).use; + assert.throws( + () => assertRecordRuntimeExecution({ kind: 'device-runtime', use: startUse }), + /exactly one|two runtime-bearing plans/, + ); + assert.throws( + () => + assertRecordRuntimeExecution({ + kind: 'device-runtime', + uses: [ + ...screenRecordingRuntimePlanUses, + { required: ['screenRecordingCleanup'], preferred: [] }, + ], + }), + /exactly one|two runtime-bearing plans/, + ); +}); + +test('rejects duplicate declared uses even when their identity set looks complete', () => { + const [startUse, recoveryUse] = screenRecordingRuntimePlanUses; + assert.ok(startUse && recoveryUse); + assert.throws( + () => + assertRecordRuntimeExecution({ + kind: 'device-runtime', + uses: [startUse, startUse, recoveryUse], + }), + /exactly one|two runtime-bearing plans/, + ); +}); diff --git a/packages/contracts/src/record-runtime-cutover.ts b/packages/contracts/src/record-runtime-cutover.ts new file mode 100644 index 0000000000..9a70b0850f --- /dev/null +++ b/packages/contracts/src/record-runtime-cutover.ts @@ -0,0 +1,38 @@ +import { + assertCommandPlatformExecution, + type CommandPlatformExecution, +} from './command-platform-execution.ts'; +import { screenRecordingRuntimePlanUses } from './screen-recording-runtime-plan.ts'; +import type { RuntimeUseDeclaration } from './platform-runtime.ts'; + +/** Joins every normalized record plan to the descriptor's exhaustive runtime declaration. */ +export function assertRecordRuntimeExecution( + value: unknown, +): asserts value is Extract { + assertCommandPlatformExecution(value); + if (value.kind !== 'device-runtime' || !('uses' in value)) throw invalidRecordExecution(); + const declaredIdentities = value.uses.map(runtimeUseIdentity); + const plannedIdentities = screenRecordingRuntimePlanUses.map(runtimeUseIdentity); + const declared = new Set(declaredIdentities); + const planned = new Set(plannedIdentities); + if ( + declaredIdentities.length !== plannedIdentities.length || + declared.size !== declaredIdentities.length || + [...declared].some((identity) => !planned.has(identity)) + ) { + throw invalidRecordExecution(); + } +} + +function runtimeUseIdentity(use: RuntimeUseDeclaration): string { + return JSON.stringify({ + required: [...use.required].sort(), + preferred: [...use.preferred].sort(), + }); +} + +function invalidRecordExecution(): TypeError { + return new TypeError( + 'Record runtime execution must declare exactly the uses selected by its two runtime-bearing plans', + ); +} diff --git a/packages/contracts/src/recording.ts b/packages/contracts/src/recording.ts index 8ebf980101..401a2e3701 100644 --- a/packages/contracts/src/recording.ts +++ b/packages/contracts/src/recording.ts @@ -48,21 +48,3 @@ export type TraceCommandResult = outPath: string; artifacts: DaemonArtifact[]; }; - -/** - * The daemon-owned recording-backend discriminant (issue #974). A PLATFORM-NEUTRAL - * string tag naming which recording backend a device resolves to; the daemon maps it - * back to the concrete {@link RecordingBackend} instance via `RECORDING_BACKENDS_BY_TAG`. - * The {@link PlatformPlugin.recording} facet returns this tag (type-only in the plugin, - * exactly like {@link LogBackend} for app-log), so core/platforms never construct the - * daemon-owned backend objects. `'unsupported'` is the fallthrough for families that - * carry no recording facet (linux) and any unregistered platform. - */ -export type RecordingBackendTag = - | 'web' - | 'android' - | 'harmonyos' - | 'macos' - | 'ios-device' - | 'ios-simulator' - | 'unsupported'; diff --git a/packages/contracts/src/screen-recording-runtime-host.ts b/packages/contracts/src/screen-recording-runtime-host.ts new file mode 100644 index 0000000000..70057e84fc --- /dev/null +++ b/packages/contracts/src/screen-recording-runtime-host.ts @@ -0,0 +1,214 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { RecordingExportQuality } from './recording-export-quality.ts'; +import type { + HostCommandResult, + ManagedProcessIdentity, + ManagedProcessOwnership, +} from './platform-runtime-host.ts'; +import type { RecordingGestureEvent } from './screen-recording-runtime.ts'; + +/** A long-lived native recorder process. It deliberately carries no request scope. */ +export type ScreenRecordingBackgroundProcess = Readonly<{ + markers?: readonly ManagedProcessIdentity[]; + wait: Promise; + terminate(): Promise; +}>; + +/** Closed Apple runner requests used by the recording facet. */ +export type AppleScreenRecordingRunnerRequest = + | Readonly<{ + kind: 'start'; + appBundleId: string; + outputPath: string; + fps?: number; + }> + | Readonly<{ + kind: 'stop'; + appBundleId?: string; + runnerSessionId: string; + runnerAuthority: 'local-lease' | 'scoped-provider'; + }>; + +export type AppleScreenRecordingRunnerResult = Readonly<{ + recorderStartUptimeMs?: number; + targetAppReadyUptimeMs?: number; + runnerSessionId?: string; + runnerAuthority?: 'local-lease' | 'scoped-provider'; + remotePath?: string; +}>; + +export type AppleScreenRecordingAvailability = + | Readonly<{ available: true }> + | Readonly<{ available: false; hint: string }>; + +export type AppleScreenRecordingClockAnchor = Readonly<{ + wallClockAtMs: number; + uptimeMs: number; +}>; + +export type AppleScreenRecordingHost = Readonly<{ + availability(device: DeviceInfo): Promise; + runRunner( + device: DeviceInfo, + request: AppleScreenRecordingRunnerRequest, + signal?: AbortSignal, + ): Promise; + startSimulator( + device: DeviceInfo, + outputPath: string, + signal?: AbortSignal, + ): Promise; + inspectProcess(marker: ManagedProcessIdentity): Promise; + terminateProcess( + marker: ManagedProcessIdentity, + ): Promise<'terminated' | 'already-missing' | 'ownership-lost'>; + inspectRunner( + device: DeviceInfo, + runnerSessionId: string, + runnerAuthority: 'local-lease' | 'scoped-provider', + ): Promise; + retrieveRunnerRecording( + device: DeviceInfo, + remotePath: string, + outputPath: string, + signal?: AbortSignal, + ): Promise; + captureClockAnchor( + device: DeviceInfo, + appBundleId: string, + signal?: AbortSignal, + ): Promise; + isRunnerBundleId(bundleId: string): Promise; +}>; + +/** Scoped Android transport; callers cannot issue arbitrary adb commands. */ +export type AndroidScreenRecordingManifestReadOutcome = + | Readonly<{ status: 'missing' }> + | Readonly<{ status: 'read'; contents: string }> + | Readonly<{ status: 'unavailable'; message: string }>; + +export type AndroidScreenRecordingProcessIdentity = Readonly<{ + pid: string; + remotePath: string; + startTime: string; +}>; + +export type AndroidScreenRecordingProcessOwnership = + | 'missing' + | 'owned-alive' + | 'ownership-lost' + | 'uncertain'; + +export type AndroidScreenRecordingStopOutcome = + | 'stopped' + | 'already-missing' + | 'ownership-lost' + | 'uncertain'; + +export type AndroidScreenRecordingTransport = Readonly<{ + mode: 'local' | 'transport-composed'; + start( + input: Readonly<{ remotePath: string; quality?: RecordingExportQuality }>, + signal?: AbortSignal, + ): Promise>; + inspect( + process: AndroidScreenRecordingProcessIdentity, + signal?: AbortSignal, + ): Promise; + stop( + process: AndroidScreenRecordingProcessIdentity, + options?: Readonly<{ force?: boolean }>, + signal?: AbortSignal, + ): Promise; + exists(remotePath: string, signal?: AbortSignal): Promise; + size(remotePath: string, signal?: AbortSignal): Promise; + findRunning( + remotePath: string, + signal?: AbortSignal, + ): Promise; + pullPlayable( + input: Readonly<{ remotePath: string; outputPath: string }>, + signal?: AbortSignal, + ): Promise>; + remove(remotePath: string, signal?: AbortSignal): Promise; + manifestPathFor(remotePath: string): string; + readManifest( + manifestPath: string, + signal?: AbortSignal, + ): Promise; + writeManifest( + input: Readonly<{ manifestPath: string; contents: string }>, + signal?: AbortSignal, + ): Promise; + removeManifest(manifestPath: string, signal?: AbortSignal): Promise; +}>; + +export type AndroidScreenRecordingHost = Readonly<{ + resolve(device: DeviceInfo): Promise; +}>; + +export type HarmonyScreenRecordingHost = Readonly<{ + start(device: DeviceInfo, fileName: string, signal?: AbortSignal): Promise; + stop(device: DeviceInfo, signal?: AbortSignal): Promise; + findMedia( + device: DeviceInfo, + fileName: string, + signal?: AbortSignal, + ): Promise; + stageMedia( + device: DeviceInfo, + input: Readonly<{ mediaUri: string; remotePath: string }>, + signal?: AbortSignal, + ): Promise; + stagedFileSize( + device: DeviceInfo, + remotePath: string, + signal?: AbortSignal, + ): Promise; + pull( + device: DeviceInfo, + input: Readonly<{ remotePath: string; outputPath: string }>, + signal?: AbortSignal, + ): Promise; + remove(device: DeviceInfo, remotePath: string, signal?: AbortSignal): Promise; + removeMedia(device: DeviceInfo, mediaUri: string, signal?: AbortSignal): Promise; +}>; + +export type WebScreenRecordingTransport = Readonly<{ + start(outputPath: string, signal?: AbortSignal): Promise; + stop(signal?: AbortSignal): Promise; +}>; + +export type WebScreenRecordingHost = Readonly<{ + resolve(device: DeviceInfo): Promise; +}>; + +/** Closed post-processing authority for stable/playable validation, telemetry, trim, and overlays. */ +export type ScreenRecordingFinalizer = Readonly<{ + complete( + input: Readonly<{ + outputPath: string; + showTouches: boolean; + gestureEvents: readonly RecordingGestureEvent[]; + exportQuality?: RecordingExportQuality; + trimStartMs?: number; + targetLabel: string; + }>, + signal?: AbortSignal, + ): Promise>; +}>; + +/** Destructive output preparation occurs only after package-owned semantic validation. */ +export type ScreenRecordingOutputHost = Readonly<{ + prepare(outputPath: string): Promise; +}>; + +/** Focused host authorities consumed only by package-owned screen-recording mechanics. */ +export type ScreenRecordingRuntimeHost = Readonly<{ + apple: AppleScreenRecordingHost; + android: AndroidScreenRecordingHost; + harmony: HarmonyScreenRecordingHost; + web: WebScreenRecordingHost; + outputs: ScreenRecordingOutputHost; + finalize: ScreenRecordingFinalizer; +}>; diff --git a/packages/contracts/src/screen-recording-runtime-plan.test.ts b/packages/contracts/src/screen-recording-runtime-plan.test.ts new file mode 100644 index 0000000000..f69ccf410e --- /dev/null +++ b/packages/contracts/src/screen-recording-runtime-plan.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + screenRecordingAdmissionUse, + screenRecordingRuntimePlanUses, + resolveScreenRecordingRuntimePlan, +} from './screen-recording-runtime-plan.ts'; + +test('declares separate start, live-stop, and recovery-stop plans', () => { + assert.deepEqual(resolveScreenRecordingRuntimePlan({ action: 'start', scope: 'app' }), { + kind: 'start', + use: screenRecordingRuntimePlanUses[0], + }); + assert.deepEqual(resolveScreenRecordingRuntimePlan({ action: 'start', scope: 'device' }), { + kind: 'start', + use: screenRecordingRuntimePlanUses[0], + }); + assert.deepEqual(resolveScreenRecordingRuntimePlan({ action: 'stop', hasLiveHandle: true }), { + kind: 'stop-live', + use: { required: [], preferred: [] }, + }); + assert.deepEqual(resolveScreenRecordingRuntimePlan({ action: 'stop', hasLiveHandle: false }), { + kind: 'stop-recovery', + use: screenRecordingRuntimePlanUses[1], + }); +}); + +test('keeps an adopted live stop out of descriptor runtime admission', () => { + assert.deepEqual(resolveScreenRecordingRuntimePlan({ action: 'stop', hasLiveHandle: true }).use, { + required: [], + preferred: [], + }); + assert.deepEqual(screenRecordingRuntimePlanUses[1], { + required: ['screenRecordingReattach', 'screenRecordingCleanup'], + preferred: [], + }); +}); + +test('admission prefers start without rejecting an owner that only supports recovery', () => { + assert.deepEqual(screenRecordingAdmissionUse, { + required: [], + preferred: ['screenRecordingStart'], + }); +}); + +test.each([ + { input: { action: 'pause' }, message: 'record requires start or stop' }, + { + input: { action: 'start', scope: 'whole-screen' }, + message: 'record scope must be app, device, or system', + }, +])('rejects invalid recording plan input', ({ input, message }) => { + assert.throws(() => resolveScreenRecordingRuntimePlan(input), { message }); +}); diff --git a/packages/contracts/src/screen-recording-runtime-plan.ts b/packages/contracts/src/screen-recording-runtime-plan.ts new file mode 100644 index 0000000000..6c6e9481f3 --- /dev/null +++ b/packages/contracts/src/screen-recording-runtime-plan.ts @@ -0,0 +1,81 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { runtimeUse } from './platform-runtime.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; +import type { RecordingScope } from './recording-scope.ts'; + +const defineScreenRecordingUse = runtimeUse(); + +export const screenRecordingStartUse = defineScreenRecordingUse({ + required: ['screenRecordingStart'], +}); +export const screenRecordingRecoveryUse = defineScreenRecordingUse({ + required: ['screenRecordingReattach', 'screenRecordingCleanup'], +}); +const screenRecordingNoRuntimeUse = defineScreenRecordingUse({ required: [] }); + +/** Fact-only admission preserves start's preferred-fast-path semantics. */ +export const screenRecordingAdmissionUse = defineScreenRecordingUse({ + required: [], + preferred: ['screenRecordingStart'], +}); + +/** The descriptor's exact runtime-bearing uses across every record execution plan. */ +export const screenRecordingRuntimePlanUses = Object.freeze([ + screenRecordingStartUse, + screenRecordingRecoveryUse, +] as const); + +export type ScreenRecordingRuntimePlan = + | Readonly<{ + kind: 'start'; + use: typeof screenRecordingStartUse; + }> + | Readonly<{ + kind: 'stop-live'; + use: typeof screenRecordingNoRuntimeUse; + }> + | Readonly<{ + kind: 'stop-recovery'; + use: typeof screenRecordingRecoveryUse; + }>; + +export type ScreenRecordingRuntimePlanInput = Readonly<{ + action?: string; + scope?: string; + hasLiveHandle?: boolean; +}>; + +export function resolveScreenRecordingRuntimePlan( + input: ScreenRecordingRuntimePlanInput, +): ScreenRecordingRuntimePlan { + const action = (input.action ?? '').toLowerCase(); + switch (action) { + case 'start': { + const scope = input.scope ?? 'app'; + if (!isRecordingScope(scope)) { + throw new AppError('INVALID_ARGS', 'record scope must be app, device, or system'); + } + return Object.freeze({ + kind: 'start', + use: screenRecordingStartUse, + }); + } + case 'stop': + if (input.hasLiveHandle === true) { + return Object.freeze({ + kind: 'stop-live', + use: screenRecordingNoRuntimeUse, + }); + } + return Object.freeze({ + kind: 'stop-recovery', + use: screenRecordingRecoveryUse, + }); + default: + throw new AppError('INVALID_ARGS', 'record requires start or stop'); + } +} + +function isRecordingScope(value: string): value is RecordingScope { + return value === 'app' || value === 'device' || value === 'system'; +} diff --git a/packages/contracts/src/screen-recording-runtime.test.ts b/packages/contracts/src/screen-recording-runtime.test.ts new file mode 100644 index 0000000000..af75d08b23 --- /dev/null +++ b/packages/contracts/src/screen-recording-runtime.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { + ScreenRecordingLiveHandle, + ScreenRecordingLiveSnapshot, + ScreenRecordingStartInput, +} from './screen-recording-runtime.ts'; + +function seedRunnerSession( + handle: Pick, + sessionId: string, +): void { + handle.setRunnerSessionId(sessionId); +} + +test('the neutral live handle can seed runner identity after start', () => { + let observed: string | undefined; + seedRunnerSession( + { + setRunnerSessionId: (sessionId) => { + observed = sessionId; + }, + }, + 'runner-after-start', + ); + assert.equal(observed, 'runner-after-start'); +}); + +test('the live snapshot retains neutral runner timing anchors', () => { + const timing: Pick< + ScreenRecordingLiveSnapshot, + 'runnerStartedAtUptimeMs' | 'targetAppReadyUptimeMs' + > = { + runnerStartedAtUptimeMs: 120, + targetAppReadyUptimeMs: 180, + }; + assert.deepEqual(timing, { + runnerStartedAtUptimeMs: 120, + targetAppReadyUptimeMs: 180, + }); +}); + +test('explicit hide-touches intent remains distinct from normalized touch visibility', () => { + const normalizedByOwner: Pick = + { + showTouches: false, + hideTouchesRequested: false, + }; + const explicitlyHidden: Pick = + { + showTouches: false, + hideTouchesRequested: true, + }; + + assert.equal(normalizedByOwner.showTouches, explicitlyHidden.showTouches); + assert.notEqual(normalizedByOwner.hideTouchesRequested, explicitlyHidden.hideTouchesRequested); +}); diff --git a/packages/contracts/src/screen-recording-runtime.ts b/packages/contracts/src/screen-recording-runtime.ts new file mode 100644 index 0000000000..3b151c0245 --- /dev/null +++ b/packages/contracts/src/screen-recording-runtime.ts @@ -0,0 +1,135 @@ +import type { GestureReferenceFrame, ScrollDirection } from './scroll-gesture.ts'; +import type { RecordingAppIdentity } from './recording.ts'; +import type { RecordingExportQuality } from './recording-export-quality.ts'; +import type { RecordingScope } from './recording-scope.ts'; +import type { CleanupOutcome, LiveResourceHandle, ReattachOutcome } from './durable-resource.ts'; +import type { DurableResourceEnvelope } from './durable-resource-envelope.ts'; +import type { PendingTransferGuard } from './async-lifecycle.ts'; +import type { ResourceOwnershipFence } from './platform-runtime.ts'; + +export const SCREEN_RECORDING_RESOURCE_KIND = 'screen-recording' as const; + +type RecordingTelemetryBase = Readonly<{ + tMs: number; + x: number; + y: number; + referenceWidth?: number; + referenceHeight?: number; +}>; + +type RecordingTelemetryTravel = RecordingTelemetryBase & + Readonly<{ + x2: number; + y2: number; + durationMs: number; + }>; + +/** Neutral touch-overlay event data retained by a live screen-recording handle. */ +export type RecordingGestureEvent = + | (RecordingTelemetryBase & + Readonly<{ + kind: 'tap' | 'longpress'; + durationMs?: number; + }>) + | (RecordingTelemetryTravel & Readonly<{ kind: 'swipe' }>) + | (RecordingTelemetryTravel & + Readonly<{ + kind: 'scroll'; + contentDirection: ScrollDirection; + amount?: number; + pixels?: number; + }>) + | (RecordingTelemetryTravel & Readonly<{ kind: 'back-swipe'; edge: 'left' | 'right' }>) + | (RecordingTelemetryBase & + Readonly<{ + kind: 'pinch'; + scale: number; + durationMs: number; + }>); + +export type ScreenRecordingChunk = Readonly<{ + index: number; + path: string; + clientOutPath?: string; +}>; + +/** + * All mutable recording state stays behind the live handle; the daemon session holds only that + * handle plus its durable envelope. + */ +export type ScreenRecordingLiveSnapshot = Readonly<{ + backend: string; + outPath: string; + clientOutPath?: string; + startedAt: number; + scope: RecordingScope; + showTouches: boolean; + recordOnlySession: boolean; + activeSessionApp?: RecordingAppIdentity; + exportQuality?: RecordingExportQuality; + gestureEvents: readonly RecordingGestureEvent[]; + touchReferenceFrame?: GestureReferenceFrame; + gestureClockOriginAtMs?: number; + gestureClockOriginUptimeMs?: number; + runnerStartedAtUptimeMs?: number; + targetAppReadyUptimeMs?: number; + runnerSessionId?: string; + invalidatedReason?: string; +}>; + +/** Final metadata needed to render the unchanged public recording-stop response. */ +export type ScreenRecordingCompletion = Readonly<{ + backend: string; + outPath: string; + clientOutPath?: string; + startedAt: number; + completedAt: number; + scope: RecordingScope; + showTouches: boolean; + recordOnlySession: boolean; + activeSessionApp?: RecordingAppIdentity; + telemetryPath?: string; + warning?: string; + overlayWarning?: string; + chunks?: readonly ScreenRecordingChunk[]; +}>; + +export type ScreenRecordingLiveHandle = LiveResourceHandle & + Readonly<{ + inspect(): ScreenRecordingLiveSnapshot; + appendGestureEvents(events: readonly RecordingGestureEvent[]): void; + setTouchReferenceFrame(frame: GestureReferenceFrame | undefined): void; + setRunnerSessionId(sessionId: string): void; + invalidate(reason: string): void; + }>; + +export type ScreenRecordingStartInput = Readonly<{ + sessionId: string; + outputPath: string; + clientOutputPath?: string; + scope: RecordingScope; + showTouches: boolean; + hideTouchesRequested: boolean; + recordOnlySession: boolean; + activeSessionApp?: RecordingAppIdentity; + exportQuality?: RecordingExportQuality; + fps?: number; + fence: ResourceOwnershipFence; +}>; + +export type ScreenRecordingStartResult = Readonly<{ + pendingHandle: PendingTransferGuard; + envelope: DurableResourceEnvelope; +}>; + +export type ScreenRecordingReattachInput = Readonly<{ + envelope: DurableResourceEnvelope; +}>; + +export type ScreenRecordingRuntimeOperations = Readonly<{ + screenRecordingStart(input: ScreenRecordingStartInput): Promise; + screenRecordingReattach( + input: ScreenRecordingReattachInput, + ): Promise>; + screenRecordingCleanup(input: ScreenRecordingReattachInput): Promise; +}>; diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts index 2317a5e700..b5e14a58a3 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -190,5 +190,38 @@ function unusedAppLogHost(): Omit< }, processTransports: { resolve: async () => ({ mode: 'local' }) }, clock: { now: () => 1, sleep: async () => {} }, + screenRecording: { + outputs: { prepare: async () => {} }, + apple: { + availability: async () => ({ available: true }), + runRunner: async () => ({}), + startSimulator: async () => { + throw new Error('unused'); + }, + inspectProcess: async () => 'missing', + terminateProcess: async () => 'already-missing', + inspectRunner: async () => 'missing', + retrieveRunnerRecording: async () => {}, + captureClockAnchor: async () => undefined, + isRunnerBundleId: async () => false, + }, + android: { + resolve: async () => { + throw new Error('unused'); + }, + }, + harmony: { + start: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + stop: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + findMedia: async () => undefined, + stageMedia: async () => false, + stagedFileSize: async () => undefined, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + removeMedia: async () => true, + }, + web: { resolve: async () => undefined }, + finalize: { complete: async () => ({}) }, + }, }; } diff --git a/packages/platform-android/src/recording/chunks.test.ts b/packages/platform-android/src/recording/chunks.test.ts new file mode 100644 index 0000000000..a66f6adcda --- /dev/null +++ b/packages/platform-android/src/recording/chunks.test.ts @@ -0,0 +1,247 @@ +import { expect, test, vi } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { + androidRecordingDevice, + recordingHost, + recordingInput, + recordingProcess, +} from './fixtures.ts'; +import { cleanupChunks, pullChunks, startChunkAt, stopChunk, stopOwnedChunks } from './chunks.ts'; +import { bindAndroidScreenRecordingRuntime } from './runtime.ts'; + +const start = async (overrides: Record) => + await bindAndroidScreenRecordingRuntime({ + host: recordingHost(overrides), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); + +test('waits for equal nonzero remote sizes after exit before pulling', async () => { + const sizes = [1, 2, 2]; + let pulls = 0; + const runtime = await start({ + size: async () => sizes.shift() ?? 2, + pull: async () => { + pulls += 1; + expect(sizes).toEqual([]); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + await expect(started.pendingHandle.transfer().finish()).resolves.toMatchObject({ + status: 'completed', + }); + expect(pulls).toBe(1); +}); + +test('returns secondary client paths, split/180s warnings, and skips chunked touch burn-in', async () => { + vi.useFakeTimers(); + try { + let pid = 41; + const runtime = await start({ start: async () => recordingProcess(String(++pid)) }); + const started = await runtime.screenRecordingStart({ + ...recordingInput(), + clientOutputPath: '/client/capture.mp4', + }); + const handle = started.pendingHandle.transfer(); + handle.appendGestureEvents([{ kind: 'tap', tMs: 1, x: 2, y: 3 }]); + await vi.advanceTimersByTimeAsync(170_000); + const finishing = handle.finish(); + await vi.advanceTimersByTimeAsync(1_000); + await expect(finishing).resolves.toMatchObject({ + status: 'completed', + result: { + warning: expect.stringContaining('Android adb screenrecord is capped at 180s'), + overlayWarning: + 'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry', + chunks: [ + { index: 1, clientOutPath: '/client/capture.mp4' }, + { index: 2, clientOutPath: '/client/capture.part-002.mp4' }, + ], + }, + }); + } finally { + vi.useRealTimers(); + } +}); + +test('continues through every owned chunk after a stop or removal failure', async () => { + const chunks = [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-1.mp4', + remotePid: '41', + remoteStartTime: '1', + }, + { + index: 2, + remotePath: '/sdcard/agent-device-recording-2.mp4', + remotePid: '42', + remoteStartTime: '1', + }, + ] as const; + const stopped: string[] = []; + const removed: string[] = []; + const transport = { + stop: async ({ pid }: { pid: string }) => { + stopped.push(pid); + if (pid === '42') throw new Error('stop failed'); + return 'stopped' as const; + }, + remove: async (path: string) => { + removed.push(path); + return !path.endsWith('2.mp4'); + }, + } as never; + await expect(stopOwnedChunks(transport, chunks)).rejects.toThrow('stop failed'); + await expect(cleanupChunks(transport, chunks)).rejects.toThrow('failed to remove'); + expect(stopped).toEqual(['42', '41']); + expect(removed).toEqual(chunks.map((chunk) => chunk.remotePath)); +}); + +test('does not force-signal a pid after its path ownership changes during graceful stop', async () => { + const stops: Array<{ pid: string; force?: boolean }> = []; + const transport = { + stop: async (input: { pid: string; force?: boolean }) => { + stops.push(input); + return 'ownership-lost' as const; + }, + } as never; + await expect( + stopChunk(transport, { + index: 1, + remotePath: '/sdcard/agent-device-recording-2.mp4', + remotePid: '42', + remoteStartTime: '1', + }), + ).rejects.toThrow('ownership could not be confirmed'); + expect(stops).toEqual([ + { pid: '42', remotePath: '/sdcard/agent-device-recording-2.mp4', startTime: '1' }, + ]); +}); + +test('rejects an invalid start identity without attempting a numeric PID stop', async () => { + const controller = new AbortController(); + const stops: string[] = []; + const transport = { + start: async () => recordingProcess('invalid'), + stop: async ({ pid }: { pid: string }) => { + stops.push(pid); + return 'stopped' as const; + }, + remove: async () => true, + } as never; + await expect( + startChunkAt( + transport, + '/sdcard/agent-device-recording-2.mp4', + recordingInput(), + controller.signal, + ), + ).rejects.toThrow('invalid process identity'); + expect(stops).toEqual([]); +}); + +test('gives exact SIGINT ownership ten seconds before considering force stop', async () => { + vi.useFakeTimers(); + try { + const calls: Array<{ force?: boolean }> = []; + let polls = 0; + const transport = { + stop: async (_process: unknown, options?: { force?: boolean }) => { + calls.push(options ?? {}); + return options?.force ? ('stopped' as const) : ('uncertain' as const); + }, + inspect: async () => (++polls === 3 ? ('missing' as const) : ('owned-alive' as const)), + } as never; + const stopping = stopChunk(transport, { + index: 1, + remotePath: '/sdcard/agent-device-recording-2.mp4', + remotePid: '42', + remoteStartTime: '7', + }); + await vi.advanceTimersByTimeAsync(3_000); + await expect(stopping).resolves.toBe(false); + expect(calls).toEqual([{}]); + } finally { + vi.useRealTimers(); + } +}); + +test('forces only after the full ten-second identity polling window remains alive', async () => { + vi.useFakeTimers(); + try { + const calls: Array<{ force?: boolean }> = []; + const transport = { + stop: async (_process: unknown, options?: { force?: boolean }) => { + calls.push(options ?? {}); + return 'stopped' as const; + }, + inspect: async () => 'owned-alive' as const, + } as never; + const stopping = stopChunk(transport, { + index: 1, + remotePath: '/sdcard/agent-device-recording-2.mp4', + remotePid: '42', + remoteStartTime: '7', + }); + await vi.advanceTimersByTimeAsync(9_999); + expect(calls).toEqual([{}]); + await vi.advanceTimersByTimeAsync(1); + await expect(stopping).resolves.toBe(false); + expect(calls).toEqual([{}, { force: true }]); + } finally { + vi.useRealTimers(); + } +}); + +test('retries a pulled MP4 until its moov is playable and retains remote evidence on exhaustion', async () => { + vi.useFakeTimers(); + try { + const chunk = [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-2.mp4', + remotePid: '42', + remoteStartTime: '7', + }, + ] as const; + let pulls = 0; + const becomingPlayable = pullChunks( + { + pullPlayable: async () => ({ + stdout: '', + stderr: '', + exitCode: 0, + playable: ++pulls === 3, + }), + } as never, + chunk, + '/tmp/capture.mp4', + ); + await vi.advanceTimersByTimeAsync(2_000); + await expect(becomingPlayable).resolves.toEqual([{ index: 1, path: '/tmp/capture.mp4' }]); + expect(pulls).toBe(3); + + pulls = 0; + const exhausted = pullChunks( + { + pullPlayable: async () => ({ + stdout: '', + stderr: '', + exitCode: 0, + playable: (++pulls, false), + }), + } as never, + chunk, + '/tmp/capture.mp4', + ); + const rejected = expect(exhausted).rejects.toThrow('playable Android recording'); + await vi.advanceTimersByTimeAsync(2_000); + await rejected; + expect(pulls).toBe(3); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-android/src/recording/chunks.ts b/packages/platform-android/src/recording/chunks.ts new file mode 100644 index 0000000000..0dd596f9e8 --- /dev/null +++ b/packages/platform-android/src/recording/chunks.ts @@ -0,0 +1,258 @@ +import path from 'node:path'; +import type { + PlatformRuntimeHost, + ScreenRecordingChunk, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import type { NativeChunk } from './manifest.ts'; + +type Transport = Awaited>; + +const GRACEFUL_STOP_TIMEOUT_MS = 10_000; +const STOP_POLL_INTERVAL_MS = 1_000; +const PLAYABLE_PULL_ATTEMPTS = 3; +const PLAYABLE_PULL_INTERVAL_MS = 1_000; + +export class AndroidScreenRecordingStartRollbackUnconfirmed extends Error { + constructor(cause: unknown) { + super('Android screenrecord launch rollback could not be confirmed', { cause }); + } +} + +export function candidateRemotePaths( + preferredDir: string | undefined, + now = Date.now(), +): readonly string[] { + const dirs = preferredDir + ? [preferredDir, '/sdcard', '/data/local/tmp'] + : ['/sdcard', '/data/local/tmp']; + return [...new Set(dirs)].map((directory) => `${directory}/agent-device-recording-${now}.mp4`); +} + +export async function startChunkAt( + transport: Transport, + remotePath: string, + input: ScreenRecordingStartInput, + signal?: AbortSignal, +): Promise { + let nativeProcess: Awaited>['process'] | undefined; + try { + nativeProcess = ( + await transport.start({ remotePath, quality: input.exportQuality ?? 'medium' }, signal) + ).process; + if ( + !/^\d+$/.test(nativeProcess.pid) || + !/^\d+$/.test(nativeProcess.startTime) || + nativeProcess.remotePath !== remotePath + ) { + throw new Error('Android screenrecord returned an invalid process identity'); + } + if (!(await waitForReady(transport, remotePath, nativeProcess, signal))) { + throw new Error('Android screenrecord did not begin producing frames'); + } + return { + index: 1, + remotePath, + remotePid: nativeProcess.pid, + remoteStartTime: nativeProcess.startTime, + }; + } catch (error) { + if ( + nativeProcess && + validProcessIdentity(nativeProcess) && + !(await rollbackStartedProcess(transport, nativeProcess)) + ) { + throw new AndroidScreenRecordingStartRollbackUnconfirmed(error); + } + if (signal?.aborted) throw signal.reason; + throw error; + } +} + +function validProcessIdentity( + nativeProcess: Awaited>['process'], +): boolean { + return ( + /^\d+$/.test(nativeProcess.pid) && + /^\d+$/.test(nativeProcess.startTime) && + nativeProcess.remotePath.length > 0 + ); +} + +export async function stopOwnedChunks( + transport: Transport, + chunks: readonly NativeChunk[], +): Promise { + let reachedLimit = false; + let failure: unknown; + for (const chunk of [...chunks].reverse()) { + try { + reachedLimit = (await stopChunk(transport, chunk)) || reachedLimit; + } catch (error) { + failure ??= error; + } + } + if (failure) throw failure; + return reachedLimit; +} + +export async function waitForStableArtifacts( + transport: Transport, + chunks: readonly NativeChunk[], +): Promise { + for (const chunk of chunks) { + let previousSize: number | undefined; + for (let attempt = 0; attempt < 3; attempt += 1) { + const size = await transport.size(chunk.remotePath); + if (typeof size === 'number' && size > 0 && size === previousSize) break; + previousSize = typeof size === 'number' && size > 0 ? size : undefined; + if (attempt === 2) + throw new Error(`Android recording artifact is not stable: ${chunk.remotePath}`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } +} + +export async function pullChunks( + transport: Transport, + chunks: readonly NativeChunk[], + outputPath: string, + clientOutputPath?: string, +): Promise { + const results: ScreenRecordingChunk[] = []; + for (const [offset, chunk] of chunks.entries()) { + const pathForChunk = offset === 0 ? outputPath : chunkOutputPath(outputPath, offset + 1); + await pullPlayableChunk(transport, chunk.remotePath, pathForChunk); + const clientPath = + offset === 0 || clientOutputPath === undefined + ? clientOutputPath + : chunkOutputPath(clientOutputPath, offset + 1); + results.push({ + index: offset + 1, + path: pathForChunk, + ...(clientPath === undefined ? {} : { clientOutPath: clientPath }), + }); + } + return Object.freeze(results); +} + +export async function cleanupChunks( + transport: Transport, + chunks: readonly NativeChunk[], +): Promise { + let failure: unknown; + for (const chunk of chunks) { + try { + if (!(await transport.remove(chunk.remotePath))) + throw new Error(`failed to remove Android recording artifact: ${chunk.remotePath}`); + } catch (error) { + failure ??= error; + } + } + if (failure) throw failure; +} + +export async function rollbackChunks( + transport: Transport, + chunks: readonly NativeChunk[], +): Promise { + try { + await stopOwnedChunks(transport, chunks); + } finally { + await cleanupChunks(transport, chunks); + } +} + +async function waitForReady( + transport: Transport, + remotePath: string, + nativeProcess: Awaited>['process'], + signal?: AbortSignal, +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + signal?.throwIfAborted(); + if ((await transport.exists(remotePath, signal)) === true) return true; + if ((await transport.inspect(nativeProcess, signal)) !== 'owned-alive') return false; + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250)); + } + return true; +} + +async function rollbackStartedProcess( + transport: Transport, + nativeProcess: Awaited>['process'], +): Promise { + try { + const stopped = await transport.stop(nativeProcess, { force: true }); + return ( + (stopped === 'stopped' || stopped === 'already-missing') && + (await transport.remove(nativeProcess.remotePath)) + ); + } catch { + return false; + } +} + +export async function stopChunk( + transport: Transport, + chunk: NativeChunk | undefined, +): Promise { + if (!chunk) return false; + const processIdentity = { + pid: chunk.remotePid, + remotePath: chunk.remotePath, + startTime: chunk.remoteStartTime, + }; + const graceful = await transport.stop(processIdentity); + if (graceful === 'already-missing') return true; + if (graceful === 'ownership-lost') + throw new Error( + `Android screenrecord ownership could not be confirmed for pid ${chunk.remotePid}`, + ); + if (await waitForStopped(transport, processIdentity)) return false; + const forced = await transport.stop(processIdentity, { force: true }); + if (forced === 'stopped' || forced === 'already-missing') return false; + throw new Error(`failed to stop Android screenrecord pid ${chunk.remotePid}`); +} + +async function pullPlayableChunk( + transport: Transport, + remotePath: string, + outputPath: string, +): Promise { + for (let attempt = 0; attempt < PLAYABLE_PULL_ATTEMPTS; attempt += 1) { + const pulled = await transport.pullPlayable({ remotePath, outputPath }); + if (pulled.exitCode === 0 && pulled.playable) return; + if (attempt + 1 < PLAYABLE_PULL_ATTEMPTS) await delay(PLAYABLE_PULL_INTERVAL_MS); + } + throw new Error('failed to retrieve playable Android recording'); +} + +async function waitForStopped( + transport: Transport, + processIdentity: Awaited>['process'], +): Promise { + for (let elapsed = 0; elapsed <= GRACEFUL_STOP_TIMEOUT_MS; elapsed += STOP_POLL_INTERVAL_MS) { + const state = await transport.inspect(processIdentity); + if (state === 'missing') return true; + if (state === 'ownership-lost') { + throw new Error( + `Android screenrecord ownership could not be confirmed for pid ${processIdentity.pid}`, + ); + } + if (elapsed < GRACEFUL_STOP_TIMEOUT_MS) await delay(STOP_POLL_INTERVAL_MS); + } + return false; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function chunkOutputPath(outputPath: string, index: number): string { + const parsed = path.parse(outputPath); + return path.join( + parsed.dir, + `${parsed.name}.part-${String(index).padStart(3, '0')}${parsed.ext || '.mp4'}`, + ); +} diff --git a/packages/platform-android/src/recording/cleanup.test.ts b/packages/platform-android/src/recording/cleanup.test.ts new file mode 100644 index 0000000000..83336d6bb7 --- /dev/null +++ b/packages/platform-android/src/recording/cleanup.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from 'vitest'; +import { cleanupVerifiedAndroidEvidence } from './cleanup.ts'; +import { androidRecordingDevice, recordingHost, recordingInput } from './fixtures.ts'; +import { createNativeManifest } from './manifest.ts'; + +test('attempts every fenced native chunk before retaining cleanup evidence', async () => { + const attempts: string[] = []; + const host = recordingHost({ + stop: async ({ pid }: { pid: string }) => { + attempts.push(`stop:${pid}`); + if (pid === '41') throw new Error('stop failed'); + return 'stopped' as const; + }, + inspect: async () => 'missing' as const, + remove: async (remotePath: string) => { + attempts.push(`remove:${remotePath}`); + return true; + }, + }); + const transport = await host.screenRecording.android.resolve(androidRecordingDevice); + const evidence = createNativeManifest( + androidRecordingDevice, + recordingInput(), + 1, + [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-1.mp4', + remotePid: '41', + remoteStartTime: '7', + }, + { + index: 2, + remotePath: '/sdcard/agent-device-recording-2.mp4', + remotePid: '42', + remoteStartTime: '8', + }, + ], + undefined, + 'local', + ); + await expect( + cleanupVerifiedAndroidEvidence( + transport, + evidence, + '/sdcard/agent-device-recording-active.json', + ), + ).resolves.toMatchObject({ status: 'cleanup-pending' }); + expect(attempts).toContain('stop:42'); + expect(attempts).toContain('stop:41'); +}); diff --git a/packages/platform-android/src/recording/cleanup.ts b/packages/platform-android/src/recording/cleanup.ts new file mode 100644 index 0000000000..463f75d519 --- /dev/null +++ b/packages/platform-android/src/recording/cleanup.ts @@ -0,0 +1,35 @@ +import type { CleanupOutcome, PlatformRuntimeHost } from '@agent-device/contracts/platform'; +import { cleanupChunks, stopOwnedChunks } from './chunks.ts'; +import { pending } from './completion.ts'; +import type { NativeManifest } from './manifest.ts'; +import { removeNativeManifest } from './launch.ts'; + +type Transport = Awaited>; + +/** Remove only resources named by validated native evidence, retaining it on every uncertainty. */ +export async function cleanupVerifiedAndroidEvidence( + transport: Transport, + evidence: NativeManifest, + manifestPath: string, +): Promise { + try { + const pendingPath = evidence.pendingRemotePath; + const pendingChunks = + pendingPath === undefined + ? [] + : (await transport.findRunning(pendingPath)).map((processIdentity) => ({ + index: evidence.chunks.length + 1, + remotePath: pendingPath, + remotePid: processIdentity.pid, + remoteStartTime: processIdentity.startTime, + })); + await stopOwnedChunks(transport, [...evidence.chunks, ...pendingChunks]); + await cleanupChunks(transport, evidence.chunks); + if (pendingPath !== undefined && !(await transport.remove(pendingPath))) + throw new Error(`failed to remove Android recording artifact: ${pendingPath}`); + await removeNativeManifest(transport, manifestPath); + return { status: 'cleaned' }; + } catch (error) { + return pending(error); + } +} diff --git a/packages/platform-android/src/recording/completion.ts b/packages/platform-android/src/recording/completion.ts new file mode 100644 index 0000000000..8c6eee9ede --- /dev/null +++ b/packages/platform-android/src/recording/completion.ts @@ -0,0 +1,86 @@ +import type { + CleanupOutcome, + PlatformRuntimeHost, + ScreenRecordingChunk, + ScreenRecordingCompletion, + ScreenRecordingLiveSnapshot, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; + +export function snapshot( + input: ScreenRecordingStartInput, + startedAt: number, +): ScreenRecordingLiveSnapshot { + return Object.freeze({ + backend: 'adb screenrecord', + outPath: input.outputPath, + ...(input.clientOutputPath ? { clientOutPath: input.clientOutputPath } : {}), + startedAt, + scope: input.scope, + showTouches: input.showTouches, + recordOnlySession: input.recordOnlySession, + ...(input.activeSessionApp ? { activeSessionApp: input.activeSessionApp } : {}), + ...(input.exportQuality ? { exportQuality: input.exportQuality } : {}), + gestureEvents: [], + }); +} + +export async function completed( + host: PlatformRuntimeHost, + recording: ScreenRecordingLiveSnapshot, + chunks: readonly ScreenRecordingChunk[], + targetLabel: string, + reachedLimit = false, +): Promise> { + const chunked = chunks.length > 1; + const finalization = await host.screenRecording.finalize.complete({ + outputPath: recording.outPath, + showTouches: chunked ? false : recording.showTouches, + gestureEvents: recording.gestureEvents, + exportQuality: recording.exportQuality ?? 'medium', + targetLabel, + }); + const warnings = [ + ...(reachedLimit + ? [ + 'Android adb screenrecord stopped before record stop, likely after reaching the 180s platform limit. The MP4 may be truncated; final interactions after the limit are not in the video.', + ] + : []), + ...(chunked + ? [ + 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.', + ] + : []), + ]; + return { + status: 'completed', + result: { + backend: recording.backend, + outPath: recording.outPath, + ...(recording.clientOutPath ? { clientOutPath: recording.clientOutPath } : {}), + startedAt: recording.startedAt, + completedAt: Date.now(), + scope: recording.scope, + showTouches: recording.showTouches, + recordOnlySession: recording.recordOnlySession, + ...(recording.activeSessionApp ? { activeSessionApp: recording.activeSessionApp } : {}), + ...(chunked ? { chunks } : {}), + ...(warnings.length ? { warning: warnings.join(' ') } : {}), + ...finalization, + ...(chunked && recording.showTouches && recording.gestureEvents.length > 0 + ? { + overlayWarning: + 'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry', + } + : {}), + }, + }; +} + +export function pending(error: unknown): CleanupOutcome { + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: error instanceof Error ? error.message : String(error), + }; +} diff --git a/packages/platform-android/src/recording/finalize.test.ts b/packages/platform-android/src/recording/finalize.test.ts new file mode 100644 index 0000000000..eb58845441 --- /dev/null +++ b/packages/platform-android/src/recording/finalize.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from 'vitest'; +import { finalizeAndroidRecording } from './finalize.ts'; +import { androidRecordingDevice, recordingHost, recordingInput } from './fixtures.ts'; +import { createNativeManifest } from './manifest.ts'; +import { snapshot } from './completion.ts'; + +test('writes terminal coordinates before removing a fenced Android artifact', async () => { + const calls: string[] = []; + const host = recordingHost({ + writeManifest: async ({ contents }: { contents: string }) => { + calls.push(JSON.parse(contents).completion ? 'completed' : 'active'); + }, + remove: async (remotePath: string) => { + calls.push(`remove:${remotePath}`); + return true; + }, + }); + const input = recordingInput(); + const transport = await host.screenRecording.android.resolve(androidRecordingDevice); + const evidence = createNativeManifest( + androidRecordingDevice, + input, + 1, + [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-1.mp4', + remotePid: '41', + remoteStartTime: '7', + }, + ], + undefined, + 'local', + ); + await expect( + finalizeAndroidRecording({ + host, + transport, + evidence, + manifestPath: '/sdcard/agent-device-recording-active.json', + recording: snapshot(input, 1), + }), + ).resolves.toMatchObject({ status: 'completed' }); + expect(calls).toEqual(['completed', 'remove:/sdcard/agent-device-recording-1.mp4']); +}); diff --git a/packages/platform-android/src/recording/finalize.ts b/packages/platform-android/src/recording/finalize.ts new file mode 100644 index 0000000000..6de0b46ee8 --- /dev/null +++ b/packages/platform-android/src/recording/finalize.ts @@ -0,0 +1,50 @@ +import type { + PlatformRuntimeHost, + ScreenRecordingLiveSnapshot, +} from '@agent-device/contracts/platform'; +import { cleanupChunks, pullChunks, stopOwnedChunks, waitForStableArtifacts } from './chunks.ts'; +import { completed } from './completion.ts'; +import { createCompletedNativeManifest, type NativeManifest } from './manifest.ts'; +import { persistNativeManifest } from './launch.ts'; + +type Transport = Awaited>; + +/** Finalize media first, then durably publish terminal coordinates before native artifact cleanup. */ +export async function finalizeAndroidRecording(params: { + host: PlatformRuntimeHost; + transport: Transport; + evidence: NativeManifest; + manifestPath: string; + recording: ScreenRecordingLiveSnapshot; + reachedLimit?: boolean; +}): Promise< + Readonly<{ + status: 'completed'; + result: import('@agent-device/contracts/platform').ScreenRecordingCompletion; + }> +> { + const reachedLimit = + (await stopOwnedChunks(params.transport, params.evidence.chunks)) || + params.reachedLimit === true; + await waitForStableArtifacts(params.transport, params.evidence.chunks); + const outputChunks = await pullChunks( + params.transport, + params.evidence.chunks, + params.recording.outPath, + params.recording.clientOutPath, + ); + const outcome = await completed( + params.host, + params.recording, + outputChunks, + 'Android recording', + reachedLimit, + ); + await persistNativeManifest( + params.transport, + params.manifestPath, + createCompletedNativeManifest(params.evidence, outcome.result), + ); + await cleanupChunks(params.transport, params.evidence.chunks); + return outcome; +} diff --git a/packages/platform-android/src/recording/fixtures.ts b/packages/platform-android/src/recording/fixtures.ts new file mode 100644 index 0000000000..cd3fb21356 --- /dev/null +++ b/packages/platform-android/src/recording/fixtures.ts @@ -0,0 +1,101 @@ +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform'; + +export const androidRecordingDevice = { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, +}; + +export function recordingInput() { + return { + sessionId: 'one', + outputPath: '/tmp/capture.mp4', + scope: 'device' as const, + showTouches: true, + hideTouchesRequested: false, + recordOnlySession: false, + fence: { token: 'fence-1', generation: 2 }, + }; +} + +export function recordingHost(overrides: Record): PlatformRuntimeHost { + const stopped = new Set(); + const legacy = overrides as Record; + const transport = { + ...overrides, + mode: legacy.mode ?? ('local' as const), + start: async ({ remotePath, quality }: { remotePath: string; quality: 'medium' | 'high' }) => { + const started = await (legacy.start?.({ remotePath, quality }) ?? + recordingProcess('42', remotePath)); + return 'process' in started + ? { process: { ...started.process, remotePath } } + : recordingProcess(started.remotePid, remotePath); + }, + exists: async (remotePath: string) => (legacy.exists ? await legacy.exists(remotePath) : true), + size: async (remotePath: string) => (legacy.size ? await legacy.size(remotePath) : 1), + inspect: async (processIdentity: { pid: string }) => + legacy.inspect + ? await legacy.inspect(processIdentity) + : legacy.isRunning + ? (await legacy.isRunning(processIdentity.pid)) + ? 'owned-alive' + : 'missing' + : stopped.has(processIdentity.pid) + ? 'missing' + : 'owned-alive', + stop: async (processIdentity: { pid: string }, options?: { force?: boolean }) => { + if (legacy.stop) return await legacy.stop(processIdentity, options); + if (legacy.signal) { + const ok = await legacy.signal({ pid: processIdentity.pid, force: options?.force }); + return ok ? ('stopped' as const) : ('uncertain' as const); + } + if (stopped.has(processIdentity.pid)) return 'already-missing' as const; + stopped.add(processIdentity.pid); + return 'stopped' as const; + }, + pullPlayable: async (input: { remotePath: string; outputPath: string }) => + legacy.pullPlayable + ? await legacy.pullPlayable(input) + : legacy.pull + ? { ...(await legacy.pull(input)), playable: true } + : { stdout: '', stderr: '', exitCode: 0, playable: true }, + remove: async (remotePath: string) => (legacy.remove ? await legacy.remove(remotePath) : true), + manifestPathFor: (remotePath: string) => + legacy.manifestPathFor?.(remotePath) ?? + `${remotePath.slice(0, remotePath.lastIndexOf('/'))}/agent-device-recording-active.json`, + readManifest: async (manifestPath: string) => + legacy.readManifest + ? await legacy.readManifest(manifestPath) + : { status: 'missing' as const }, + writeManifest: async (request: { manifestPath: string; contents: string }) => { + await legacy.writeManifest?.(request); + }, + removeManifest: async (manifestPath: string) => + legacy.removeManifest ? await legacy.removeManifest(manifestPath) : true, + findRunning: async (remotePath: string) => { + const found = await (legacy.findRunning?.(remotePath) ?? ['42', '43', '66']); + return found.map((entry: string | { pid: string; remotePath: string; startTime: string }) => + typeof entry === 'string' ? { pid: entry, remotePath, startTime: '1' } : entry, + ); + }, + }; + return { + screenRecording: { + android: { resolve: async () => transport }, + outputs: legacy.outputs ?? { prepare: async () => {} }, + finalize: legacy.finalize ?? { complete: async () => ({}) }, + }, + } as unknown as PlatformRuntimeHost; +} + +export function recordingProcess( + remotePid: string, + remotePath = '/sdcard/agent-device-recording-1.mp4', +) { + return { + process: { pid: remotePid, remotePath, startTime: '1' }, + }; +} diff --git a/packages/platform-android/src/recording/launch.test.ts b/packages/platform-android/src/recording/launch.test.ts new file mode 100644 index 0000000000..b6e29d82f4 --- /dev/null +++ b/packages/platform-android/src/recording/launch.test.ts @@ -0,0 +1,225 @@ +import { expect, test } from 'vitest'; +import type { AndroidScreenRecordingTransport } from '@agent-device/contracts/platform'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { + androidRecordingDevice, + recordingHost, + recordingInput, + recordingProcess, +} from './fixtures.ts'; +import { bindAndroidScreenRecordingRuntime } from './runtime.ts'; + +const start = async (overrides: Record, signal = new AbortController().signal) => + await bindAndroidScreenRecordingRuntime({ + host: recordingHost(overrides), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal, + }); + +test('publishes fenced pending evidence before launching the first child', async () => { + const order: string[] = []; + let pending: Record | undefined; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + const parsed = JSON.parse(contents) as Record; + order.push(`manifest:${Array.isArray(parsed.chunks) ? parsed.chunks.length : -1}`); + if (Array.isArray(parsed.chunks) && parsed.chunks.length === 0) pending = parsed; + }, + start: async () => { + order.push('start'); + return recordingProcess('42'); + }, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + expect(order.slice(0, 2)).toEqual(['manifest:0', 'start']); + expect(pending).toMatchObject({ + fenceToken: 'fence-1', + sessionId: 'one', + deviceId: 'emulator-5554', + }); + await started.pendingHandle.transfer().forceCleanup(); +}); + +test('retained native evidence blocks replacement before output preparation or launch', async () => { + let starts = 0; + let writes = 0; + let prepared = 0; + const runtime = await bindAndroidScreenRecordingRuntime({ + host: recordingHost({ + start: async () => { + starts += 1; + return recordingProcess('42'); + }, + writeManifest: async () => { + writes += 1; + }, + readManifest: async () => ({ status: 'read' as const, contents: '{broken' }), + outputs: { + prepare: async () => { + prepared += 1; + }, + }, + }), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); + await expect(runtime.screenRecordingStart(recordingInput())).rejects.toThrow( + 'native recovery evidence already exists', + ); + expect({ starts, writes, prepared }).toEqual({ starts: 0, writes: 0, prepared: 0 }); +}); + +test('unavailable native evidence blocks replacement before output preparation or launch', async () => { + let starts = 0; + let writes = 0; + let prepared = 0; + const runtime = await bindAndroidScreenRecordingRuntime({ + host: recordingHost({ + start: async () => { + starts += 1; + return recordingProcess('42'); + }, + writeManifest: async () => { + writes += 1; + }, + readManifest: async () => ({ + status: 'unavailable' as const, + message: 'adb transport did not confirm the native manifest state', + }), + outputs: { + prepare: async () => { + prepared += 1; + }, + }, + }), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); + await expect(runtime.screenRecordingStart(recordingInput())).rejects.toThrow( + 'native recovery evidence is unavailable', + ); + expect({ starts, writes, prepared }).toEqual({ starts: 0, writes: 0, prepared: 0 }); +}); + +test('cleans a failed candidate before authorizing fallback and aborts if that cleanup is uncertain', async () => { + const live = new Set(); + let starts = 0; + const order: string[] = []; + const runtime = await start({ + start: async ({ remotePath }: Parameters[0]) => { + starts += 1; + order.push(`start:${remotePath}`); + if (starts === 1) throw new Error('sdcard unavailable'); + return recordingProcess('42'); + }, + writeManifest: async ({ manifestPath }: { manifestPath: string }) => { + live.add(manifestPath); + order.push(`write:${manifestPath}`); + }, + removeManifest: async (manifestPath: string) => { + order.push(`remove:${manifestPath}`); + return live.delete(manifestPath); + }, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + expect(order.slice(0, 3)).toEqual([ + expect.stringContaining('write:/sdcard/'), + expect.stringContaining('start:/sdcard/'), + expect.stringContaining('remove:/sdcard/'), + ]); + expect(live).toEqual(new Set(['/data/local/tmp/agent-device-recording-active.json'])); + await started.pendingHandle.transfer().forceCleanup(); + + const noFallback = await start({ + start: async () => { + starts += 1; + throw new Error('unavailable'); + }, + removeManifest: async () => { + throw new Error('unavailable'); + }, + }); + await expect(noFallback.screenRecordingStart(recordingInput())).rejects.toThrow( + 'Android screenrecord launch rollback could not be confirmed', + ); + expect(starts).toBe(3); +}); + +test('rejects an invalid process identity before committing active evidence', async () => { + const runtime = await start({ + start: async () => recordingProcess('not-a-pid'), + removeManifest: async () => { + throw new Error('unavailable'); + }, + }); + await expect(runtime.screenRecordingStart(recordingInput())).rejects.toThrow( + 'launch rollback could not be confirmed', + ); +}); + +test('rolls back a child when initial active evidence publication fails', async () => { + const calls: string[] = []; + let writes = 0; + const runtime = await start({ + stop: async ({ pid }: { pid: string }) => { + calls.push(`signal:${pid}`); + return 'already-missing' as const; + }, + remove: async (remotePath: string) => { + calls.push(`remove:${remotePath}`); + return true; + }, + writeManifest: async () => { + writes += 1; + if (writes === 2) throw new Error('manifest publication failed'); + }, + }); + await expect(runtime.screenRecordingStart(recordingInput())).rejects.toThrow( + 'manifest publication failed', + ); + expect(calls).toEqual([ + 'signal:42', + expect.stringMatching(/^remove:\/sdcard\/agent-device-recording-\d+\.mp4$/), + ]); +}); + +test('preserves cancellation before and after child acquisition without retries', async () => { + const before = new AbortController(); + const reason = new Error('setup canceled'); + before.abort(reason); + const never = await start({ start: async () => recordingProcess('42') }, before.signal); + await expect(never.screenRecordingStart(recordingInput())).rejects.toBe(reason); + + const after = new AbortController(); + const calls: string[] = []; + let disposals = 0; + const runtime = await start( + { + start: async () => { + calls.push('start'); + after.abort(reason); + return { + ...recordingProcess('42'), + [Symbol.asyncDispose]: async () => { + disposals += 1; + }, + }; + }, + stop: async ({ pid, force }: { pid: string; force?: boolean }) => { + calls.push(`signal:${pid}:${String(force)}`); + return 'already-missing' as const; + }, + remove: async () => { + calls.push('remove'); + return true; + }, + }, + after.signal, + ); + await expect(runtime.screenRecordingStart(recordingInput())).rejects.toBe(reason); + expect(calls).toEqual(['start', 'signal:42:undefined', 'remove']); + expect(disposals).toBe(0); +}); diff --git a/packages/platform-android/src/recording/launch.ts b/packages/platform-android/src/recording/launch.ts new file mode 100644 index 0000000000..09e2544fc8 --- /dev/null +++ b/packages/platform-android/src/recording/launch.ts @@ -0,0 +1,218 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + PlatformRuntimeHost, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { + cleanupChunks, + AndroidScreenRecordingStartRollbackUnconfirmed, + candidateRemotePaths, + rollbackChunks, + startChunkAt, +} from './chunks.ts'; +import { + createNativeManifest, + decodeNativeManifest, + type NativeChunk, + type NativeManifest, +} from './manifest.ts'; + +type Transport = Awaited>; +type ManifestCandidate = Readonly<{ + manifestPath: string; + read: Awaited>; +}>; +type CompletedCandidate = Readonly<{ manifestPath: string; evidence: NativeManifest }>; + +export async function startInitialTransaction(params: { + transport: Transport; + device: DeviceInfo; + input: ScreenRecordingStartInput; + startedAt: number; + signal: AbortSignal; + prepareOutput: () => Promise; +}): Promise> { + const { transport, device, input, startedAt, signal, prepareOutput } = params; + await reconcileCompletedStartEvidence(transport, device); + await prepareOutput(); + let last: unknown; + for (const remotePath of candidateRemotePaths(undefined)) { + const manifestPath = transport.manifestPathFor(remotePath); + await persistNativeManifest( + transport, + manifestPath, + createNativeManifest(device, input, startedAt, [], remotePath, transport.mode), + signal, + ); + let chunk: NativeChunk; + try { + chunk = await startChunkAt(transport, remotePath, input, signal); + } catch (error) { + if (signal.aborted) throw signal.reason; + await removeFailedCandidateManifest(transport, manifestPath, error); + last = error; + continue; + } + try { + await persistNativeManifest( + transport, + manifestPath, + createNativeManifest(device, input, startedAt, [chunk], undefined, transport.mode), + signal, + ); + } catch (error) { + await rollbackChunks(transport, [chunk]).catch(() => {}); + throw error; + } + return { chunk, manifestPath }; + } + throw last ?? new Error('Android screenrecord did not begin producing frames'); +} + +/** + * A terminal marker outlives native cleanup until a later, fenced start reconciles it. Never + * retire open or uncertain evidence: that would erase the only recovery authority after a crash. + */ +async function reconcileCompletedStartEvidence( + transport: Transport, + device: DeviceInfo, +): Promise { + const candidates = await readManifestCandidates(transport); + const completed = candidates.flatMap((candidate) => + completedCandidate(candidate, device, transport.mode), + ); + for (const candidate of completed) { + await retireCompletedEvidence(transport, candidate.evidence, candidate.manifestPath); + } +} + +async function readManifestCandidates(transport: Transport): Promise { + return await Promise.all( + candidateRemotePaths(undefined).map(async (remotePath) => { + const manifestPath = transport.manifestPathFor(remotePath); + return { manifestPath, read: await transport.readManifest(manifestPath) }; + }), + ); +} + +function completedCandidate( + candidate: ManifestCandidate, + device: DeviceInfo, + transportMode: NativeManifest['transportMode'], +): readonly CompletedCandidate[] { + if (candidate.read.status === 'missing') return []; + if (candidate.read.status !== 'read') throw unavailableEvidence(); + const evidence = decodeNativeManifest(candidate.read.contents); + if (!isRetireableCompletedEvidence(evidence, device, transportMode)) throw existingEvidence(); + return [{ manifestPath: candidate.manifestPath, evidence }]; +} + +function isRetireableCompletedEvidence( + evidence: NativeManifest | undefined, + device: DeviceInfo, + transportMode: NativeManifest['transportMode'], +): evidence is NativeManifest { + return ( + evidence !== undefined && + evidence.completion !== undefined && + evidence.pendingRemotePath === undefined && + evidence.deviceId === device.id && + evidence.transportMode === transportMode + ); +} + +function unavailableEvidence(): Error { + return new Error('Android screenrecord native recovery evidence is unavailable'); +} + +function existingEvidence(): Error { + return new Error('Android screenrecord native recovery evidence already exists'); +} + +async function retireCompletedEvidence( + transport: Transport, + evidence: NativeManifest, + manifestPath: string, +): Promise { + for (const chunk of evidence.chunks) { + const state = await transport.inspect({ + pid: chunk.remotePid, + remotePath: chunk.remotePath, + startTime: chunk.remoteStartTime, + }); + if (state !== 'missing') + throw new Error('Android screenrecord completed evidence cannot be safely retired'); + } + await cleanupChunks(transport, evidence.chunks); + await removeNativeManifest(transport, manifestPath); + const confirmed = await transport.readManifest(manifestPath); + if (confirmed.status !== 'missing') + throw new Error('Android screenrecord completed evidence removal could not be confirmed'); +} + +export async function startPendingChunk(params: { + transport: Transport; + device: DeviceInfo; + input: ScreenRecordingStartInput; + startedAt: number; + chunks: readonly NativeChunk[]; + manifestPath: string; + preferredDir: string; +}): Promise { + const { transport, device, input, startedAt, chunks, manifestPath, preferredDir } = params; + let last: unknown; + for (const remotePath of candidateRemotePaths(preferredDir)) { + await persistNativeManifest( + transport, + manifestPath, + createNativeManifest(device, input, startedAt, chunks, remotePath, transport.mode), + ); + try { + return await startChunkAt(transport, remotePath, input); + } catch (error) { + if (error instanceof AndroidScreenRecordingStartRollbackUnconfirmed) throw error; + try { + await persistNativeManifest( + transport, + manifestPath, + createNativeManifest(device, input, startedAt, chunks, undefined, transport.mode), + ); + } catch { + throw new AndroidScreenRecordingStartRollbackUnconfirmed(error); + } + last = error; + } + } + throw last ?? new Error('failed to start next Android recording chunk'); +} + +async function removeFailedCandidateManifest( + transport: Transport, + manifestPath: string, + launchError: unknown, +): Promise { + if (launchError instanceof AndroidScreenRecordingStartRollbackUnconfirmed) throw launchError; + try { + await removeNativeManifest(transport, manifestPath); + } catch { + throw new AndroidScreenRecordingStartRollbackUnconfirmed(launchError); + } +} + +export async function persistNativeManifest( + transport: Transport, + manifestPath: string, + evidence: NativeManifest, + signal?: AbortSignal, +): Promise { + await transport.writeManifest({ manifestPath, contents: JSON.stringify(evidence) }, signal); +} + +export async function removeNativeManifest( + transport: Transport, + manifestPath: string, +): Promise { + if (!(await transport.removeManifest(manifestPath))) { + throw new Error(`failed to remove Android recording manifest: ${manifestPath}`); + } +} diff --git a/packages/platform-android/src/recording/manifest-validation.test.ts b/packages/platform-android/src/recording/manifest-validation.test.ts new file mode 100644 index 0000000000..2513f80e1d --- /dev/null +++ b/packages/platform-android/src/recording/manifest-validation.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'vitest'; +import { androidRecordingDevice, recordingInput } from './fixtures.ts'; +import { createCompletedNativeManifest, createNativeManifest } from './manifest.ts'; +import { isValidAndroidRecordingDescriptor, isValidNativeManifest } from './manifest-validation.ts'; + +test('validates descriptor structure independently of descriptor decoding', () => { + expect( + isValidAndroidRecordingDescriptor({ + backend: 'adb-screenrecord', + manifestPath: '/sdcard/agent-device-recording-active.json', + outputPath: '/tmp/capture.mp4', + scope: 'device', + showTouches: true, + recordOnlySession: false, + transportMode: 'local', + }), + ).toBe(true); + expect( + isValidAndroidRecordingDescriptor({ + backend: 'adb-screenrecord', + manifestPath: '/tmp/active.json', + outputPath: '/tmp/capture.mp4', + scope: 'device', + showTouches: true, + recordOnlySession: false, + transportMode: 'local', + }), + ).toBe(false); +}); + +test('rejects terminal evidence whose result coordinates diverge from its manifest', () => { + const input = recordingInput(); + const active = createNativeManifest( + androidRecordingDevice, + input, + 1, + [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-1.mp4', + remotePid: '41', + remoteStartTime: '7', + }, + ], + undefined, + 'local', + ); + const complete = createCompletedNativeManifest(active, { + backend: 'adb screenrecord', + outPath: input.outputPath, + startedAt: 1, + completedAt: 2, + scope: input.scope, + showTouches: input.showTouches, + recordOnlySession: input.recordOnlySession, + }); + expect(isValidNativeManifest(complete)).toBe(true); + expect( + isValidNativeManifest({ + ...complete, + completion: { ...complete.completion!, outPath: '/tmp/other.mp4' }, + }), + ).toBe(false); +}); diff --git a/packages/platform-android/src/recording/manifest-validation.ts b/packages/platform-android/src/recording/manifest-validation.ts new file mode 100644 index 0000000000..20335d0a8f --- /dev/null +++ b/packages/platform-android/src/recording/manifest-validation.ts @@ -0,0 +1,261 @@ +import type { + ScreenRecordingChunk, + ScreenRecordingCompletion, +} from '@agent-device/contracts/platform'; +import type { AndroidRecordingDescriptor, NativeChunk, NativeManifest } from './manifest.ts'; + +const nativeRecordingPath = + /^(?:\/sdcard|\/data\/local\/tmp)\/agent-device-recording-\d{1,20}\.mp4$/; +const nativeManifestPath = /^(?:\/sdcard|\/data\/local\/tmp)\/agent-device-recording-active\.json$/; + +export function isValidAndroidRecordingDescriptor( + value: Record, +): value is AndroidRecordingDescriptor { + return ( + descriptorIdentityIsValid(value) && + descriptorRecordingIsValid(value) && + descriptorOptionsAreValid(value) + ); +} + +export function isValidNativeManifest(value: unknown): value is NativeManifest { + if (!isObject(value)) return false; + const candidate = value as Partial; + return ( + manifestIdentityIsValid(candidate) && + manifestRecordingIsValid(candidate) && + manifestOptionsAreValid(candidate) && + hasValidManifestResources(candidate) && + (candidate.completion === undefined || completionMatchesManifest(candidate as NativeManifest)) + ); +} + +export function completionMatchesDescriptor( + completion: ScreenRecordingCompletion, + descriptor: AndroidRecordingDescriptor, +): boolean { + return ( + completionCoordinatesMatch(completion, descriptor) && + completionChunksMatch(completion, descriptor.outputPath, descriptor.clientOutputPath) + ); +} + +function descriptorIdentityIsValid(value: Record): boolean { + return ( + value.backend === 'adb-screenrecord' && + typeof value.manifestPath === 'string' && + nativeManifestPath.test(value.manifestPath) && + typeof value.outputPath === 'string' + ); +} + +function descriptorRecordingIsValid(value: Record): boolean { + return ( + (value.clientOutputPath === undefined || typeof value.clientOutputPath === 'string') && + isScope(value.scope) && + typeof value.showTouches === 'boolean' && + typeof value.recordOnlySession === 'boolean' + ); +} + +function descriptorOptionsAreValid(value: Record): boolean { + return ( + isTransportMode(value.transportMode) && + isOptionalQuality(value.exportQuality) && + isOptionalApp(value.activeSessionApp) + ); +} + +function manifestIdentityIsValid(candidate: Partial): boolean { + return ( + candidate.version === 1 && + candidate.resourceKind === 'screen-recording' && + typeof candidate.fenceToken === 'string' && + Number.isInteger(candidate.fenceGeneration) && + typeof candidate.sessionId === 'string' && + typeof candidate.deviceId === 'string' && + Number.isFinite(candidate.startedAt) + ); +} + +function manifestRecordingIsValid(candidate: Partial): boolean { + return ( + typeof candidate.outputPath === 'string' && + (candidate.clientOutputPath === undefined || typeof candidate.clientOutputPath === 'string') && + isScope(candidate.scope) && + typeof candidate.showTouches === 'boolean' && + typeof candidate.recordOnlySession === 'boolean' + ); +} + +function manifestOptionsAreValid(candidate: Partial): boolean { + return ( + isOptionalApp(candidate.activeSessionApp) && + isOptionalQuality(candidate.exportQuality) && + isTransportMode(candidate.transportMode) && + (candidate.pendingRemotePath === undefined || + isNativeRecordingPath(candidate.pendingRemotePath)) + ); +} + +function hasValidManifestResources(candidate: Partial): boolean { + return ( + Array.isArray(candidate.chunks) && + candidate.chunks.every(isValidNativeChunk) && + (candidate.chunks.length > 0 || candidate.pendingRemotePath !== undefined) && + (candidate.completion === undefined || isValidCompletion(candidate.completion)) + ); +} + +function isValidNativeChunk(chunk: unknown, index: number): chunk is NativeChunk { + if (!isObject(chunk)) return false; + const candidate = chunk as Partial; + return ( + candidate.index === index + 1 && + isNativeRecordingPath(candidate.remotePath) && + isDecimal(candidate.remotePid) && + isDecimal(candidate.remoteStartTime) + ); +} + +function isValidCompletion(value: unknown): value is ScreenRecordingCompletion { + if (!isObject(value)) return false; + const candidate = value as Partial; + return ( + completionIdentityIsValid(candidate) && + completionRecordingIsValid(candidate) && + (candidate.chunks === undefined || candidate.chunks.every(isValidCompletionChunk)) + ); +} + +function completionIdentityIsValid(candidate: Partial): boolean { + return ( + typeof candidate.backend === 'string' && + typeof candidate.outPath === 'string' && + (candidate.clientOutPath === undefined || typeof candidate.clientOutPath === 'string') && + Number.isFinite(candidate.startedAt) && + Number.isFinite(candidate.completedAt) + ); +} + +function completionRecordingIsValid(candidate: Partial): boolean { + return ( + isScope(candidate.scope) && + typeof candidate.showTouches === 'boolean' && + typeof candidate.recordOnlySession === 'boolean' && + isOptionalApp(candidate.activeSessionApp) + ); +} + +function isValidCompletionChunk(chunk: ScreenRecordingChunk, index: number): boolean { + return ( + chunk.index === index + 1 && + typeof chunk.path === 'string' && + (chunk.clientOutPath === undefined || typeof chunk.clientOutPath === 'string') + ); +} + +function completionMatchesManifest(manifest: NativeManifest): boolean { + const completion = manifest.completion; + return ( + completion !== undefined && + completionCoordinatesMatch(completion, manifest) && + completion.startedAt === manifest.startedAt && + completionChunksMatch( + completion, + manifest.outputPath, + manifest.clientOutputPath, + manifest.chunks.length, + ) + ); +} + +function completionCoordinatesMatch( + completion: ScreenRecordingCompletion, + recording: Pick< + AndroidRecordingDescriptor, + | 'outputPath' + | 'clientOutputPath' + | 'scope' + | 'showTouches' + | 'recordOnlySession' + | 'activeSessionApp' + >, +): boolean { + return ( + completion.backend === 'adb screenrecord' && + completion.outPath === recording.outputPath && + completion.clientOutPath === recording.clientOutputPath && + completion.scope === recording.scope && + completion.showTouches === recording.showTouches && + completion.recordOnlySession === recording.recordOnlySession && + sameApp(completion.activeSessionApp, recording.activeSessionApp) + ); +} + +function completionChunksMatch( + completion: ScreenRecordingCompletion, + outputPath: string, + clientOutputPath: string | undefined, + expectedChunks = completion.chunks === undefined ? 1 : completion.chunks.length, +): boolean { + if (expectedChunks === 1) return completion.chunks === undefined; + return ( + completion.chunks?.length === expectedChunks && + completion.chunks.every( + (chunk, index) => + chunk.path === chunkPath(outputPath, index + 1) && + chunk.clientOutPath === + (clientOutputPath === undefined ? undefined : chunkPath(clientOutputPath, index + 1)), + ) + ); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isScope(value: unknown): value is 'app' | 'device' | 'system' { + return value === 'app' || value === 'device' || value === 'system'; +} + +function isTransportMode(value: unknown): value is AndroidRecordingDescriptor['transportMode'] { + return value === 'local' || value === 'transport-composed'; +} + +function isOptionalQuality(value: unknown): boolean { + return value === undefined || value === 'medium' || value === 'high'; +} + +function isOptionalApp(value: unknown): boolean { + return ( + value === undefined || + (isObject(value) && + typeof value.bundleId === 'string' && + (value.name === undefined || typeof value.name === 'string')) + ); +} + +function isNativeRecordingPath(value: unknown): value is string { + return typeof value === 'string' && nativeRecordingPath.test(value); +} + +function isDecimal(value: unknown): value is string { + return typeof value === 'string' && /^\d+$/.test(value); +} + +function sameApp( + left: ScreenRecordingCompletion['activeSessionApp'], + right: AndroidRecordingDescriptor['activeSessionApp'], +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function chunkPath(outputPath: string, index: number): string { + if (index === 1) return outputPath; + const extension = outputPath.lastIndexOf('.'); + const base = + extension > outputPath.lastIndexOf('/') ? outputPath.slice(0, extension) : outputPath; + const suffix = extension > outputPath.lastIndexOf('/') ? outputPath.slice(extension) : '.mp4'; + return `${base}.part-${String(index).padStart(3, '0')}${suffix}`; +} diff --git a/packages/platform-android/src/recording/manifest.test.ts b/packages/platform-android/src/recording/manifest.test.ts new file mode 100644 index 0000000000..8016e94621 --- /dev/null +++ b/packages/platform-android/src/recording/manifest.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from 'vitest'; +import { + androidScreenRecordingDescriptorCodec, + createNativeManifest, + decodeNativeManifest, + nativeManifestMatchesEnvelope, +} from './manifest.ts'; + +const device = { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, +}; + +test('decodes only complete, ordered Android native recording evidence', () => { + const manifest = createNativeManifest( + device, + { + sessionId: 'session-1', + outputPath: '/tmp/capture.mp4', + scope: 'device', + showTouches: true, + hideTouchesRequested: false, + recordOnlySession: false, + fence: { token: 'fence-1', generation: 2 }, + }, + 100, + [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-100.mp4', + remotePid: '42', + remoteStartTime: '1', + }, + ], + ); + expect(decodeNativeManifest(JSON.stringify(manifest))).toEqual(manifest); + expect( + decodeNativeManifest( + JSON.stringify({ ...manifest, chunks: [{ ...manifest.chunks[0], remotePid: 'pid' }] }), + ), + ).toBeUndefined(); + expect( + decodeNativeManifest( + JSON.stringify({ ...manifest, chunks: [{ ...manifest.chunks[0], index: 2 }] }), + ), + ).toBeUndefined(); +}); + +test('accepts only a durable Android manifest path in the descriptor', () => { + expect( + androidScreenRecordingDescriptorCodec.decode({ + backend: 'adb-screenrecord', + manifestPath: '/data/local/tmp/agent-device-recording-active.json', + outputPath: '/tmp/capture.mp4', + scope: 'device', + showTouches: true, + recordOnlySession: false, + transportMode: 'local', + }), + ).toMatchObject({ status: 'decoded' }); + expect( + androidScreenRecordingDescriptorCodec.decode({ + backend: 'adb-screenrecord', + manifestPath: '/tmp/agent-device-recording-active.json', + }), + ).toMatchObject({ status: 'invalid' }); +}); + +test('requires the device, session, and fence identity carried by the durable envelope', () => { + const manifest = createNativeManifest( + device, + { + sessionId: 'session-1', + outputPath: '/tmp/capture.mp4', + scope: 'device', + showTouches: true, + hideTouchesRequested: false, + recordOnlySession: false, + fence: { token: 'fence-1', generation: 2 }, + }, + 100, + [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-100.mp4', + remotePid: '42', + remoteStartTime: '1', + }, + ], + ); + const envelope = { + sessionId: 'session-1', + device: { id: device.id }, + fence: { token: 'fence-1', generation: 2 }, + }; + expect(nativeManifestMatchesEnvelope(manifest, device, envelope as never)).toBe(true); + expect( + nativeManifestMatchesEnvelope(manifest, device, { + ...envelope, + device: { id: 'another-device' }, + } as never), + ).toBe(false); +}); diff --git a/packages/platform-android/src/recording/manifest.ts b/packages/platform-android/src/recording/manifest.ts new file mode 100644 index 0000000000..cf3a251324 --- /dev/null +++ b/packages/platform-android/src/recording/manifest.ts @@ -0,0 +1,170 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + ScreenRecordingCompletion, + ScreenRecordingRuntimeOperations, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { SCREEN_RECORDING_RESOURCE_KIND } from '@agent-device/contracts/platform'; +import { + completionMatchesDescriptor, + isValidAndroidRecordingDescriptor, + isValidNativeManifest, +} from './manifest-validation.ts'; + +export type AndroidRecordingDescriptor = Readonly<{ + backend: 'adb-screenrecord'; + manifestPath: string; + outputPath: string; + clientOutputPath?: string; + scope: ScreenRecordingStartInput['scope']; + showTouches: boolean; + recordOnlySession: boolean; + activeSessionApp?: ScreenRecordingStartInput['activeSessionApp']; + exportQuality?: ScreenRecordingStartInput['exportQuality']; + transportMode: 'local' | 'transport-composed'; +}>; + +export type NativeChunk = Readonly<{ + index: number; + remotePath: string; + remotePid: string; + remoteStartTime: string; +}>; + +export type NativeManifest = Readonly<{ + version: 1; + resourceKind: 'screen-recording'; + fenceToken: string; + fenceGeneration: number; + sessionId: string; + deviceId: string; + startedAt: number; + outputPath: string; + clientOutputPath?: string; + scope: ScreenRecordingStartInput['scope']; + showTouches: boolean; + recordOnlySession: boolean; + activeSessionApp?: ScreenRecordingStartInput['activeSessionApp']; + exportQuality?: ScreenRecordingStartInput['exportQuality']; + transportMode: 'local' | 'transport-composed'; + chunks: readonly NativeChunk[]; + pendingRemotePath?: string; + completion?: ScreenRecordingCompletion; +}>; + +export const androidScreenRecordingDescriptorCodec = Object.freeze({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + version: 1, + encode: (descriptor: AndroidRecordingDescriptor) => ({ ...descriptor }), + decode: (body: Record) => + isValidAndroidRecordingDescriptor(body) + ? ({ + status: 'decoded', + descriptor: Object.freeze({ + backend: 'adb-screenrecord', + manifestPath: body.manifestPath, + outputPath: body.outputPath, + ...(body.clientOutputPath === undefined + ? {} + : { clientOutputPath: body.clientOutputPath }), + scope: body.scope, + showTouches: body.showTouches, + recordOnlySession: body.recordOnlySession, + transportMode: body.transportMode, + ...(body.activeSessionApp === undefined + ? {} + : { + activeSessionApp: + body.activeSessionApp as AndroidRecordingDescriptor['activeSessionApp'], + }), + ...(body.exportQuality === undefined + ? {} + : { + exportQuality: body.exportQuality as AndroidRecordingDescriptor['exportQuality'], + }), + }), + } as const) + : ({ status: 'invalid', message: 'Invalid Android screen-recording descriptor' } as const), +}); + +export function createNativeManifest( + device: DeviceInfo, + input: ScreenRecordingStartInput, + startedAt: number, + chunks: readonly NativeChunk[], + pendingRemotePath?: string, + transportMode: NativeManifest['transportMode'] = 'local', +): NativeManifest { + return Object.freeze({ + version: 1, + resourceKind: 'screen-recording', + fenceToken: input.fence.token, + fenceGeneration: input.fence.generation, + sessionId: input.sessionId, + deviceId: device.id, + startedAt, + outputPath: input.outputPath, + ...(input.clientOutputPath === undefined ? {} : { clientOutputPath: input.clientOutputPath }), + scope: input.scope, + showTouches: input.showTouches, + recordOnlySession: input.recordOnlySession, + ...(input.activeSessionApp === undefined ? {} : { activeSessionApp: input.activeSessionApp }), + ...(input.exportQuality === undefined ? {} : { exportQuality: input.exportQuality }), + chunks, + ...(pendingRemotePath === undefined ? {} : { pendingRemotePath }), + transportMode, + }); +} + +/** + * Native completion evidence remains fenced by the same manifest until the daemon has committed + * the matching durable terminal transition. It deliberately retains the original coordinates so + * a crash after native finalization can return the exact terminal result without rerunning it. + */ +export function createCompletedNativeManifest( + active: NativeManifest, + completion: ScreenRecordingCompletion, +): NativeManifest { + return Object.freeze({ ...active, completion }); +} + +export function decodeNativeManifest(value: string | undefined): NativeManifest | undefined { + try { + const parsed: unknown = value && JSON.parse(value); + return isValidNativeManifest(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export function nativeManifestMatchesEnvelope( + manifest: NativeManifest, + device: DeviceInfo, + envelope: Parameters[0]['envelope'], +): boolean { + return ( + manifest.deviceId === device.id && + manifest.deviceId === envelope.device.id && + manifest.fenceToken === envelope.fence.token && + manifest.fenceGeneration === envelope.fence.generation && + manifest.sessionId === envelope.sessionId + ); +} + +export function nativeManifestMatchesDescriptor( + manifest: NativeManifest, + descriptor: AndroidRecordingDescriptor, +): boolean { + return ( + manifest.outputPath === descriptor.outputPath && + manifest.clientOutputPath === descriptor.clientOutputPath && + manifest.scope === descriptor.scope && + manifest.showTouches === descriptor.showTouches && + manifest.recordOnlySession === descriptor.recordOnlySession && + JSON.stringify(manifest.activeSessionApp) === JSON.stringify(descriptor.activeSessionApp) && + manifest.exportQuality === descriptor.exportQuality && + manifest.transportMode === descriptor.transportMode && + (manifest.completion === undefined || + completionMatchesDescriptor(manifest.completion, descriptor)) + ); +} diff --git a/packages/platform-android/src/recording/recovery-cleanup.test.ts b/packages/platform-android/src/recording/recovery-cleanup.test.ts new file mode 100644 index 0000000000..be0315d796 --- /dev/null +++ b/packages/platform-android/src/recording/recovery-cleanup.test.ts @@ -0,0 +1,200 @@ +import { expect, test } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { androidRecordingDevice, recordingHost, recordingInput } from './fixtures.ts'; +import { bindAndroidScreenRecordingRuntime } from './runtime.ts'; + +const start = async (overrides: Record) => + await bindAndroidScreenRecordingRuntime({ + host: recordingHost(overrides), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); + +test('rejects corrupt native chunk paths with zero side effects', async () => { + let manifest = ''; + const calls: string[] = []; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + signal: async () => { + calls.push('signal'); + return true; + }, + pull: async () => { + calls.push('pull'); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + remove: async () => { + calls.push('remove'); + return true; + }, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + manifest = JSON.stringify({ + ...JSON.parse(manifest), + chunks: [{ index: 1, remotePid: '42', remotePath: '/data/local/tmp/unrelated.mp4' }], + }); + await expect( + runtime.screenRecordingReattach({ envelope: started.envelope }), + ).resolves.toMatchObject({ status: 'unreattachable', reason: 'ownership-fence-lost' }); + await expect( + runtime.screenRecordingCleanup({ envelope: started.envelope }), + ).resolves.toMatchObject({ status: 'cleanup-pending', reason: 'ownership-fence-lost' }); + expect(calls).toEqual([]); +}); + +test('retains evidence when artifact or manifest deletion is unconfirmed', async () => { + let artifactManifest = ''; + const artifactRuntime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + artifactManifest = contents; + }, + remove: async () => false, + }); + const artifact = await artifactRuntime.screenRecordingStart(recordingInput()); + await expect(artifact.pendingHandle.transfer().forceCleanup()).resolves.toMatchObject({ + status: 'cleanup-pending', + }); + expect(artifactManifest).not.toBe(''); + let manifest = ''; + const manifestRuntime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + removeManifest: async () => false, + }); + const started = await manifestRuntime.screenRecordingStart(recordingInput()); + await expect(started.pendingHandle.transfer().forceCleanup()).resolves.toMatchObject({ + status: 'cleanup-pending', + }); + expect(manifest).not.toBe(''); +}); + +test('never signals an exact-identity mismatch', async () => { + let manifest = ''; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + stop: async () => 'ownership-lost' as const, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + await expect(started.pendingHandle.transfer().forceCleanup()).resolves.toMatchObject({ + status: 'cleanup-pending', + }); + expect(manifest).not.toBe(''); +}); + +test('cleans verified dead evidence so a later start is admitted', async () => { + let manifest = ''; + let starts = 0; + let dead = false; + const runtime = await start({ + start: async () => { + dead = false; + return { + remotePid: String(40 + ++starts), + wait: new Promise(() => {}), + terminate: async () => {}, + [Symbol.asyncDispose]: async () => {}, + }; + }, + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + removeManifest: async () => { + manifest = ''; + return true; + }, + isRunning: async () => !dead, + exists: async () => !dead, + }); + const first = await runtime.screenRecordingStart(recordingInput()); + dead = true; + await expect( + runtime.screenRecordingReattach({ envelope: first.envelope }), + ).resolves.toMatchObject({ status: 'unreattachable', reason: 'transport-not-reattachable' }); + await expect(runtime.screenRecordingCleanup({ envelope: first.envelope })).resolves.toEqual({ + status: 'cleaned', + }); + await expect(runtime.screenRecordingStart(recordingInput())).resolves.toMatchObject({ + envelope: expect.any(Object), + }); + expect(starts).toBe(2); +}); + +test('rejects a tampered output path before native recovery side effects', async () => { + let manifest = ''; + const calls: string[] = []; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + signal: async () => { + calls.push('signal'); + return true; + }, + remove: async () => { + calls.push('remove'); + return true; + }, + pull: async () => { + calls.push('pull'); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + manifest = JSON.stringify({ ...JSON.parse(manifest), outputPath: '/tmp/unrelated.mp4' }); + await expect( + runtime.screenRecordingReattach({ envelope: started.envelope }), + ).resolves.toMatchObject({ status: 'unreattachable', reason: 'ownership-fence-lost' }); + await expect( + runtime.screenRecordingCleanup({ envelope: started.envelope }), + ).resolves.toMatchObject({ status: 'cleanup-pending', reason: 'ownership-fence-lost' }); + expect(calls).toEqual([]); +}); + +test('refuses mode-mismatched recovery without using a replacement transport', async () => { + let manifest = ''; + const calls: string[] = []; + const local = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + }); + const started = await local.screenRecordingStart(recordingInput()); + const composed = await bindAndroidScreenRecordingRuntime({ + host: recordingHost({ + mode: 'transport-composed', + readManifest: async () => ({ status: 'read' as const, contents: manifest }), + signal: async () => { + calls.push('signal'); + return true; + }, + pull: async () => { + calls.push('pull'); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); + await expect( + composed.screenRecordingReattach({ envelope: started.envelope }), + ).resolves.toMatchObject({ status: 'unreattachable', reason: 'transport-not-reattachable' }); + await expect( + composed.screenRecordingCleanup({ envelope: started.envelope }), + ).resolves.toMatchObject({ status: 'cleanup-pending', reason: 'owner-unavailable' }); + expect(calls).toEqual([]); +}); diff --git a/packages/platform-android/src/recording/recovery.test.ts b/packages/platform-android/src/recording/recovery.test.ts new file mode 100644 index 0000000000..8183106dc5 --- /dev/null +++ b/packages/platform-android/src/recording/recovery.test.ts @@ -0,0 +1,179 @@ +import { expect, test } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { androidRecordingDevice, recordingHost, recordingInput } from './fixtures.ts'; +import { bindAndroidScreenRecordingRuntime } from './runtime.ts'; + +const start = async (overrides: Record) => + await bindAndroidScreenRecordingRuntime({ + host: recordingHost(overrides), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); + +test('reattaches complete matching evidence and refuses fence, session, or device changes', async () => { + let manifest = ''; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + }); + const started = await runtime.screenRecordingStart({ + ...recordingInput(), + outputPath: '/tmp/manifest-name.mp4', + clientOutputPath: '/client/manifest-name.mp4', + scope: 'system', + recordOnlySession: true, + activeSessionApp: { bundleId: 'com.example.app', name: 'Example' }, + exportQuality: 'high', + }); + const active = await runtime.screenRecordingReattach({ envelope: started.envelope }); + expect(active.status).toBe('active'); + if (active.status === 'active') + expect(active.handle.inspect()).toMatchObject({ + outPath: '/tmp/manifest-name.mp4', + clientOutPath: '/client/manifest-name.mp4', + scope: 'system', + recordOnlySession: true, + exportQuality: 'high', + }); + for (const envelope of [ + { ...started.envelope, fence: { token: 'other', generation: 2 } }, + { ...started.envelope, sessionId: 'other-session' }, + { ...started.envelope, device: { ...started.envelope.device, id: 'other-device' } }, + ]) { + await expect(runtime.screenRecordingReattach({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'ownership-fence-lost', + }); + } +}); + +test('reattaches an ended pid with an artifact as finishable recovery and reports the 180s warning', async () => { + let manifest = ''; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + isRunning: async () => false, + exists: async () => true, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + const reattached = await runtime.screenRecordingReattach({ envelope: started.envelope }); + expect(reattached.status).toBe('active'); + if (reattached.status === 'active') + await expect(reattached.handle.finish()).resolves.toMatchObject({ + status: 'completed', + result: { warning: expect.stringContaining('likely after reaching the 180s platform limit') }, + }); +}); + +test('returns fenced native completion after a crash between native finalization and daemon terminalization', async () => { + let manifest = ''; + const removals: string[] = []; + let gcWouldFail = false; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + remove: async (remotePath: string) => { + removals.push(remotePath); + return !gcWouldFail; + }, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + const result = await started.pendingHandle.transfer().finish(); + expect(result.status).toBe('completed'); + expect(JSON.parse(manifest)).toMatchObject({ completion: { outPath: '/tmp/capture.mp4' } }); + gcWouldFail = true; + if (result.status === 'completed') { + await expect(runtime.screenRecordingReattach({ envelope: started.envelope })).resolves.toEqual({ + status: 'completed', + result: result.result, + }); + } + expect(removals).toHaveLength(1); +}); + +test('retains completed evidence while an exact persisted recorder identity remains alive', async () => { + let manifest = ''; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + inspect: async () => 'owned-alive' as const, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + const native = JSON.parse(manifest); + manifest = JSON.stringify({ + ...native, + completion: { + backend: 'adb screenrecord', + outPath: native.outputPath, + startedAt: native.startedAt, + completedAt: native.startedAt + 1, + scope: native.scope, + showTouches: native.showTouches, + recordOnlySession: native.recordOnlySession, + }, + }); + + await expect( + runtime.screenRecordingReattach({ envelope: started.envelope }), + ).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'ownership-fence-lost', + }); + expect(JSON.parse(manifest)).toHaveProperty('completion'); +}); + +test('makes matching pending evidence cleanup-eligible and stops discovered exact recorder pids', async () => { + let manifest = ''; + const removed: string[] = []; + const signals: string[] = []; + const stopped = new Set(); + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + remove: async (remotePath: string) => { + removed.push(remotePath); + return true; + }, + findRunning: async () => ['66'], + stop: async ({ pid }: { pid: string }) => { + signals.push(pid); + stopped.add(pid); + return 'stopped' as const; + }, + inspect: async () => 'missing' as const, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + manifest = JSON.stringify({ + ...JSON.parse(manifest), + chunks: [], + pendingRemotePath: '/data/local/tmp/agent-device-recording-777.mp4', + }); + await expect( + runtime.screenRecordingReattach({ envelope: started.envelope }), + ).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'transport-not-reattachable', + message: 'Android recording launch was interrupted before its process identity was committed.', + }); + await expect(runtime.screenRecordingCleanup({ envelope: started.envelope })).resolves.toEqual({ + status: 'cleaned', + }); + expect(removed).toEqual(['/data/local/tmp/agent-device-recording-777.mp4']); + expect(signals).toEqual(['66']); +}); diff --git a/packages/platform-android/src/recording/recovery.ts b/packages/platform-android/src/recording/recovery.ts new file mode 100644 index 0000000000..150ab5d8dc --- /dev/null +++ b/packages/platform-android/src/recording/recovery.ts @@ -0,0 +1,241 @@ +import type { + CleanupOutcome, + PlatformRuntimeHost, + ScreenRecordingRuntimeOperations, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { createScreenRecordingLiveHandle } from '@agent-device/capture-kit'; +import { + androidScreenRecordingDescriptorCodec, + decodeNativeManifest, + nativeManifestMatchesDescriptor, + nativeManifestMatchesEnvelope, + type AndroidRecordingDescriptor, + type NativeManifest, +} from './manifest.ts'; +import { cleanupVerifiedAndroidEvidence } from './cleanup.ts'; +import { snapshot } from './completion.ts'; +import { finalizeAndroidRecording } from './finalize.ts'; + +type Transport = Awaited>; +type Envelope = Parameters< + ScreenRecordingRuntimeOperations['screenRecordingCleanup'] +>[0]['envelope']; + +export type DescriptorEvidence = + | Readonly<{ status: 'matched'; evidence: NativeManifest }> + | Readonly<{ status: 'transport-unavailable' | 'ownership-lost' }>; + +async function readDescriptorEvidence(params: { + transport: Transport; + device: Parameters[1]; + envelope: Envelope; + descriptor: AndroidRecordingDescriptor; +}): Promise { + const { transport, device, envelope, descriptor } = params; + if (descriptor.transportMode !== transport.mode) return { status: 'transport-unavailable' }; + const nativeRead = await transport.readManifest(descriptor.manifestPath); + if (nativeRead.status === 'unavailable') return { status: 'transport-unavailable' }; + const evidence = + nativeRead.status === 'read' ? decodeNativeManifest(nativeRead.contents) : undefined; + return evidence && + nativeManifestMatchesEnvelope(evidence, device, envelope) && + nativeManifestMatchesDescriptor(evidence, descriptor) + ? { status: 'matched', evidence } + : { status: 'ownership-lost' }; +} + +export async function readLiveEvidence(params: { + transport: Transport; + deviceId: string; + sessionId: string; + fence: ScreenRecordingStartInput['fence']; + manifestPath: string; +}): Promise { + const nativeRead = await params.transport.readManifest(params.manifestPath); + if (nativeRead.status !== 'read') return undefined; + const evidence = decodeNativeManifest(nativeRead.contents); + return evidence && liveEvidenceMatches(evidence, params) ? evidence : undefined; +} + +function liveEvidenceMatches( + evidence: NativeManifest, + expected: Omit[0], 'transport' | 'manifestPath'>, +): boolean { + return ( + evidence.deviceId === expected.deviceId && + evidence.sessionId === expected.sessionId && + evidence.fenceToken === expected.fence.token && + evidence.fenceGeneration === expected.fence.generation + ); +} + +export async function reattachAndroidRecording(params: { + host: PlatformRuntimeHost; + transport: Transport; + device: Parameters[1]; + input: Parameters[0]; +}) { + const { host, transport, device, input } = params; + const parsed = androidScreenRecordingDescriptorCodec.decode(input.envelope.descriptor.body); + if (parsed.status !== 'decoded') return unreattachable('descriptor-invalid'); + const recovered = await readDescriptorEvidence({ + transport, + device, + envelope: input.envelope, + descriptor: parsed.descriptor, + }); + if (recovered.status !== 'matched') + return unreattachable( + recovered.status === 'transport-unavailable' + ? 'transport-not-reattachable' + : 'ownership-fence-lost', + ); + return await reattachEvidence({ + host, + transport, + device, + input, + descriptor: parsed.descriptor, + evidence: recovered.evidence, + }); +} + +export async function cleanupAndroidRecording(params: { + transport: Transport; + device: Parameters[1]; + input: Parameters[0]; +}): Promise { + const parsed = androidScreenRecordingDescriptorCodec.decode( + params.input.envelope.descriptor.body, + ); + if (parsed.status !== 'decoded') + return { status: 'cleanup-pending', reason: 'manual-recovery-required' }; + const recovered = await readDescriptorEvidence({ + transport: params.transport, + device: params.device, + envelope: params.input.envelope, + descriptor: parsed.descriptor, + }); + if (recovered.status !== 'matched') + return recovered.status === 'transport-unavailable' + ? { status: 'cleanup-pending', reason: 'owner-unavailable' } + : { status: 'cleanup-pending', reason: 'ownership-fence-lost' }; + return await cleanupVerifiedAndroidEvidence( + params.transport, + recovered.evidence, + parsed.descriptor.manifestPath, + ); +} + +async function reattachEvidence(params: { + host: PlatformRuntimeHost; + transport: Transport; + device: Parameters[1]; + input: Parameters[0]; + descriptor: AndroidRecordingDescriptor; + evidence: NativeManifest; +}) { + const { host, transport, device, input, descriptor, evidence } = params; + if (evidence.completion !== undefined && (await completedEvidenceIsTerminal(transport, evidence))) + return { status: 'completed' as const, result: evidence.completion }; + if (evidence.completion !== undefined) + return unreattachable( + 'ownership-fence-lost', + 'Android recording completed evidence still names a live or unverifiable recorder.', + ); + if (evidence.pendingRemotePath !== undefined) + return unreattachable( + 'transport-not-reattachable', + 'Android recording launch was interrupted before its process identity was committed.', + ); + const active = evidence.chunks.at(-1); + if (!active) return { status: 'missing' as const }; + const running = await transport.inspect({ + pid: active.remotePid, + remotePath: active.remotePath, + startTime: active.remoteStartTime, + }); + if (running !== 'owned-alive' && (await transport.exists(active.remotePath)) !== true) + return unreattachable( + 'transport-not-reattachable', + 'Android recording process ended before its artifact could be recovered.', + ); + const inputForHandle = inputFromDescriptor( + input.envelope.sessionId, + input.envelope.fence, + descriptor, + ); + let nativeCleanupConfirmed = false; + const handle = createScreenRecordingLiveHandle(snapshot(inputForHandle, evidence.startedAt), { + finish: async (current) => { + const outcome = await finalizeAndroidRecording({ + host, + transport, + evidence, + manifestPath: descriptor.manifestPath, + recording: current, + reachedLimit: running === 'missing', + }); + nativeCleanupConfirmed = true; + return outcome; + }, + forceCleanup: async () => + nativeCleanupConfirmed + ? ({ status: 'cleaned' } as const) + : await cleanupAndroidRecording({ transport, device, input }), + }); + return { status: 'active' as const, handle }; +} + +async function completedEvidenceIsTerminal(transport: Transport, evidence: NativeManifest) { + try { + for (const chunk of evidence.chunks) { + if ( + (await transport.inspect({ + pid: chunk.remotePid, + remotePath: chunk.remotePath, + startTime: chunk.remoteStartTime, + })) !== 'missing' + ) + return false; + } + return true; + } catch { + return false; + } +} + +function inputFromDescriptor( + sessionId: string, + fence: ScreenRecordingStartInput['fence'], + descriptor: AndroidRecordingDescriptor, +): ScreenRecordingStartInput { + return { + sessionId, + outputPath: descriptor.outputPath, + ...(descriptor.clientOutputPath === undefined + ? {} + : { clientOutputPath: descriptor.clientOutputPath }), + scope: descriptor.scope, + showTouches: descriptor.showTouches, + hideTouchesRequested: false, + recordOnlySession: descriptor.recordOnlySession, + ...(descriptor.activeSessionApp === undefined + ? {} + : { activeSessionApp: descriptor.activeSessionApp }), + ...(descriptor.exportQuality === undefined ? {} : { exportQuality: descriptor.exportQuality }), + fence, + }; +} + +function unreattachable( + reason: 'descriptor-invalid' | 'transport-not-reattachable' | 'ownership-fence-lost', + message?: string, +) { + return { + status: 'unreattachable' as const, + reason, + ...(message === undefined ? {} : { message }), + }; +} diff --git a/packages/platform-android/src/recording/runtime.test.ts b/packages/platform-android/src/recording/runtime.test.ts new file mode 100644 index 0000000000..4f0e1853bf --- /dev/null +++ b/packages/platform-android/src/recording/runtime.test.ts @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict'; +import { expect, test, vi } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { + androidRecordingDevice, + recordingHost, + recordingInput, + recordingProcess, +} from './fixtures.ts'; +import { bindAndroidScreenRecordingRuntime } from './runtime.ts'; + +const start = async (overrides: Record) => + await bindAndroidScreenRecordingRuntime({ + host: recordingHost(overrides), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); + +test('persists durable fence evidence and finishes through the scoped Android transport', async () => { + let manifest = ''; + const calls: string[] = []; + const runtime = await start({ + start: async ({ remotePath }: { remotePath: string }) => { + calls.push(`start:${remotePath}`); + return recordingProcess('42'); + }, + pull: async ({ remotePath, outputPath }: { remotePath: string; outputPath: string }) => { + calls.push(`pull:${remotePath}:${outputPath}`); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + calls.push(JSON.parse(contents).completion ? 'completed-evidence' : 'active-evidence'); + }, + removeManifest: async () => { + manifest = ''; + return true; + }, + remove: async (remotePath: string) => { + calls.push(`remove:${remotePath}`); + return true; + }, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + assert.match(manifest, /"fenceToken":"fence-1"/); + assert.match(manifest, /"fenceGeneration":2/); + await expect(started.pendingHandle.transfer().finish()).resolves.toMatchObject({ + status: 'completed', + }); + expect(calls.filter((call) => call.startsWith('pull:'))).toHaveLength(1); + expect(JSON.parse(manifest)).toMatchObject({ completion: { outPath: '/tmp/capture.mp4' } }); + expect(calls.indexOf('completed-evidence')).toBeLessThan( + calls.findIndex((call) => call.startsWith('remove:')), + ); +}); + +test('does not remove native evidence when bounded playable pulls are exhausted', async () => { + vi.useFakeTimers(); + try { + let manifest = ''; + const removed: string[] = []; + const runtime = await start({ + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + pullPlayable: async () => ({ stdout: '', stderr: '', exitCode: 0, playable: false }), + remove: async (remotePath: string) => { + removed.push(remotePath); + return true; + }, + }); + const handle = (await runtime.screenRecordingStart(recordingInput())).pendingHandle.transfer(); + const finishing = handle.finish(); + const rejected = expect(finishing).rejects.toThrow('playable Android recording'); + await vi.advanceTimersByTimeAsync(3_000); + await rejected; + expect(removed).toEqual([]); + expect(JSON.parse(manifest)).not.toHaveProperty('completion'); + } finally { + vi.useRealTimers(); + } +}); + +test('waits for a SIGINT-accepted recorder to exit before sizing or pulling its artifact', async () => { + vi.useFakeTimers(); + try { + let alive = true; + const calls: string[] = []; + const runtime = await start({ + stop: async () => { + calls.push('sigint'); + return 'stopped' as const; + }, + inspect: async () => (alive ? ('owned-alive' as const) : ('missing' as const)), + size: async () => { + calls.push('size'); + return 1; + }, + pullPlayable: async () => { + calls.push('pull'); + return { stdout: '', stderr: '', exitCode: 0, playable: true }; + }, + }); + const finishing = (await runtime.screenRecordingStart(recordingInput())).pendingHandle + .transfer() + .finish(); + await vi.advanceTimersByTimeAsync(0); + expect(calls).toEqual(['sigint']); + alive = false; + await vi.advanceTimersByTimeAsync(2_000); + await expect(finishing).resolves.toMatchObject({ status: 'completed' }); + expect(calls).toEqual(['sigint', 'size', 'size', 'pull']); + } finally { + vi.useRealTimers(); + } +}); + +test('retains native evidence when finalization fails, then cleans it through compensation', async () => { + const calls: string[] = []; + const stopped = new Set(); + let manifest = ''; + const host = recordingHost({ + signal: async ({ pid }: { pid: string }) => { + calls.push(`signal:${pid}`); + stopped.add(pid); + return true; + }, + isRunning: async (pid: string) => !stopped.has(pid), + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + remove: async (path: string) => { + calls.push(`remove:${path}`); + return true; + }, + removeManifest: async (path: string) => { + calls.push(`manifest:${path}`); + return true; + }, + }); + (host.screenRecording.finalize as { complete: () => Promise }).complete = async () => { + throw new Error('finalizer failed'); + }; + const runtime = await bindAndroidScreenRecordingRuntime({ + host, + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); + const handle = (await runtime.screenRecordingStart(recordingInput())).pendingHandle.transfer(); + await expect(handle.finish()).rejects.toThrow('finalizer failed'); + await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); + expect(calls).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^signal:/), + expect.stringMatching(/^remove:/), + expect.stringMatching(/^manifest:/), + ]), + ); +}); + +test('serializes a failed rotation so finish cannot publish completion', async () => { + vi.useFakeTimers(); + try { + let starts = 0; + let manifest = ''; + const runtime = await start({ + start: async () => { + starts += 1; + if (starts > 1) throw new Error('rotation start failed'); + return recordingProcess('42'); + }, + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + }); + const started = await runtime.screenRecordingStart(recordingInput()); + await vi.advanceTimersByTimeAsync(170_000); + expect(JSON.parse(manifest)).toMatchObject({ chunks: [{ remotePid: '42' }] }); + expect(JSON.parse(manifest)).not.toHaveProperty('pendingRemotePath'); + await expect(started.pendingHandle.transfer().finish()).rejects.toThrow( + 'rotation start failed', + ); + } finally { + vi.useRealTimers(); + } +}); + +test('falls back to sequential rotation when concurrent start is unavailable', async () => { + vi.useFakeTimers(); + try { + let starts = 0; + let manifest = ''; + const runtime = await start({ + start: async () => { + starts += 1; + if (starts === 1 || starts > 4) return recordingProcess(String(41 + starts)); + throw new Error('concurrent unavailable'); + }, + writeManifest: async ({ contents }: { contents: string }) => { + manifest = contents; + }, + }); + await runtime.screenRecordingStart(recordingInput()); + await vi.advanceTimersByTimeAsync(170_000); + expect(starts).toBe(5); + expect(JSON.parse(manifest).chunks).toHaveLength(2); + } finally { + vi.useRealTimers(); + } +}); + +test('rolls back a failed concurrent rotation commit and restores prior active evidence', async () => { + vi.useFakeTimers(); + try { + let writes = 0; + let starts = 0; + let manifest = ''; + const stopped = new Set(); + const runtime = await start({ + start: async () => recordingProcess(String(42 + starts++)), + writeManifest: async ({ contents }: { contents: string }) => { + writes += 1; + if (writes === 4) throw new Error('rotation manifest failed'); + manifest = contents; + }, + readManifest: async () => + manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const }, + signal: async ({ pid }: { pid: string }) => { + stopped.add(pid); + return true; + }, + isRunning: async (pid: string) => !stopped.has(pid), + }); + const handle = (await runtime.screenRecordingStart(recordingInput())).pendingHandle.transfer(); + await vi.advanceTimersByTimeAsync(170_000); + expect(JSON.parse(manifest)).toMatchObject({ chunks: [{ remotePid: '42' }] }); + expect(JSON.parse(manifest)).not.toHaveProperty('pendingRemotePath'); + await expect(handle.finish()).rejects.toThrow('rotation manifest failed'); + await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); + expect(stopped).toEqual(new Set(['42', '43'])); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-android/src/recording/runtime.ts b/packages/platform-android/src/recording/runtime.ts new file mode 100644 index 0000000000..330938fdad --- /dev/null +++ b/packages/platform-android/src/recording/runtime.ts @@ -0,0 +1,214 @@ +import path from 'node:path'; +import { deviceIdentity, type DeviceInfo } from '@agent-device/kernel/device'; +import type { + PlatformRuntimeHost, + RuntimeOwnerRef, + ScreenRecordingRuntimeOperations, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { + PendingTransferGuard, + SCREEN_RECORDING_RESOURCE_KIND, +} from '@agent-device/contracts/platform'; +import { + createDurableResourceEnvelope, + createScreenRecordingLiveHandle, + encodeDurableDescriptor, +} from '@agent-device/capture-kit'; +import { + androidScreenRecordingDescriptorCodec, + createNativeManifest, + type NativeChunk, +} from './manifest.ts'; +import { rollbackChunks, stopChunk } from './chunks.ts'; +import { persistNativeManifest, startInitialTransaction, startPendingChunk } from './launch.ts'; +import { snapshot } from './completion.ts'; +import { finalizeAndroidRecording } from './finalize.ts'; +import { cleanupVerifiedAndroidEvidence } from './cleanup.ts'; +import { cleanupAndroidRecording, reattachAndroidRecording, readLiveEvidence } from './recovery.ts'; + +const ROTATE_AFTER_MS = 170_000; + +export async function bindAndroidScreenRecordingRuntime(params: { + host: PlatformRuntimeHost; + device: DeviceInfo; + owner: RuntimeOwnerRef; + signal: AbortSignal; +}): Promise { + const { host, device, owner, signal } = params; + const transport = await host.screenRecording.android.resolve(device); + return Object.freeze({ + screenRecordingStart: async (input) => + await startAndroidRecording({ host, transport, device, owner, input, signal }), + screenRecordingReattach: async (input) => + await reattachAndroidRecording({ host, transport, device, input }), + screenRecordingCleanup: async (input) => + await cleanupAndroidRecording({ transport, device, input }), + } satisfies ScreenRecordingRuntimeOperations); +} + +async function startAndroidRecording(params: { + host: PlatformRuntimeHost; + transport: Awaited>; + device: DeviceInfo; + owner: RuntimeOwnerRef; + input: ScreenRecordingStartInput; + signal: AbortSignal; +}) { + const { host, transport, device, owner, input, signal } = params; + signal.throwIfAborted(); + const startedAt = Date.now(); + const initial = await startInitialTransaction({ + transport, + device, + input, + startedAt, + signal, + prepareOutput: async () => await host.screenRecording.outputs.prepare(input.outputPath), + }); + const manifestPath = initial.manifestPath; + let chunks: NativeChunk[] = [initial.chunk]; + let timer: ReturnType | undefined; + let rotation: Promise | undefined; + let rotationFailure: unknown; + let nativeCleanupConfirmed = false; + const schedule = () => { + timer = setTimeout(() => { + rotation = rotate().catch((error: unknown) => { + rotationFailure = error; + }); + }, ROTATE_AFTER_MS); + timer.unref?.(); + }; + const rotate = async () => { + const previous = chunks.at(-1); + if (!previous) throw new Error('Android recording has no active chunk'); + let previousStopped = false; + let next: NativeChunk; + try { + next = await startPendingChunk({ + transport, + device, + input, + startedAt, + chunks, + manifestPath, + preferredDir: path.posix.dirname(previous.remotePath), + }); + } catch (concurrentStartError) { + await stopChunk(transport, previous); + previousStopped = true; + try { + next = await startPendingChunk({ + transport, + device, + input, + startedAt, + chunks, + manifestPath, + preferredDir: path.posix.dirname(previous.remotePath), + }); + } catch (sequentialStartError) { + throw sequentialStartError instanceof Error ? sequentialStartError : concurrentStartError; + } + } + const nextChunks = [...chunks, { ...next, index: chunks.length + 1 }]; + try { + await persistNativeManifest( + transport, + manifestPath, + createNativeManifest(device, input, startedAt, nextChunks, undefined, transport.mode), + ); + } catch (error) { + await rollbackChunks(transport, [next]).catch(() => {}); + if (!previousStopped) { + try { + await persistNativeManifest( + transport, + manifestPath, + createNativeManifest(device, input, startedAt, chunks, undefined, transport.mode), + ); + } catch { + await stopChunk(transport, previous).catch(() => {}); + await persistNativeManifest( + transport, + manifestPath, + createNativeManifest(device, input, startedAt, chunks, next.remotePath, transport.mode), + ).catch(() => {}); + } + } else { + await persistNativeManifest( + transport, + manifestPath, + createNativeManifest(device, input, startedAt, chunks, next.remotePath, transport.mode), + ).catch(() => {}); + } + throw error; + } + chunks = nextChunks; + if (!previousStopped) await stopChunk(transport, previous); + schedule(); + }; + schedule(); + const handle = createScreenRecordingLiveHandle(snapshot(input, startedAt), { + finish: async (current) => { + if (timer) clearTimeout(timer); + await rotation; + if (rotationFailure) throw rotationFailure; + const outcome = await finalizeAndroidRecording({ + host, + transport, + evidence: createNativeManifest(device, input, startedAt, chunks, undefined, transport.mode), + manifestPath, + recording: current, + }); + nativeCleanupConfirmed = true; + return outcome; + }, + forceCleanup: async () => { + if (timer) clearTimeout(timer); + if (nativeCleanupConfirmed) return { status: 'cleaned' } as const; + try { + await rotation; + } catch (error) { + rotationFailure = error; + } + const evidence = await readLiveEvidence({ + transport, + deviceId: device.id, + sessionId: input.sessionId, + fence: input.fence, + manifestPath, + }); + if (!evidence) return { status: 'cleanup-pending', reason: 'ownership-fence-lost' }; + return await cleanupVerifiedAndroidEvidence(transport, evidence, manifestPath); + }, + }); + return Object.freeze({ + pendingHandle: new PendingTransferGuard(handle), + envelope: createDurableResourceEnvelope({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + sessionId: input.sessionId, + device: deviceIdentity(device), + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(androidScreenRecordingDescriptorCodec, { + backend: 'adb-screenrecord', + manifestPath, + outputPath: input.outputPath, + ...(input.clientOutputPath === undefined + ? {} + : { clientOutputPath: input.clientOutputPath }), + scope: input.scope, + showTouches: input.showTouches, + recordOnlySession: input.recordOnlySession, + ...(input.activeSessionApp === undefined + ? {} + : { activeSessionApp: input.activeSessionApp }), + ...(input.exportQuality === undefined ? {} : { exportQuality: input.exportQuality }), + transportMode: transport.mode, + }), + }), + }); +} diff --git a/packages/platform-android/src/recording/start-reconciliation.test.ts b/packages/platform-android/src/recording/start-reconciliation.test.ts new file mode 100644 index 0000000000..0ab1bf3c36 --- /dev/null +++ b/packages/platform-android/src/recording/start-reconciliation.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { + androidRecordingDevice, + recordingHost, + recordingInput, + recordingProcess, +} from './fixtures.ts'; +import { createCompletedNativeManifest, createNativeManifest } from './manifest.ts'; +import { bindAndroidScreenRecordingRuntime } from './runtime.ts'; + +const start = async (overrides: Record) => + await bindAndroidScreenRecordingRuntime({ + host: recordingHost(overrides), + device: androidRecordingDevice, + owner: localRuntimeOwner('android'), + signal: new AbortController().signal, + }); +const newInput = () => ({ ...recordingInput(), fence: { token: 'fence-2', generation: 3 } }); + +test('reconciles coherent completed evidence before output preparation or launch', async () => { + let marker = JSON.stringify(completedEvidence()); + const calls: string[] = []; + const runtime = await start({ + readManifest: async (path: string) => + path.startsWith('/sdcard') && marker + ? { status: 'read' as const, contents: marker } + : { status: 'missing' as const }, + inspect: async () => 'missing' as const, + remove: async (path: string) => { + calls.push(`artifact:${path}`); + return true; + }, + removeManifest: async () => { + calls.push('manifest'); + marker = ''; + return true; + }, + outputs: { + prepare: async () => { + calls.push('prepare'); + }, + }, + start: async () => { + calls.push('launch'); + return recordingProcess('77'); + }, + }); + const started = await runtime.screenRecordingStart(newInput()); + expect(calls).toEqual([ + 'artifact:/sdcard/agent-device-recording-1.mp4', + 'manifest', + 'prepare', + 'launch', + ]); + await started.pendingHandle.transfer().forceCleanup(); +}); + +test('retirement failure blocks launch and can succeed on a later retry', async () => { + let marker = JSON.stringify(completedEvidence()); + let allowRemoval = false; + const calls: string[] = []; + const runtime = await start({ + readManifest: async (path: string) => + path.startsWith('/sdcard') && marker + ? { status: 'read' as const, contents: marker } + : { status: 'missing' as const }, + inspect: async () => 'missing' as const, + remove: async () => allowRemoval, + removeManifest: async () => { + marker = ''; + return true; + }, + outputs: { + prepare: async () => { + calls.push('prepare'); + }, + }, + start: async () => { + calls.push('launch'); + return recordingProcess('77'); + }, + }); + await expect(runtime.screenRecordingStart(newInput())).rejects.toThrow('failed to remove'); + expect(calls).toEqual([]); + expect(marker).not.toBe(''); + allowRemoval = true; + const started = await runtime.screenRecordingStart(newInput()); + expect(calls).toEqual(['prepare', 'launch']); + await started.pendingHandle.transfer().forceCleanup(); +}); + +test('requires manifest retirement confirmation after artifact cleanup', async () => { + const marker = JSON.stringify(completedEvidence()); + const calls: string[] = []; + const runtime = await start({ + readManifest: async (path: string) => + path.startsWith('/sdcard') + ? { status: 'read' as const, contents: marker } + : { status: 'missing' as const }, + inspect: async () => 'missing' as const, + remove: async () => { + calls.push('artifact'); + return true; + }, + removeManifest: async () => { + calls.push('manifest'); + return true; + }, + outputs: { + prepare: async () => { + calls.push('prepare'); + }, + }, + start: async () => { + calls.push('launch'); + return recordingProcess('77'); + }, + }); + await expect(runtime.screenRecordingStart(newInput())).rejects.toThrow( + 'removal could not be confirmed', + ); + expect(calls).toEqual(['artifact', 'manifest']); +}); + +test.each([ + [ + 'open', + JSON.stringify( + createNativeManifest( + androidRecordingDevice, + recordingInput(), + 1, + [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-1.mp4', + remotePid: '41', + remoteStartTime: '7', + }, + ], + undefined, + 'local', + ), + ), + ], + ['corrupt', '{broken'], + ['wrong device', JSON.stringify({ ...completedEvidence(), deviceId: 'other-device' })], + [ + 'changed completion outPath', + JSON.stringify(tamperCompletion({ outPath: '/tmp/unrelated.mp4' })), + ], + ['changed completion backend', JSON.stringify(tamperCompletion({ backend: 'other recorder' }))], + ['changed completion startedAt', JSON.stringify(tamperCompletion({ startedAt: 9 }))], + [ + 'changed completion chunk path', + JSON.stringify(tamperCompletion({ chunks: [{ index: 1, path: '/tmp/unrelated.mp4' }] })), + ], +])('does not retire %s native evidence', async (_name, marker) => { + const calls: string[] = []; + const runtime = await start({ + readManifest: async (path: string) => + path.startsWith('/sdcard') + ? { status: 'read' as const, contents: marker } + : { status: 'missing' as const }, + remove: async () => { + calls.push('artifact'); + return true; + }, + removeManifest: async () => { + calls.push('manifest'); + return true; + }, + outputs: { + prepare: async () => { + calls.push('prepare'); + }, + }, + start: async () => { + calls.push('launch'); + return recordingProcess('77'); + }, + }); + await expect(runtime.screenRecordingStart(newInput())).rejects.toThrow( + 'native recovery evidence', + ); + expect(calls).toEqual([]); +}); + +function completedEvidence() { + const input = recordingInput(); + return createCompletedNativeManifest( + createNativeManifest( + androidRecordingDevice, + input, + 1, + [ + { + index: 1, + remotePath: '/sdcard/agent-device-recording-1.mp4', + remotePid: '41', + remoteStartTime: '7', + }, + ], + undefined, + 'local', + ), + { + backend: 'adb screenrecord', + outPath: input.outputPath, + startedAt: 1, + completedAt: 2, + scope: input.scope, + showTouches: input.showTouches, + recordOnlySession: input.recordOnlySession, + }, + ); +} + +function tamperCompletion(patch: Record) { + const evidence = completedEvidence(); + return { ...evidence, completion: { ...evidence.completion!, ...patch } }; +} diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index d2e0444381..cade9cab63 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -18,6 +18,24 @@ test.each([ ])('classifies the Android %s runtime denominator', async (_name, runtimeDevice) => { const host = { processTransports: { resolve: async () => ({ mode: 'local' as const }) }, + screenRecording: { + android: { + resolve: async () => ({ + mode: 'local' as const, + start: async () => { + throw new Error('unused'); + }, + signal: async () => true, + isRunning: async () => false, + exists: async () => false, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + readManifest: async () => undefined, + writeManifest: async () => {}, + removeManifest: async () => {}, + }), + }, + }, } as unknown as PlatformRuntimeHost; const binding = await createAndroidPlatformRuntime(host).bind({ device: runtimeDevice, @@ -31,4 +49,7 @@ test.each([ const { facts } = binding; expect(facts.device.providerMode).toBe('local'); expect(facts.operations.networkDump).toEqual({ available: true }); + expect(facts.operations.screenRecordingStart).toEqual({ available: true }); + expect(facts.operations.screenRecordingReattach).toEqual({ available: true }); + expect(facts.operations.screenRecordingCleanup).toEqual({ available: true }); }); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 1f9f936aa9..3ec12d788e 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -8,6 +8,7 @@ import type { import { localRuntimeOwner } from '@agent-device/contracts/platform'; import { createAndroidAppLogRuntime } from './logs/runtime.ts'; import { dumpAndroidNetworkTraffic } from './network/runtime.ts'; +import { bindAndroidScreenRecordingRuntime } from './recording/runtime.ts'; const owner = localRuntimeOwner('android'); const available = Object.freeze({ available: true } as const); @@ -19,17 +20,30 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ownsDevice: (device) => device.platform === 'android', bind: async (request) => { const logs = await appLogs.bind(request); + const recording = await bindAndroidScreenRecordingRuntime({ + host, + device: request.device, + owner, + signal: request.scope.signal, + }); return Object.freeze({ device: logs.device, owner, facts: Object.freeze({ device: logs.facts.device, - operations: { ...logs.facts.operations, networkDump: available }, + operations: { + ...logs.facts.operations, + networkDump: available, + screenRecordingStart: available, + screenRecordingReattach: available, + screenRecordingCleanup: available, + }, }), operations: Object.freeze({ ...logs.operations, networkDump: async (input: NetworkDumpInput) => await dumpAndroidNetworkTraffic(host, request.device, input, request.scope.signal), + ...recording, }), [Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](), }) satisfies DeviceBinding; diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 691a518174..7461f62082 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -154,5 +154,38 @@ function unusedAppLogHost(): Omit< }, processTransports: { resolve: async () => ({ mode: 'local' }) }, clock: { now: () => 1, sleep: async () => {} }, + screenRecording: { + outputs: { prepare: async () => {} }, + apple: { + availability: async () => ({ available: true }), + runRunner: async () => ({}), + startSimulator: async () => { + throw new Error('unused'); + }, + inspectProcess: async () => 'missing', + terminateProcess: async () => 'already-missing', + inspectRunner: async () => 'missing', + retrieveRunnerRecording: async () => {}, + captureClockAnchor: async () => undefined, + isRunnerBundleId: async () => false, + }, + android: { + resolve: async () => { + throw new Error('unused'); + }, + }, + harmony: { + start: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + stop: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + findMedia: async () => undefined, + stageMedia: async () => false, + stagedFileSize: async () => undefined, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + removeMedia: async () => true, + }, + web: { resolve: async () => undefined }, + finalize: { complete: async () => ({}) }, + }, }; } diff --git a/packages/platform-apple/src/recording/completion.test.ts b/packages/platform-apple/src/recording/completion.test.ts new file mode 100644 index 0000000000..54a81bce7b --- /dev/null +++ b/packages/platform-apple/src/recording/completion.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; +import { completeAppleRecording } from './completion.ts'; +import { appleRecordingHost } from './runtime.fixtures.ts'; + +test('projects invalidated touch-overlay state without publishing a false overlay', async () => { + const snapshot = { + backend: 'runner AVAssetWriter', + outPath: '/tmp/capture.mp4', + startedAt: 1, + scope: 'app' as const, + showTouches: true, + recordOnlySession: false, + gestureEvents: [], + invalidatedReason: 'runner restarted', + }; + await expect( + completeAppleRecording(appleRecordingHost(), snapshot, 'iOS recording'), + ).resolves.toMatchObject({ + status: 'completed', + result: { overlayWarning: 'overlay unavailable: runner restarted' }, + }); +}); diff --git a/packages/platform-apple/src/recording/completion.ts b/packages/platform-apple/src/recording/completion.ts new file mode 100644 index 0000000000..3441e6d38b --- /dev/null +++ b/packages/platform-apple/src/recording/completion.ts @@ -0,0 +1,35 @@ +import type { ScreenRecordingLiveSnapshot } from '@agent-device/contracts/platform'; +import { createScreenRecordingCompletion } from '@agent-device/capture-kit'; +import type { AppleScreenRecordingOperationHost } from './recovery.ts'; + +export async function completeAppleRecording( + host: AppleScreenRecordingOperationHost, + snapshot: ScreenRecordingLiveSnapshot, + targetLabel: string, +) { + if (snapshot.invalidatedReason && !snapshot.showTouches) { + throw new Error(`recording invalidated: ${snapshot.invalidatedReason}`); + } + const finalization = await host.screenRecording.finalize.complete({ + outputPath: snapshot.outPath, + showTouches: snapshot.invalidatedReason ? false : snapshot.showTouches, + gestureEvents: snapshot.gestureEvents, + exportQuality: snapshot.exportQuality ?? 'medium', + ...(snapshot.runnerStartedAtUptimeMs !== undefined && + snapshot.targetAppReadyUptimeMs !== undefined + ? { + trimStartMs: Math.max( + 0, + snapshot.targetAppReadyUptimeMs - snapshot.runnerStartedAtUptimeMs, + ), + } + : {}), + targetLabel, + }); + return createScreenRecordingCompletion(snapshot, { + ...finalization, + ...(snapshot.invalidatedReason + ? { overlayWarning: `overlay unavailable: ${snapshot.invalidatedReason}` } + : {}), + }); +} diff --git a/packages/platform-apple/src/recording/recovery.test.ts b/packages/platform-apple/src/recording/recovery.test.ts new file mode 100644 index 0000000000..9fd9a44304 --- /dev/null +++ b/packages/platform-apple/src/recording/recovery.test.ts @@ -0,0 +1,152 @@ +import { expect, test, vi } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createAppleScreenRecordingOperations } from './runtime.ts'; +import { + appleRecordingHost, + coreDevice, + processIdentity, + recordingInput, + simulator, +} from './runtime.fixtures.ts'; + +test('daemon-loss cleanup distinguishes live, dead, replaced, and corrupt simulator identity', async () => { + const startOperations = createAppleScreenRecordingOperations({ + host: appleRecordingHost({ + apple: { + startSimulator: async () => ({ + markers: [processIdentity], + wait: new Promise(() => {}), + terminate: async () => {}, + }), + }, + }), + device: simulator, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const started = await startOperations.screenRecordingStart(recordingInput()); + for (const [ownership, expected, terminations] of [ + ['owned-alive', 'cleaned', 1], + ['missing', 'already-missing', 0], + ['ownership-lost', 'cleanup-pending', 0], + ] as const) { + const terminateProcess = vi.fn(async () => 'terminated' as const); + const recovery = createAppleScreenRecordingOperations({ + host: appleRecordingHost({ + apple: { inspectProcess: async () => ownership, terminateProcess }, + }), + device: simulator, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + await expect( + recovery.screenRecordingCleanup({ envelope: started.envelope }), + ).resolves.toMatchObject({ status: expected }); + expect(terminateProcess).toHaveBeenCalledTimes(terminations); + } + + const inspectProcess = vi.fn(async () => 'owned-alive' as const); + const corruptEnvelope = { + ...started.envelope, + descriptor: { + ...started.envelope.descriptor, + body: { + ...started.envelope.descriptor.body, + processes: [{ pid: 42, startTime: '', command: processIdentity.command }], + }, + }, + }; + const corruptRecovery = createAppleScreenRecordingOperations({ + host: appleRecordingHost({ apple: { inspectProcess } }), + device: simulator, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + await expect( + corruptRecovery.screenRecordingCleanup({ envelope: corruptEnvelope }), + ).resolves.toMatchObject({ status: 'cleanup-pending' }); + expect(inspectProcess).not.toHaveBeenCalled(); +}); + +test('runner recovery never stops a replacement session owner', async () => { + const operations = createAppleScreenRecordingOperations({ + host: appleRecordingHost(), + device: coreDevice, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(recordingInput({ scope: 'app' })); + const runRunner = vi.fn(async () => ({})); + const recovery = createAppleScreenRecordingOperations({ + host: appleRecordingHost({ + apple: { inspectRunner: async () => 'ownership-lost', runRunner }, + }), + device: coreDevice, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + + await expect( + recovery.screenRecordingCleanup({ envelope: started.envelope }), + ).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'ownership-fence-lost', + }); + expect(runRunner).not.toHaveBeenCalled(); +}); + +test.each([ + ['CoreDevice runner without remote path', coreDevice, undefined], + [ + 'macOS runner with remote path', + { ...coreDevice, appleOs: 'macos' as const, target: 'desktop' as const }, + 'tmp/agent-device-recording-123.mp4', + ], +] as const)( + 'rejects %s descriptor coherence before any ownership side effect', + async (_name, device, remotePath) => { + const operations = createAppleScreenRecordingOperations({ + host: appleRecordingHost({ + apple: { + runRunner: async (_device, request) => + request.kind === 'start' + ? { + runnerSessionId: 'runner-session', + runnerAuthority: 'local-lease', + remotePath: 'tmp/agent-device-recording-123.mp4', + } + : {}, + }, + }), + device: coreDevice, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(recordingInput({ scope: 'app' })); + const inspectRunner = vi.fn(async () => 'owned-alive' as const); + const recovery = createAppleScreenRecordingOperations({ + host: appleRecordingHost({ apple: { inspectRunner } }), + device, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const { remotePath: _persistedRemotePath, ...bodyWithoutRemotePath } = + started.envelope.descriptor.body; + const envelope = { + ...started.envelope, + descriptor: { + ...started.envelope.descriptor, + body: + remotePath === undefined + ? bodyWithoutRemotePath + : { ...bodyWithoutRemotePath, remotePath }, + }, + }; + + await expect(recovery.screenRecordingReattach({ envelope })).resolves.toMatchObject({ + status: 'unreattachable', + reason: 'descriptor-invalid', + }); + expect(inspectRunner).not.toHaveBeenCalled(); + }, +); diff --git a/packages/platform-apple/src/recording/recovery.ts b/packages/platform-apple/src/recording/recovery.ts new file mode 100644 index 0000000000..f28daf87d5 --- /dev/null +++ b/packages/platform-apple/src/recording/recovery.ts @@ -0,0 +1,281 @@ +import { deviceIdentity, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import type { + CleanupOutcome, + DurableDescriptorCodec, + ManagedProcessIdentity, + RuntimeOwnerRef, + ScreenRecordingRuntimeHost, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { SCREEN_RECORDING_RESOURCE_KIND } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit'; + +export type AppleScreenRecordingOperationHost = Readonly<{ + screenRecording: Pick; +}>; + +export type AppleRecordingDescriptor = + | Readonly<{ + backend: 'simctl'; + outputPath: string; + processes: readonly ManagedProcessIdentity[]; + }> + | Readonly<{ + backend: 'runner'; + outputPath: string; + appBundleId: string; + runnerSessionId: string; + runnerAuthority: 'local-lease' | 'scoped-provider'; + remotePath?: string; + }>; + +type AppleRecordingDescriptorCodec = DurableDescriptorCodec< + AppleRecordingDescriptor, + typeof SCREEN_RECORDING_RESOURCE_KIND +>; + +const encodeAppleRecordingDescriptor: AppleRecordingDescriptorCodec['encode'] = (descriptor) => { + if (descriptor.backend === 'simctl') { + const encoded: ReturnType = { + backend: descriptor.backend, + outputPath: descriptor.outputPath, + processes: descriptor.processes.map((process) => ({ ...process })), + }; + return encoded; + } + const encoded: ReturnType = { + backend: descriptor.backend, + outputPath: descriptor.outputPath, + appBundleId: descriptor.appBundleId, + runnerSessionId: descriptor.runnerSessionId, + runnerAuthority: descriptor.runnerAuthority, + ...(descriptor.remotePath === undefined ? {} : { remotePath: descriptor.remotePath }), + }; + return encoded; +}; + +const descriptorCodec: AppleRecordingDescriptorCodec = Object.freeze({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + version: 1, + encode: encodeAppleRecordingDescriptor, + decode: (body) => decodeAppleRecordingDescriptor(body), +}); + +export function createAppleRecordingEnvelope(params: { + device: DeviceInfo; + owner: RuntimeOwnerRef; + input: ScreenRecordingStartInput; + descriptor: AppleRecordingDescriptor; +}) { + const { device, owner, input, descriptor } = params; + return createDurableResourceEnvelope({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + sessionId: input.sessionId, + device: deviceIdentity(device), + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(descriptorCodec, descriptor), + }); +} + +export async function cleanupAppleRecording( + host: AppleScreenRecordingOperationHost, + device: DeviceInfo, + body: Parameters[0], +): Promise { + const decoded = descriptorCodec.decode(body); + if (decoded.status !== 'decoded' || !descriptorMatchesAppleDevice(device, decoded.descriptor)) { + return { status: 'cleanup-pending', reason: 'manual-recovery-required' }; + } + if (decoded.descriptor.backend === 'simctl') { + return await cleanupSimulator(host, decoded.descriptor.processes); + } + return await cleanupRunner(host, device, decoded.descriptor); +} + +async function cleanupSimulator( + host: AppleScreenRecordingOperationHost, + processes: readonly ManagedProcessIdentity[], +): Promise { + const ownership = await Promise.all( + processes.map(async (marker) => await host.screenRecording.apple.inspectProcess(marker)), + ); + if (ownership.includes('ownership-lost')) { + return { status: 'cleanup-pending', reason: 'ownership-fence-lost' }; + } + if (ownership.every((value) => value === 'missing')) return { status: 'already-missing' }; + const outcomes = await Promise.all( + processes.flatMap((marker, index) => + ownership[index] === 'owned-alive' + ? [host.screenRecording.apple.terminateProcess(marker)] + : [], + ), + ); + return outcomes.includes('ownership-lost') + ? { status: 'cleanup-pending', reason: 'ownership-fence-lost' } + : outcomes.every((outcome) => outcome === 'already-missing') + ? { status: 'already-missing' } + : { status: 'cleaned' }; +} + +async function cleanupRunner( + host: AppleScreenRecordingOperationHost, + device: DeviceInfo, + descriptor: Extract, +): Promise { + try { + const ownership = await host.screenRecording.apple.inspectRunner( + device, + descriptor.runnerSessionId, + descriptor.runnerAuthority, + ); + if (ownership === 'missing') return { status: 'already-missing' }; + if (ownership === 'ownership-lost') { + return { status: 'cleanup-pending', reason: 'ownership-fence-lost' }; + } + await host.screenRecording.apple.runRunner(device, { + kind: 'stop', + appBundleId: descriptor.appBundleId, + runnerSessionId: descriptor.runnerSessionId, + runnerAuthority: descriptor.runnerAuthority, + }); + return { status: 'cleaned' }; + } catch (error) { + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: error instanceof Error ? error.message : 'Apple recording cleanup failed', + }; + } +} + +export async function reattachAppleRecording( + host: AppleScreenRecordingOperationHost, + device: DeviceInfo, + body: Parameters[0], +) { + const decoded = descriptorCodec.decode(body); + if (decoded.status !== 'decoded') { + return { + status: 'unreattachable' as const, + reason: 'descriptor-invalid' as const, + message: decoded.message, + }; + } + if (!descriptorMatchesAppleDevice(device, decoded.descriptor)) { + return { + status: 'unreattachable' as const, + reason: 'descriptor-invalid' as const, + message: 'Apple screen-recording descriptor does not match the bound device.', + }; + } + const ownership = + decoded.descriptor.backend === 'simctl' + ? await Promise.all( + decoded.descriptor.processes.map( + async (marker) => await host.screenRecording.apple.inspectProcess(marker), + ), + ) + : [ + await host.screenRecording.apple.inspectRunner( + device, + decoded.descriptor.runnerSessionId, + decoded.descriptor.runnerAuthority, + ), + ]; + if (ownership.every((value) => value === 'missing')) return { status: 'missing' as const }; + return { + status: 'unreattachable' as const, + reason: 'transport-not-reattachable' as const, + message: ownership.includes('ownership-lost') + ? 'Apple recording ownership no longer matches the durable descriptor.' + : 'Apple screen recordings require exact cleanup after daemon restart.', + }; +} + +function decodeAppleRecordingDescriptor( + body: Parameters[0], +) { + if (typeof body.outputPath !== 'string' || body.outputPath.length === 0) + return invalidDescriptor(); + if (body.backend === 'simctl') return decodeSimulatorDescriptor(body, body.outputPath); + if (body.backend === 'runner') return decodeRunnerDescriptor(body, body.outputPath); + return invalidDescriptor(); +} + +function decodeSimulatorDescriptor(body: Record, outputPath: string) { + const processes = decodeProcessIdentities(body.processes); + return processes + ? ({ + status: 'decoded', + descriptor: Object.freeze({ backend: 'simctl', outputPath, processes }), + } as const) + : invalidDescriptor(); +} + +function decodeRunnerDescriptor(body: Record, outputPath: string) { + if (!isNonemptyString(body.appBundleId)) return invalidDescriptor(); + if (!isNonemptyString(body.runnerSessionId)) return invalidDescriptor(); + if (!isRunnerAuthority(body.runnerAuthority)) return invalidDescriptor(); + if (!isOptionalCanonicalRemotePath(body.remotePath)) return invalidDescriptor(); + return { + status: 'decoded', + descriptor: Object.freeze({ + backend: 'runner', + outputPath, + appBundleId: body.appBundleId, + runnerSessionId: body.runnerSessionId, + runnerAuthority: body.runnerAuthority, + ...(body.remotePath === undefined ? {} : { remotePath: body.remotePath }), + }), + } as const; +} + +function isNonemptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isRunnerAuthority(value: unknown): value is 'local-lease' | 'scoped-provider' { + return value === 'local-lease' || value === 'scoped-provider'; +} + +function isOptionalCanonicalRemotePath(value: unknown): value is string | undefined { + return value === undefined || isCanonicalRunnerRemotePath(value); +} + +function isCanonicalRunnerRemotePath(value: unknown): value is string { + return typeof value === 'string' && /^tmp\/agent-device-recording-\d+\.mp4$/.test(value); +} + +function descriptorMatchesAppleDevice( + device: DeviceInfo, + descriptor: AppleRecordingDescriptor, +): boolean { + if (device.kind === 'simulator') return descriptor.backend === 'simctl'; + if (descriptor.backend !== 'runner') return false; + if (device.appleOs === 'macos') return descriptor.remotePath === undefined; + return isIosFamily(device) + ? descriptor.remotePath !== undefined && isCanonicalRunnerRemotePath(descriptor.remotePath) + : descriptor.remotePath === undefined; +} + +function decodeProcessIdentities(value: unknown): readonly ManagedProcessIdentity[] | undefined { + if (!Array.isArray(value) || value.length === 0) return undefined; + const processes = value.filter( + (candidate): candidate is ManagedProcessIdentity => + typeof candidate === 'object' && + candidate !== null && + Number.isInteger((candidate as { pid?: unknown }).pid) && + ((candidate as { pid: number }).pid ?? 0) > 0 && + typeof (candidate as { startTime?: unknown }).startTime === 'string' && + (candidate as { startTime: string }).startTime.length > 0 && + typeof (candidate as { command?: unknown }).command === 'string' && + (candidate as { command: string }).command.length > 0, + ); + return processes.length === value.length ? Object.freeze([...processes]) : undefined; +} + +function invalidDescriptor() { + return { status: 'invalid', message: 'Invalid Apple screen-recording descriptor' } as const; +} diff --git a/packages/platform-apple/src/recording/runtime.fixtures.ts b/packages/platform-apple/src/recording/runtime.fixtures.ts new file mode 100644 index 0000000000..c9ee2cbba4 --- /dev/null +++ b/packages/platform-apple/src/recording/runtime.fixtures.ts @@ -0,0 +1,94 @@ +import type { + AppleScreenRecordingRunnerRequest, + ScreenRecordingFinalizer, + ScreenRecordingRuntimeHost, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppleScreenRecordingOperationHost } from './recovery.ts'; + +export const coreDevice = Object.freeze({ + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'ios-device', + name: 'iPhone', + kind: 'device' as const, + target: 'mobile' as const, + iosPhysicalDeviceBackend: 'coredevice' as const, + booted: true, +}); + +export const simulator = Object.freeze({ + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'sim', + name: 'Simulator', + kind: 'simulator' as const, + target: 'mobile' as const, + booted: true, +}); + +export const runnerOwnership = Object.freeze({ + runnerSessionId: 'runner-session', + runnerAuthority: 'local-lease' as const, +}); + +export const coreDeviceRunnerStart = Object.freeze({ + ...runnerOwnership, + remotePath: 'tmp/agent-device-recording-123.mp4', +}); + +export const processIdentity = Object.freeze({ + pid: 42, + startTime: 'start-time', + command: 'xcrun simctl io sim recordVideo /tmp/capture.mp4', +}); + +export function recordingInput( + overrides: Partial = {}, +): ScreenRecordingStartInput { + return { + sessionId: 'one', + outputPath: '/tmp/capture.mp4', + scope: 'device', + showTouches: false, + hideTouchesRequested: false, + recordOnlySession: false, + activeSessionApp: { bundleId: 'com.example.app' }, + fence: { token: 'fence', generation: 1 }, + ...overrides, + }; +} + +export function appleRecordingHost( + options: { + apple?: Partial; + complete?: ScreenRecordingFinalizer['complete']; + prepare?: ScreenRecordingRuntimeHost['outputs']['prepare']; + } = {}, +): AppleScreenRecordingOperationHost { + const apple = Object.assign( + { + availability: async () => ({ available: true }) as const, + runRunner: async (_device: DeviceInfo, request: AppleScreenRecordingRunnerRequest) => + request.kind === 'start' ? coreDeviceRunnerStart : {}, + startSimulator: async () => { + throw new Error('unused'); + }, + inspectProcess: async () => 'owned-alive' as const, + terminateProcess: async () => 'terminated' as const, + inspectRunner: async () => 'owned-alive' as const, + retrieveRunnerRecording: async () => {}, + captureClockAnchor: async () => undefined, + isRunnerBundleId: async () => false, + }, + options.apple, + ); + return { + screenRecording: { + apple, + outputs: { prepare: options.prepare ?? (async () => {}) }, + finalize: { complete: options.complete ?? (async () => ({})) }, + }, + }; +} diff --git a/packages/platform-apple/src/recording/runtime.test.ts b/packages/platform-apple/src/recording/runtime.test.ts new file mode 100644 index 0000000000..29a3dbcbcd --- /dev/null +++ b/packages/platform-apple/src/recording/runtime.test.ts @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import { expect, test, vi } from 'vitest'; +import type { AppleScreenRecordingRunnerRequest } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createAppleScreenRecordingOperations, appleScreenRecordingFacts } from './runtime.ts'; +import { + appleRecordingHost as appleHost, + coreDevice, + coreDeviceRunnerStart, + processIdentity, + recordingInput as input, + runnerOwnership, + simulator, +} from './runtime.fixtures.ts'; + +test('uses the closed Apple runner and finalizer for a CoreDevice recording', async () => { + const calls: string[] = []; + const operations = createAppleScreenRecordingOperations({ + host: appleHost({ + apple: { + availability: async () => ({ available: true }), + runRunner: async (_device: DeviceInfo, request: AppleScreenRecordingRunnerRequest) => { + calls.push(request.kind); + return { + ...coreDeviceRunnerStart, + recorderStartUptimeMs: 10, + targetAppReadyUptimeMs: 12, + }; + }, + retrieveRunnerRecording: async (_device, remotePath, outputPath) => { + calls.push(`retrieve:${remotePath}:${outputPath}`); + }, + startSimulator: async () => { + throw new Error('unused'); + }, + }, + complete: async ({ targetLabel }) => { + calls.push(`finalize:${targetLabel}`); + return { telemetryPath: '/tmp/capture.gesture-telemetry.json' }; + }, + }), + device: coreDevice, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart({ + sessionId: 'one', + outputPath: '/tmp/capture.mp4', + scope: 'app', + showTouches: true, + hideTouchesRequested: false, + recordOnlySession: false, + activeSessionApp: { bundleId: 'com.example.app' }, + fence: { token: 'fence', generation: 1 }, + }); + const outcome = await started.pendingHandle.transfer().finish(); + assert.equal(outcome.status, 'completed'); + if (outcome.status === 'completed') { + assert.equal(outcome.result.telemetryPath, '/tmp/capture.gesture-telemetry.json'); + } + assert.deepEqual(calls, [ + 'start', + 'stop', + 'retrieve:tmp/agent-device-recording-123.mp4:/tmp/capture.mp4', + 'finalize:iOS recording', + ]); +}); + +test('declares the exact XCTest backend failure before exposing operations', () => { + const fact = appleScreenRecordingFacts({ ...coreDevice, iosPhysicalDeviceBackend: 'xctest' }); + assert.deepEqual(fact, { + available: false, + reason: 'unsupported-device-backend', + hint: 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.', + }); +}); + +test('uses simctl on simulators and retains the macOS runner path', async () => { + const calls: string[] = []; + const host = appleHost({ + apple: { + captureClockAnchor: async () => ({ wallClockAtMs: 100, uptimeMs: 50 }), + startSimulator: async () => ({ + markers: [processIdentity], + terminate: async () => { + calls.push('simctl-stop'); + }, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }), + runRunner: async (_device: DeviceInfo, request: AppleScreenRecordingRunnerRequest) => { + calls.push(`runner:${request.kind}`); + return request.kind === 'start' ? runnerOwnership : {}; + }, + }, + complete: async ({ targetLabel }) => { + calls.push(`finalize:${targetLabel}`); + return {}; + }, + }); + for (const runtimeDevice of [ + { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'sim', + name: 'Simulator', + kind: 'simulator' as const, + target: 'mobile' as const, + booted: true, + }, + { + platform: 'apple' as const, + appleOs: 'macos' as const, + id: 'mac', + name: 'Mac', + kind: 'device' as const, + target: 'desktop' as const, + booted: true, + }, + ]) { + const operations = createAppleScreenRecordingOperations({ + host, + device: runtimeDevice, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart({ + sessionId: runtimeDevice.id, + outputPath: `/tmp/${runtimeDevice.id}.mp4`, + scope: 'device', + showTouches: false, + hideTouchesRequested: false, + recordOnlySession: false, + activeSessionApp: { bundleId: 'com.example.app' }, + fence: { token: runtimeDevice.id, generation: 1 }, + }); + const handle = started.pendingHandle.transfer(); + if (runtimeDevice.kind === 'simulator') { + expect(handle.inspect()).toMatchObject({ + gestureClockOriginAtMs: 100, + gestureClockOriginUptimeMs: 50, + }); + } + await handle.finish(); + } + expect(calls).toEqual([ + 'simctl-stop', + 'finalize:iOS recording', + 'runner:start', + 'runner:stop', + 'finalize:macOS recording', + ]); +}); + +test('simulator cleanup waits for confirmed process exit and nonzero finish fails', async () => { + let settleWait: + | ((result: { stdout: string; stderr: string; exitCode: number }) => void) + | undefined; + const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + settleWait = resolve; + }); + const operations = createAppleScreenRecordingOperations({ + host: appleHost({ + apple: { + startSimulator: async () => ({ + markers: [processIdentity], + wait, + terminate: async () => {}, + }), + runRunner: async (_device, request) => (request.kind === 'start' ? runnerOwnership : {}), + }, + }), + device: simulator, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(input()); + const handle = started.pendingHandle.transfer(); + let settled = false; + const cleanup = handle.forceCleanup().then((outcome) => { + settled = true; + return outcome; + }); + await Promise.resolve(); + expect(settled).toBe(false); + settleWait?.({ stdout: '', stderr: 'recording failed', exitCode: 1 }); + await expect(cleanup).resolves.toEqual({ status: 'cleaned' }); + + const second = await operations.screenRecordingStart(input()); + await expect(second.pendingHandle.transfer().finish()).rejects.toThrow( + 'simctl recordVideo exited with code 1', + ); +}); + +test('runner cancellation after acquisition stops the recorder and preserves the exact reason', async () => { + const controller = new AbortController(); + const reason = new Error('cancel runner acquisition'); + const calls: string[] = []; + const operations = createAppleScreenRecordingOperations({ + host: appleHost({ + apple: { + startSimulator: async () => { + throw new Error('unused'); + }, + runRunner: async (_device, request) => { + calls.push(request.kind); + if (request.kind === 'start') controller.abort(reason); + return request.kind === 'start' ? coreDeviceRunnerStart : {}; + }, + }, + }), + device: coreDevice, + owner: localRuntimeOwner('apple'), + signal: controller.signal, + }); + + await expect(operations.screenRecordingStart(input({ scope: 'app' }))).rejects.toBe(reason); + expect(calls).toEqual(['start', 'stop']); +}); + +test('rejects invalid simulator app scope before output or process acquisition', async () => { + const prepare = vi.fn(async () => {}); + const startSimulator = vi.fn(async () => { + throw new Error('must not start'); + }); + const operations = createAppleScreenRecordingOperations({ + host: appleHost({ + apple: { startSimulator }, + prepare, + }), + device: simulator, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + + await expect( + operations.screenRecordingStart(input({ scope: 'app', activeSessionApp: undefined })), + ).rejects.toThrow('open the app under test before recording'); + expect(prepare).not.toHaveBeenCalled(); + expect(startSimulator).not.toHaveBeenCalled(); +}); + +test('compensates a runner finalizer failure without stopping the runner twice', async () => { + const stop = vi.fn(async () => ({})); + const operations = createAppleScreenRecordingOperations({ + host: appleHost({ + apple: { + runRunner: async (_device, request) => + request.kind === 'start' ? coreDeviceRunnerStart : await stop(), + }, + complete: async () => { + throw new Error('finalizer failed after runner stop'); + }, + }), + device: coreDevice, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(input({ scope: 'app' })); + const handle = started.pendingHandle.transfer(); + + await expect(handle.finish()).rejects.toThrow('finalizer failed after runner stop'); + await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); + expect(stop).toHaveBeenCalledOnce(); +}); diff --git a/packages/platform-apple/src/recording/runtime.ts b/packages/platform-apple/src/recording/runtime.ts new file mode 100644 index 0000000000..d1a5c2438d --- /dev/null +++ b/packages/platform-apple/src/recording/runtime.ts @@ -0,0 +1,290 @@ +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import type { + CleanupOutcome, + RuntimeOwnerRef, + ScreenRecordingRuntimeHost, + ScreenRecordingLiveSnapshot, + ScreenRecordingRuntimeOperations, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { PendingTransferGuard } from '@agent-device/contracts/platform'; +import { createScreenRecordingLiveHandle } from '@agent-device/capture-kit'; +import { completeAppleRecording as completion } from './completion.ts'; +import { + cleanupAppleRecording, + createAppleRecordingEnvelope, + reattachAppleRecording, + type AppleRecordingDescriptor, + type AppleScreenRecordingOperationHost, +} from './recovery.ts'; +import { validateAppleSimulatorRecording } from './validation.ts'; + +export function appleScreenRecordingFacts(device: DeviceInfo) { + if (device.appleOs === 'watchos') + return unavailable('unsupported-platform-leaf', 'watchOS recording is not supported.'); + if ( + isIosFamily(device) && + device.kind === 'device' && + device.iosPhysicalDeviceBackend === 'xctest' + ) { + return unavailable( + 'unsupported-device-backend', + 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.', + ); + } + return Object.freeze({ available: true } as const); +} + +export function createAppleScreenRecordingOperations(params: { + host: AppleScreenRecordingOperationHost; + device: DeviceInfo; + owner: RuntimeOwnerRef; + signal: AbortSignal; +}): ScreenRecordingRuntimeOperations { + const { host, device, owner, signal } = params; + return Object.freeze({ + screenRecordingStart: async (input) => + await startAppleRecording({ host, device, owner, input, signal }), + screenRecordingReattach: async (input) => + await reattachAppleRecording(host, device, input.envelope.descriptor.body), + screenRecordingCleanup: async (input) => + await cleanupAppleRecording(host, device, input.envelope.descriptor.body), + } satisfies ScreenRecordingRuntimeOperations); +} + +type AppleRecordingStartParams = Readonly<{ + host: AppleScreenRecordingOperationHost; + device: DeviceInfo; + owner: RuntimeOwnerRef; + input: ScreenRecordingStartInput; + signal: AbortSignal; +}>; + +async function startAppleRecording(params: AppleRecordingStartParams) { + params.signal.throwIfAborted(); + return params.device.kind === 'simulator' + ? await startAppleSimulatorRecording(params) + : await startAppleRunnerRecording(params); +} + +async function startAppleSimulatorRecording(params: AppleRecordingStartParams) { + const { host, device, owner, input, signal } = params; + await validateAppleSimulatorRecording(device, input, host.screenRecording.apple.isRunnerBundleId); + const clockAnchor = input.activeSessionApp + ? await host.screenRecording.apple.captureClockAnchor( + device, + input.activeSessionApp.bundleId, + signal, + ) + : undefined; + await host.screenRecording.outputs.prepare(input.outputPath); + const nativeProcess = await host.screenRecording.apple.startSimulator( + device, + input.outputPath, + signal, + ); + const processes = nativeProcess.markers; + if (!processes || processes.length === 0) { + await settleAppleSimulatorProcess(nativeProcess).catch(() => {}); + throw new Error('simctl recordVideo did not expose durable process identity'); + } + try { + signal.throwIfAborted(); + } catch (error) { + await settleAppleSimulatorProcess(nativeProcess).catch(() => {}); + throw error; + } + return startResult({ + device, + owner, + input, + descriptor: { backend: 'simctl', outputPath: input.outputPath, processes }, + snapshot: snapshot(input, 'simctl recordVideo', {}, clockAnchor), + finish: async (current) => { + await nativeProcess.terminate(); + const result = await nativeProcess.wait; + if (result.exitCode !== 0) { + throw new Error(`simctl recordVideo exited with code ${result.exitCode}`); + } + return await completion(host, current, 'iOS recording'); + }, + cleanup: async () => await cleanupAppleSimulatorProcess(nativeProcess), + }); +} + +async function startAppleRunnerRecording(params: AppleRecordingStartParams) { + const { host, device, owner, input, signal } = params; + const appBundleId = input.activeSessionApp?.bundleId; + if (!appBundleId) { + throw new TypeError('Apple runner recording requires an active app session identity'); + } + await host.screenRecording.outputs.prepare(input.outputPath); + const result = await host.screenRecording.apple.runRunner( + device, + { + kind: 'start', + appBundleId, + outputPath: input.outputPath, + ...(input.fps === undefined ? {} : { fps: input.fps }), + }, + signal, + ); + if (!result.runnerSessionId || !result.runnerAuthority) { + throw new Error('Apple runner recording did not expose durable session ownership'); + } + const runnerOwnership = { + runnerSessionId: result.runnerSessionId, + runnerAuthority: result.runnerAuthority, + } as const; + let runnerStop: Promise | undefined; + const stopRunner = () => + (runnerStop ??= host.screenRecording.apple + .runRunner(device, { + kind: 'stop', + appBundleId, + ...runnerOwnership, + }) + .then(() => undefined)); + if (!runnerDescriptorMatchesDevice(device, result.remotePath)) { + await stopRunner().catch(() => {}); + throw new Error('Apple runner recording did not expose coherent durable media ownership'); + } + try { + signal.throwIfAborted(); + } catch (error) { + await stopRunner().catch(() => {}); + throw error; + } + return startResult({ + device, + owner, + input, + descriptor: { + backend: 'runner', + outputPath: input.outputPath, + appBundleId, + ...runnerOwnership, + ...(result.remotePath === undefined ? {} : { remotePath: result.remotePath }), + }, + snapshot: snapshot(input, 'runner AVAssetWriter', result), + finish: async (current) => { + await stopRunner(); + if (result.remotePath !== undefined) { + await host.screenRecording.apple.retrieveRunnerRecording( + device, + result.remotePath, + current.outPath, + ); + } + return await completion( + host, + current, + device.appleOs === 'macos' ? 'macOS recording' : 'iOS recording', + ); + }, + cleanup: async () => { + await stopRunner(); + return { status: 'cleaned' } as const; + }, + }); +} + +function runnerDescriptorMatchesDevice( + device: DeviceInfo, + remotePath: string | undefined, +): boolean { + if (device.appleOs === 'macos') return remotePath === undefined; + if (!isIosFamily(device)) return remotePath === undefined; + return remotePath !== undefined && /^tmp\/agent-device-recording-\d+\.mp4$/.test(remotePath); +} + +async function settleAppleSimulatorProcess( + nativeProcess: Awaited>, +) { + await nativeProcess.terminate(); + return await nativeProcess.wait; +} + +async function cleanupAppleSimulatorProcess( + nativeProcess: Awaited>, +): Promise { + try { + await settleAppleSimulatorProcess(nativeProcess); + return { status: 'cleaned' }; + } catch (error) { + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: error instanceof Error ? error.message : 'Apple simulator cleanup failed', + }; + } +} + +function startResult(params: { + device: DeviceInfo; + owner: RuntimeOwnerRef; + input: ScreenRecordingStartInput; + descriptor: AppleRecordingDescriptor; + snapshot: ScreenRecordingLiveSnapshot; + finish(snapshot: ScreenRecordingLiveSnapshot): ReturnType; + cleanup(): Promise; +}) { + const { device, owner, input, descriptor, snapshot, finish, cleanup } = params; + const handle = createScreenRecordingLiveHandle(snapshot, { + finish, + forceCleanup: cleanup, + }); + return Object.freeze({ + pendingHandle: new PendingTransferGuard(handle), + envelope: createAppleRecordingEnvelope({ device, owner, input, descriptor }), + }); +} + +function snapshot( + input: ScreenRecordingStartInput, + backend: string, + timing: Readonly<{ + recorderStartUptimeMs?: number; + targetAppReadyUptimeMs?: number; + runnerSessionId?: string; + }> = {}, + clockAnchor?: Readonly<{ wallClockAtMs: number; uptimeMs: number }>, +): ScreenRecordingLiveSnapshot { + const startedAt = Date.now(); + return Object.freeze({ + backend, + outPath: input.outputPath, + ...(input.clientOutputPath === undefined ? {} : { clientOutPath: input.clientOutputPath }), + startedAt, + scope: input.scope, + showTouches: input.showTouches, + recordOnlySession: input.recordOnlySession, + ...(input.activeSessionApp === undefined ? {} : { activeSessionApp: input.activeSessionApp }), + ...(input.exportQuality === undefined ? {} : { exportQuality: input.exportQuality }), + gestureEvents: [], + ...(clockAnchor === undefined + ? {} + : { + gestureClockOriginAtMs: clockAnchor.wallClockAtMs, + gestureClockOriginUptimeMs: clockAnchor.uptimeMs, + }), + ...(timing.recorderStartUptimeMs === undefined + ? {} + : { + gestureClockOriginAtMs: startedAt, + gestureClockOriginUptimeMs: timing.recorderStartUptimeMs, + runnerStartedAtUptimeMs: timing.recorderStartUptimeMs, + }), + ...(timing.targetAppReadyUptimeMs === undefined + ? {} + : { targetAppReadyUptimeMs: timing.targetAppReadyUptimeMs }), + ...(timing.runnerSessionId === undefined ? {} : { runnerSessionId: timing.runnerSessionId }), + }); +} + +function unavailable( + reason: 'unsupported-platform-leaf' | 'unsupported-device-backend', + hint: string, +) { + return Object.freeze({ available: false, reason, hint } as const); +} diff --git a/packages/platform-apple/src/recording/validation.test.ts b/packages/platform-apple/src/recording/validation.test.ts new file mode 100644 index 0000000000..fd0b18b3f6 --- /dev/null +++ b/packages/platform-apple/src/recording/validation.test.ts @@ -0,0 +1,19 @@ +import { expect, test, vi } from 'vitest'; +import { recordingInput, simulator } from './runtime.fixtures.ts'; +import { validateAppleSimulatorRecording } from './validation.ts'; + +test('requires an active non-runner app for app-scoped simulator recording', async () => { + const isRunnerBundleId = vi.fn(async () => true); + await expect( + validateAppleSimulatorRecording( + simulator, + recordingInput({ scope: 'app', activeSessionApp: undefined }), + isRunnerBundleId, + ), + ).rejects.toThrow('active app session'); + expect(isRunnerBundleId).not.toHaveBeenCalled(); + + await expect( + validateAppleSimulatorRecording(simulator, recordingInput({ scope: 'app' }), isRunnerBundleId), + ).rejects.toThrow('real application session'); +}); diff --git a/packages/platform-apple/src/recording/validation.ts b/packages/platform-apple/src/recording/validation.ts new file mode 100644 index 0000000000..517450eae7 --- /dev/null +++ b/packages/platform-apple/src/recording/validation.ts @@ -0,0 +1,21 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { ScreenRecordingStartInput } from '@agent-device/contracts/platform'; + +export async function validateAppleSimulatorRecording( + device: DeviceInfo, + input: ScreenRecordingStartInput, + isRunnerBundleId: (bundleId: string) => Promise, +): Promise { + if (device.kind !== 'simulator' || input.scope !== 'app') return; + const bundleId = input.activeSessionApp?.bundleId; + if (!bundleId) { + throw new TypeError( + 'App-scoped recording requires an active app session; open the app under test before recording.', + ); + } + if (await isRunnerBundleId(bundleId)) { + throw new TypeError( + 'App-scoped recording requires a real application session; open the app under test before recording.', + ); + } +} diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts new file mode 100644 index 0000000000..c7effbd83e --- /dev/null +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -0,0 +1,51 @@ +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform'; +import { hostFixture } from './logs/runtime.fixtures.ts'; + +export function platformRuntimeHostFixture(): PlatformRuntimeHost { + return { + ...hostFixture().host, + appLogs: { + readRecent: async () => ({ + path: '/sessions/one/app.log', + exists: false, + text: '', + skippedLines: 0, + }), + readProcessMarker: async () => ({ status: 'missing' }), + }, + networkTransports: { resolve: async () => ({ mode: 'local' }) }, + screenRecording: { + apple: { + availability: async () => ({ available: true }), + runRunner: async () => ({}), + startSimulator: async () => { + throw new Error('unused'); + }, + inspectProcess: async () => 'missing', + terminateProcess: async () => 'already-missing', + inspectRunner: async () => 'missing', + retrieveRunnerRecording: async () => {}, + captureClockAnchor: async () => undefined, + isRunnerBundleId: async () => false, + }, + android: { + resolve: async () => { + throw new Error('unused'); + }, + }, + harmony: { + start: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + stop: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + findMedia: async () => undefined, + stageMedia: async () => false, + stagedFileSize: async () => undefined, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + removeMedia: async () => true, + }, + web: { resolve: async () => undefined }, + outputs: { prepare: async () => {} }, + finalize: { complete: async () => ({}) }, + }, + }; +} diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 8401c401e2..d51e390b93 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; -import type { PlatformRuntimeHost } from '@agent-device/contracts/platform'; import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; import { createApplePlatformRuntime } from './runtime.ts'; +import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; function appleDevice(overrides: Partial = {}): DeviceInfo { return { @@ -45,7 +45,7 @@ test.each([ ['visionOS simulator', leaves.visionos, true, undefined], ['watchOS sentinel', leaves.watchos, false, 'watchOS app logs are not supported'], ])('classifies the %s leaf explicitly', async (_name, device, available, hint) => { - const binding = await createApplePlatformRuntime({} as PlatformRuntimeHost).bind({ + const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ device, intent: { kind: 'ordinary' }, scope: { @@ -62,4 +62,21 @@ test.each([ expect(fact.available).toBe(available); if (!available && hint) expect(fact).toHaveProperty('hint', expect.stringContaining(hint)); } + for (const operation of [ + 'screenRecordingStart', + 'screenRecordingReattach', + 'screenRecordingCleanup', + ] as const) { + expect(facts.operations[operation].available).toBe(available); + } + if (device.iosPhysicalDeviceBackend === 'xctest') { + expect(facts.operations.screenRecordingStart).toMatchObject({ + hint: expect.stringContaining('CoreDevice-backed physical iOS device'), + }); + } + if (device.appleOs === 'watchos') { + expect(facts.operations.screenRecordingStart).toMatchObject({ + hint: 'watchOS recording is not supported.', + }); + } }); diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index ea56011ac9..6d5dcd2423 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -8,6 +8,10 @@ import type { import { localRuntimeOwner } from '@agent-device/contracts/platform'; import { createAppleAppLogRuntime } from './logs/runtime.ts'; import { dumpAppleNetworkTraffic } from './network/runtime.ts'; +import { + appleScreenRecordingFacts, + createAppleScreenRecordingOperations, +} from './recording/runtime.ts'; const owner = localRuntimeOwner('apple'); const available = Object.freeze({ available: true } as const); @@ -19,17 +23,43 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR ownsDevice: (device) => device.platform === 'apple', bind: async (request) => { const logs = await appLogs.bind(request); + const leafRecordingFacts = appleScreenRecordingFacts(request.device); + const hostAvailability = leafRecordingFacts.available + ? await host.screenRecording.apple.availability(request.device) + : undefined; + const recordingFacts = + leafRecordingFacts.available && hostAvailability?.available === false + ? Object.freeze({ + available: false, + reason: 'unsupported-provider-mode' as const, + hint: hostAvailability.hint, + }) + : leafRecordingFacts; return Object.freeze({ device: logs.device, owner, facts: Object.freeze({ device: logs.facts.device, - operations: { ...logs.facts.operations, networkDump: available }, + operations: { + ...logs.facts.operations, + networkDump: available, + screenRecordingStart: recordingFacts, + screenRecordingReattach: recordingFacts, + screenRecordingCleanup: recordingFacts, + }, }), operations: Object.freeze({ ...logs.operations, networkDump: async (input: NetworkDumpInput) => await dumpAppleNetworkTraffic(host, request.device, input, request.scope.signal), + ...(recordingFacts.available + ? createAppleScreenRecordingOperations({ + host, + device: request.device, + owner, + signal: request.scope.signal, + }) + : {}), }), [Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](), }) satisfies DeviceBinding; diff --git a/packages/platform-harmonyos/src/recording/recovery.test.ts b/packages/platform-harmonyos/src/recording/recovery.test.ts new file mode 100644 index 0000000000..0eff6db355 --- /dev/null +++ b/packages/platform-harmonyos/src/recording/recovery.test.ts @@ -0,0 +1,62 @@ +import { expect, test, vi } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createHarmonyScreenRecordingOperations } from './runtime.ts'; +import { + harmonyCommandSuccess, + harmonyDevice, + harmonyRecordingHost, + harmonyRecordingInput, +} from './runtime.fixtures.ts'; + +test('rejects incoherent recovery paths before stop or removal', async () => { + const stop = vi.fn(async () => harmonyCommandSuccess()); + const remove = vi.fn(async () => true); + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyRecordingHost({ stop, remove }), + device: harmonyDevice, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(harmonyRecordingInput()); + stop.mockClear(); + remove.mockClear(); + const corruptEnvelope = { + ...started.envelope, + descriptor: { + ...started.envelope.descriptor, + body: { + ...started.envelope.descriptor.body, + remotePath: '/data/local/tmp/unrelated.mp4', + }, + }, + }; + + await expect( + operations.screenRecordingCleanup({ envelope: corruptEnvelope }), + ).resolves.toMatchObject({ status: 'cleanup-pending' }); + expect(stop).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); +}); + +test('recreated cleanup never stops or removes an unproven recorder', async () => { + const stop = vi.fn(async () => harmonyCommandSuccess()); + const remove = vi.fn(async () => true); + const removeMedia = vi.fn(async () => true); + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyRecordingHost({ stop, remove, removeMedia }), + device: harmonyDevice, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(harmonyRecordingInput()); + stop.mockClear(); + remove.mockClear(); + removeMedia.mockClear(); + + await expect( + operations.screenRecordingCleanup({ envelope: started.envelope }), + ).resolves.toMatchObject({ status: 'cleanup-pending' }); + expect(stop).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(removeMedia).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-harmonyos/src/recording/recovery.ts b/packages/platform-harmonyos/src/recording/recovery.ts new file mode 100644 index 0000000000..13585d551a --- /dev/null +++ b/packages/platform-harmonyos/src/recording/recovery.ts @@ -0,0 +1,78 @@ +import { deviceIdentity, type DeviceInfo } from '@agent-device/kernel/device'; +import type { + CleanupOutcome, + DurableDescriptorCodec, + RuntimeOwnerRef, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { SCREEN_RECORDING_RESOURCE_KIND } from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit'; + +export type HarmonyRecordingDescriptor = Readonly<{ + backend: 'harmony-screen-recorder'; + fileName: string; + remotePath: string; +}>; + +type HarmonyDescriptorCodec = DurableDescriptorCodec< + HarmonyRecordingDescriptor, + typeof SCREEN_RECORDING_RESOURCE_KIND +>; + +const descriptorCodec: HarmonyDescriptorCodec = Object.freeze({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + version: 1, + encode: (descriptor) => ({ ...descriptor }), + decode: (body) => + body.backend === 'harmony-screen-recorder' && + typeof body.fileName === 'string' && + isCanonicalHarmonyRecordingFileName(body.fileName) && + body.remotePath === `/data/local/tmp/${body.fileName}` + ? ({ + status: 'decoded', + descriptor: Object.freeze({ + backend: 'harmony-screen-recorder', + fileName: body.fileName, + remotePath: body.remotePath, + }), + } as const) + : ({ status: 'invalid', message: 'Invalid HarmonyOS screen-recording descriptor' } as const), +}); + +export function createHarmonyRecordingEnvelope(params: { + device: DeviceInfo; + owner: RuntimeOwnerRef; + input: ScreenRecordingStartInput; + descriptor: HarmonyRecordingDescriptor; +}) { + const { device, owner, input, descriptor } = params; + return createDurableResourceEnvelope({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + sessionId: input.sessionId, + device: deviceIdentity(device), + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(descriptorCodec, descriptor), + }); +} + +export function cleanupRecoveredHarmonyRecording( + body: Parameters[0], +): CleanupOutcome { + const decoded = descriptorCodec.decode(body); + return decoded.status === 'decoded' + ? { + status: 'cleanup-pending', + reason: 'manual-recovery-required', + message: + 'HarmonyOS recorder ownership cannot be proven after daemon restart; no recorder was stopped.', + } + : { status: 'cleanup-pending', reason: 'manual-recovery-required' }; +} + +function isCanonicalHarmonyRecordingFileName(value: string): boolean { + return /^agent-device-recording-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.mp4$/i.test( + value, + ); +} diff --git a/packages/platform-harmonyos/src/recording/runtime.fixtures.ts b/packages/platform-harmonyos/src/recording/runtime.fixtures.ts new file mode 100644 index 0000000000..4368414ae0 --- /dev/null +++ b/packages/platform-harmonyos/src/recording/runtime.fixtures.ts @@ -0,0 +1,61 @@ +import type { + ScreenRecordingRuntimeHost, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import type { createHarmonyScreenRecordingOperations } from './runtime.ts'; + +export const harmonyDevice = Object.freeze({ + platform: 'harmonyos' as const, + id: 'harmony-device', + name: 'Harmony', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, +}); + +export function harmonyRecordingInput( + overrides: Partial = {}, +): ScreenRecordingStartInput { + return { + sessionId: 'one', + outputPath: '/tmp/harmony.mp4', + scope: 'device', + showTouches: true, + hideTouchesRequested: false, + recordOnlySession: false, + fence: { token: 'fence', generation: 1 }, + ...overrides, + }; +} + +export function harmonyCommandSuccess() { + return { stdout: 'start ability successfully', stderr: '', exitCode: 0 }; +} + +type HarmonyHost = Parameters[0]['host']; + +export function harmonyRecordingHost( + overrides: Partial & { + complete?: ScreenRecordingRuntimeHost['finalize']['complete']; + prepare?: ScreenRecordingRuntimeHost['outputs']['prepare']; + } = {}, +): HarmonyHost { + const harmony: ScreenRecordingRuntimeHost['harmony'] = { + start: overrides.start ?? (async () => harmonyCommandSuccess()), + stop: overrides.stop ?? (async () => harmonyCommandSuccess()), + findMedia: overrides.findMedia ?? (async () => 'file://media/video'), + stageMedia: overrides.stageMedia ?? (async () => true), + stagedFileSize: overrides.stagedFileSize ?? (async () => 12), + pull: overrides.pull ?? (async () => ({ stdout: '', stderr: '', exitCode: 0 })), + remove: overrides.remove ?? (async () => true), + removeMedia: overrides.removeMedia ?? (async () => true), + }; + return { + screenRecording: { + harmony, + outputs: { prepare: overrides.prepare ?? (async () => {}) }, + finalize: { complete: overrides.complete ?? (async () => ({})) }, + }, + clock: { now: () => 0, sleep: async () => {} }, + }; +} diff --git a/packages/platform-harmonyos/src/recording/runtime.test.ts b/packages/platform-harmonyos/src/recording/runtime.test.ts new file mode 100644 index 0000000000..d652ae4aff --- /dev/null +++ b/packages/platform-harmonyos/src/recording/runtime.test.ts @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createHarmonyScreenRecordingOperations, harmonyScreenRecordingFacts } from './runtime.ts'; +import { + harmonyCommandSuccess as success, + harmonyDevice as device, + harmonyRecordingHost as harmonyHost, + harmonyRecordingInput as input, +} from './runtime.fixtures.ts'; + +test('runs whole-screen capture and finalizes through the closed Harmony host', async () => { + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost(), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart({ + sessionId: 'one', + outputPath: '/tmp/harmony.mp4', + scope: 'device', + showTouches: true, + hideTouchesRequested: false, + recordOnlySession: false, + fence: { token: 'fence', generation: 1 }, + }); + await assert.doesNotReject(async () => await started.pendingHandle.transfer().finish()); +}); + +test('keeps HarmonyOS recording physical-device-only', () => { + assert.deepEqual(harmonyScreenRecordingFacts({ ...device, kind: 'emulator' }), { + available: false, + reason: 'unsupported-device-kind', + hint: 'HarmonyOS recording is supported on physical devices only.', + }); +}); + +test.each([ + [{ scope: 'app' as const }, 'HarmonyOS recording captures the whole physical-device screen'], + [{ fps: 30 }, 'HarmonyOS recordings do not support --fps'], + [{ exportQuality: 'high' as const }, 'HarmonyOS recordings do not support --quality'], + [{ hideTouchesRequested: true }, 'HarmonyOS recordings do not support --hide-touches'], +])( + 'rejects unsupported HarmonyOS recording option %# before starting', + async (override, message) => { + const start = async () => { + throw new Error('must not start'); + }; + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ start }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + await expect( + operations.screenRecordingStart({ + sessionId: 'one', + outputPath: '/tmp/harmony.mp4', + scope: 'device', + showTouches: true, + hideTouchesRequested: false, + recordOnlySession: false, + fence: { token: 'fence', generation: 1 }, + ...override, + }), + ).rejects.toThrow(message); + }, +); + +test('forced cleanup stops the recorder and removes exact media before reporting cleaned', async () => { + const calls: string[] = []; + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ + stop: async () => { + calls.push('stop'); + return success(); + }, + remove: async () => { + calls.push('remove'); + return true; + }, + findMedia: async () => { + calls.push('findMedia'); + return 'file://media/video'; + }, + removeMedia: async () => { + calls.push('removeMedia'); + return true; + }, + }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(input()); + await expect(started.pendingHandle.transfer().forceCleanup()).resolves.toEqual({ + status: 'cleaned', + }); + expect(calls).toEqual(['stop', 'remove', 'findMedia', 'removeMedia']); + + calls.length = 0; + await expect( + operations.screenRecordingCleanup({ envelope: started.envelope }), + ).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'manual-recovery-required', + }); + expect(calls).toEqual([]); +}); + +test('stop failure keeps Harmony cleanup pending', async () => { + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ + stop: async () => ({ stdout: 'ability failed', stderr: '', exitCode: 0 }), + }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(input()); + + await expect(started.pendingHandle.transfer().forceCleanup()).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'transport-failed', + }); +}); + +test.each([ + ['staging artifact', false, true], + ['media artifact', true, false], +] as const)( + 'keeps cleanup pending when %s deletion is not confirmed', + async (_label, removeStaging, removeMedia) => { + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ + remove: async () => removeStaging, + findMedia: async () => 'file://media/video', + removeMedia: async () => removeMedia, + }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(input()); + + await expect(started.pendingHandle.transfer().forceCleanup()).resolves.toMatchObject({ + status: 'cleanup-pending', + reason: 'transport-failed', + }); + }, +); + +test('retries zero-byte Harmony staging and retains media when finalization fails', async () => { + let sizeReads = 0; + const sizes = [0, 0, 12, 12] as const; + const removeMedia = vi.fn(async () => true); + const stageMedia = vi.fn(async () => true); + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ + stageMedia, + stagedFileSize: async () => sizes[sizeReads++] ?? 12, + removeMedia, + complete: async () => { + throw new Error('unplayable output'); + }, + }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(input()); + + await expect(started.pendingHandle.transfer().finish()).rejects.toThrow('unplayable output'); + expect(stageMedia).toHaveBeenCalledTimes(4); + expect(removeMedia).not.toHaveBeenCalled(); +}); + +test('compensates finalizer failure without stopping the native recorder twice', async () => { + const stop = vi.fn(async () => success()); + const removeMedia = vi.fn(async () => true); + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ + stop, + findMedia: async () => 'file://media/video', + stagedFileSize: async () => 12, + removeMedia, + complete: async () => { + throw new Error('finalizer failed after Harmony stop'); + }, + }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(input()); + const handle = started.pendingHandle.transfer(); + + await expect(handle.finish()).rejects.toThrow('finalizer failed after Harmony stop'); + await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); + expect(stop).toHaveBeenCalledOnce(); + expect(removeMedia).toHaveBeenCalledOnce(); +}); + +test('cancellation after Harmony acquisition stops the recorder and preserves its reason', async () => { + const controller = new AbortController(); + const reason = new Error('cancel Harmony acquisition'); + const stop = vi.fn(async () => success()); + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ + start: async () => { + controller.abort(reason); + return success(); + }, + stop, + }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: controller.signal, + }); + + await expect(operations.screenRecordingStart(input())).rejects.toBe(reason); + expect(stop).toHaveBeenCalledOnce(); +}); + +test('Harmony start rejection after cancellation preserves the exact reason', async () => { + const controller = new AbortController(); + const reason = new Error('cancelled during HDC start'); + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ + start: async () => { + controller.abort(reason); + throw new Error('different transport rejection'); + }, + }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: controller.signal, + }); + + await expect(operations.screenRecordingStart(input())).rejects.toBe(reason); +}); + +test('invalid Harmony options leave an existing output untouched', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-harmony-output-')); + const outputPath = path.join(root, 'capture.mp4'); + fs.writeFileSync(outputPath, 'keep me'); + const prepare = vi.fn(async (pathname: string) => fs.rmSync(pathname, { force: true })); + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ prepare }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + + await expect( + operations.screenRecordingStart({ ...input(), outputPath, fps: 30 }), + ).rejects.toThrow('HarmonyOS recordings do not support --fps'); + expect(prepare).not.toHaveBeenCalled(); + expect(fs.readFileSync(outputPath, 'utf8')).toBe('keep me'); +}); diff --git a/packages/platform-harmonyos/src/recording/runtime.ts b/packages/platform-harmonyos/src/recording/runtime.ts new file mode 100644 index 0000000000..5e8a68bc2b --- /dev/null +++ b/packages/platform-harmonyos/src/recording/runtime.ts @@ -0,0 +1,261 @@ +import { randomUUID } from 'node:crypto'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + CleanupOutcome, + PlatformRuntimeHost, + RuntimeOwnerRef, + ScreenRecordingRuntimeHost, + ScreenRecordingLiveSnapshot, + ScreenRecordingRuntimeOperations, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { PendingTransferGuard } from '@agent-device/contracts/platform'; +import { + createScreenRecordingCompletion, + createScreenRecordingLiveHandle, + assertScreenRecordingOptionsSupported, +} from '@agent-device/capture-kit'; +import { + cleanupRecoveredHarmonyRecording, + createHarmonyRecordingEnvelope, + type HarmonyRecordingDescriptor, +} from './recovery.ts'; + +type HarmonyScreenRecordingOperationHost = Readonly<{ + screenRecording: Pick; + clock: PlatformRuntimeHost['clock']; +}>; + +export function harmonyScreenRecordingFacts(device: DeviceInfo) { + return device.kind === 'device' + ? Object.freeze({ available: true } as const) + : Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'HarmonyOS recording is supported on physical devices only.', + } as const); +} + +export function createHarmonyScreenRecordingOperations(params: { + host: HarmonyScreenRecordingOperationHost; + device: DeviceInfo; + owner: RuntimeOwnerRef; + signal: AbortSignal; +}): ScreenRecordingRuntimeOperations { + const { host, device, owner, signal } = params; + return Object.freeze({ + screenRecordingStart: async (input) => + await startHarmonyRecording({ host, device, owner, input, signal }), + screenRecordingReattach: async () => ({ + status: 'unreattachable' as const, + reason: 'transport-not-reattachable' as const, + message: 'HarmonyOS recordings cannot be reattached after daemon restart.', + }), + screenRecordingCleanup: async (input) => + cleanupRecoveredHarmonyRecording(input.envelope.descriptor.body), + } satisfies ScreenRecordingRuntimeOperations); +} + +async function startHarmonyRecording(params: { + host: HarmonyScreenRecordingOperationHost; + device: DeviceInfo; + owner: RuntimeOwnerRef; + input: ScreenRecordingStartInput; + signal: AbortSignal; +}) { + const { host, device, owner, input, signal } = params; + if (input.scope === 'app') { + throw new AppError( + 'INVALID_ARGS', + 'HarmonyOS recording captures the whole physical-device screen; use --scope device or --scope system', + ); + } + assertScreenRecordingOptionsSupported( + input, + { scopes: ['device', 'system'], fps: false, exportQuality: false, hideTouches: false }, + (unsupported) => `HarmonyOS recordings do not support ${unsupported.join(', ')}`, + ); + signal.throwIfAborted(); + await host.screenRecording.outputs.prepare(input.outputPath); + const fileName = `agent-device-recording-${randomUUID()}.mp4`; + const remotePath = `/data/local/tmp/${fileName}`; + const descriptor = { + backend: 'harmony-screen-recorder' as const, + fileName, + remotePath, + }; + let start; + try { + start = await host.screenRecording.harmony.start(device, fileName, signal); + signal.throwIfAborted(); + } catch (error) { + if (signal.aborted) { + await cleanupLiveHarmonyDescriptor(host, device, descriptor).catch(() => {}); + throw signal.reason; + } + throw error; + } + assertHarmonyRecorderCommandSucceeded(start, 'start'); + try { + signal.throwIfAborted(); + } catch (error) { + await cleanupLiveHarmonyDescriptor(host, device, descriptor).catch(() => {}); + throw error; + } + const initial = snapshot(input); + let recorderStopped = false; + const stopRecorder = async () => { + if (recorderStopped) return; + const stop = await host.screenRecording.harmony.stop(device); + assertHarmonyRecorderCommandSucceeded(stop, 'stop'); + recorderStopped = true; + }; + const handle = createScreenRecordingLiveHandle(initial, { + finish: async (current) => + await finishHarmonyRecording(host, device, current, descriptor, stopRecorder), + forceCleanup: async () => + await cleanupLiveHarmonyDescriptor(host, device, descriptor, stopRecorder), + }); + return Object.freeze({ + pendingHandle: new PendingTransferGuard(handle), + envelope: createHarmonyRecordingEnvelope({ device, owner, input, descriptor }), + }); +} + +function snapshot(input: ScreenRecordingStartInput): ScreenRecordingLiveSnapshot { + return Object.freeze({ + backend: 'HarmonyOS ScreenRecorder', + outPath: input.outputPath, + ...(input.clientOutputPath === undefined ? {} : { clientOutPath: input.clientOutputPath }), + startedAt: Date.now(), + scope: input.scope, + showTouches: false, + recordOnlySession: input.recordOnlySession, + ...(input.activeSessionApp === undefined ? {} : { activeSessionApp: input.activeSessionApp }), + gestureEvents: [], + }); +} + +async function finishHarmonyRecording( + host: HarmonyScreenRecordingOperationHost, + device: DeviceInfo, + snapshot: ScreenRecordingLiveSnapshot, + descriptor: HarmonyRecordingDescriptor, + stopRecorder: () => Promise, +) { + await stopRecorder(); + const mediaUri = await host.screenRecording.harmony.findMedia(device, descriptor.fileName); + if (!mediaUri) + throw new Error(`failed to find finalized HarmonyOS recording '${descriptor.fileName}'`); + const staged = await stageHarmonyMedia(host, device, descriptor, mediaUri); + if (!staged) { + throw new Error( + `failed to finalize HarmonyOS recording: ${descriptor.fileName} did not produce a non-empty media file`, + ); + } + let completed = false; + try { + const pulled = await host.screenRecording.harmony.pull(device, { + remotePath: descriptor.remotePath, + outputPath: snapshot.outPath, + }); + if (pulled.exitCode !== 0) throw new Error('failed to retrieve HarmonyOS recording'); + const finalization = await host.screenRecording.finalize.complete({ + outputPath: snapshot.outPath, + showTouches: false, + gestureEvents: snapshot.gestureEvents, + targetLabel: 'HarmonyOS recording', + }); + completed = true; + return createScreenRecordingCompletion(snapshot, finalization, false); + } finally { + await host.screenRecording.harmony.remove(device, descriptor.remotePath).catch(() => {}); + if (completed) { + await host.screenRecording.harmony.removeMedia(device, mediaUri).catch(() => {}); + } + } +} + +async function stageHarmonyMedia( + host: HarmonyScreenRecordingOperationHost, + device: DeviceInfo, + descriptor: HarmonyRecordingDescriptor, + mediaUri: string, +): Promise { + let previousNonzeroSize: number | undefined; + for (let attempt = 0; attempt < 40; attempt += 1) { + await host.screenRecording.harmony.remove(device, descriptor.remotePath).catch(() => {}); + const staged = await host.screenRecording.harmony.stageMedia(device, { + mediaUri, + remotePath: descriptor.remotePath, + }); + if (staged) { + const size = await host.screenRecording.harmony.stagedFileSize(device, descriptor.remotePath); + if (size !== undefined && size > 0 && size === previousNonzeroSize) return true; + previousNonzeroSize = size !== undefined && size > 0 ? size : undefined; + } else { + previousNonzeroSize = undefined; + } + if (attempt < 39) await host.clock.sleep(250); + } + return false; +} + +async function cleanupLiveHarmonyDescriptor( + host: HarmonyScreenRecordingOperationHost, + device: DeviceInfo, + descriptor: HarmonyRecordingDescriptor, + stopRecorder: () => Promise = async () => { + const stop = await host.screenRecording.harmony.stop(device); + assertHarmonyRecorderCommandSucceeded(stop, 'stop'); + }, +): Promise { + const failures = [ + await cleanupFailure(stopRecorder), + await cleanupFailure(async () => { + if (!(await host.screenRecording.harmony.remove(device, descriptor.remotePath))) { + throw new Error('HarmonyOS recording staging artifact removal was not confirmed'); + } + }), + await cleanupFailure(async () => await removeHarmonyMedia(host, device, descriptor.fileName)), + ]; + const failure = failures.find((candidate) => candidate !== undefined); + if (failure !== undefined) { + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: failure instanceof Error ? failure.message : 'HarmonyOS recording cleanup failed', + }; + } + return { status: 'cleaned' }; +} + +async function removeHarmonyMedia( + host: HarmonyScreenRecordingOperationHost, + device: DeviceInfo, + fileName: string, +): Promise { + const mediaUri = await host.screenRecording.harmony.findMedia(device, fileName); + if (mediaUri && !(await host.screenRecording.harmony.removeMedia(device, mediaUri))) { + throw new Error('HarmonyOS recording media removal was not confirmed'); + } +} + +async function cleanupFailure(action: () => Promise): Promise { + try { + await action(); + return undefined; + } catch (error) { + return error; + } +} + +function assertHarmonyRecorderCommandSucceeded( + result: Readonly<{ exitCode: number | null; stdout: string; stderr: string }>, + action: 'start' | 'stop', +): void { + if (result.exitCode === 0 && result.stdout.includes('start ability successfully')) return; + const output = result.stdout.trim() || result.stderr.trim() || `hdc exit ${result.exitCode}`; + throw new Error(`failed to ${action} HarmonyOS screen recording: ${output}`); +} diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index ffaccfc529..0bad99e086 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -32,4 +32,7 @@ test('classifies the HarmonyOS runtime denominator', async () => { reason: 'unsupported-platform-leaf', }); expect(facts.operations.appLogInspect).toEqual({ available: true }); + expect(facts.operations.screenRecordingStart).toEqual({ available: true }); + expect(facts.operations.screenRecordingReattach).toEqual({ available: true }); + expect(facts.operations.screenRecordingCleanup).toEqual({ available: true }); }); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 73411bed44..fe2b752c60 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -6,6 +6,10 @@ import type { } from '@agent-device/contracts/platform'; import { localRuntimeOwner } from '@agent-device/contracts/platform'; import { createHarmonyAppLogRuntime } from './logs/runtime.ts'; +import { + createHarmonyScreenRecordingOperations, + harmonyScreenRecordingFacts, +} from './recording/runtime.ts'; const owner = localRuntimeOwner('harmonyos'); const unavailable = Object.freeze({ @@ -20,14 +24,31 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor ownsDevice: (device) => device.platform === 'harmonyos', bind: async (request) => { const logs = await appLogs.bind(request); + const recordingFacts = harmonyScreenRecordingFacts(request.device); return Object.freeze({ device: logs.device, owner, facts: Object.freeze({ device: logs.facts.device, - operations: { ...logs.facts.operations, networkDump: unavailable }, + operations: { + ...logs.facts.operations, + networkDump: unavailable, + screenRecordingStart: recordingFacts, + screenRecordingReattach: recordingFacts, + screenRecordingCleanup: recordingFacts, + }, + }), + operations: Object.freeze({ + ...logs.operations, + ...(recordingFacts.available + ? createHarmonyScreenRecordingOperations({ + host, + device: request.device, + owner, + signal: request.scope.signal, + }) + : {}), }), - operations: logs.operations, [Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](), }) satisfies DeviceBinding; }, diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index e6c96dfcdb..b2ad8335e4 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Web-family platform runtime metadata and implementations for agent-device.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*" }, diff --git a/packages/platform-web/src/recording/runtime.test.ts b/packages/platform-web/src/recording/runtime.test.ts new file mode 100644 index 0000000000..e04ab86590 --- /dev/null +++ b/packages/platform-web/src/recording/runtime.test.ts @@ -0,0 +1,185 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { expect, test, vi } from 'vitest'; +import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/platform'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { bindWebScreenRecordingRuntime } from './runtime.ts'; + +const device = { + platform: 'web' as const, + id: 'browser', + name: 'Browser', + kind: 'device' as const, + target: 'desktop' as const, + booted: true, +}; + +test.each([ + [{ recordOnlySession: true }, 'record on web requires an active browser session'], + [{ scope: 'device' as const }, 'web recordings do not support --scope'], + [{ fps: 30 }, 'web recordings do not support --fps'], + [{ exportQuality: 'high' as const }, 'web recordings do not support --quality'], + [{ hideTouchesRequested: true }, 'web recordings do not support --hide-touches'], +])( + 'rejects web recording option %# before the browser recorder starts', + async (override, message) => { + let starts = 0; + const recording = await runtime({ + start: async () => { + starts += 1; + }, + stop: async () => {}, + }); + await expect( + recording.operations.screenRecordingStart?.({ ...input(), ...override }), + ).rejects.toThrow(message); + expect(starts).toBe(0); + }, +); + +test('does not report completion when the browser stop or finalizer fails', async () => { + const stopFailure = await runtime({ + start: async () => {}, + stop: async () => { + throw new Error('browser stop failed'); + }, + }); + const started = await stopFailure.operations.screenRecordingStart?.(input()); + if (!started) throw new Error('missing recording operation'); + await expect(started.pendingHandle.transfer().finish()).rejects.toThrow('browser stop failed'); + + const finalizerFailure = await runtime( + { start: async () => {}, stop: async () => {} }, + async () => { + throw new Error('finalizer failed'); + }, + ); + const second = await finalizerFailure.operations.screenRecordingStart?.(input()); + if (!second) throw new Error('missing recording operation'); + await expect(second.pendingHandle.transfer().finish()).rejects.toThrow('finalizer failed'); +}); + +test('rolls back an acquired browser recorder when setup is cancelled', async () => { + const controller = new AbortController(); + const reason = new Error('request cancelled'); + let stops = 0; + const recording = await runtime( + { + start: async () => controller.abort(reason), + stop: async () => { + stops += 1; + }, + }, + undefined, + controller.signal, + ); + + await expect(recording.operations.screenRecordingStart?.(input())).rejects.toBe(reason); + expect(stops).toBe(1); +}); + +test('does not stop a browser recorder that failed before acquisition', async () => { + const startFailure = new Error('browser recorder unavailable'); + const stop = vi.fn(async () => {}); + const recording = await runtime({ + start: async () => { + throw startFailure; + }, + stop, + }); + + await expect(recording.operations.screenRecordingStart?.(input())).rejects.toBe(startFailure); + expect(stop).not.toHaveBeenCalled(); +}); + +test('validates options before destructive output preparation', async () => { + const directory = await mkdtemp(join(tmpdir(), 'agent-device-web-recording-')); + const outputPath = join(directory, 'capture.webm'); + await writeFile(outputPath, 'existing recording'); + let preparations = 0; + const recording = await runtime( + { start: async () => {}, stop: async () => {} }, + undefined, + undefined, + async (path) => { + preparations += 1; + await rm(path, { force: true }); + }, + ); + + try { + await expect( + recording.operations.screenRecordingStart?.({ + ...input(), + outputPath, + scope: 'device', + }), + ).rejects.toThrow('web recordings do not support --scope'); + expect(preparations).toBe(0); + await expect(readFile(outputPath, 'utf8')).resolves.toBe('existing recording'); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('rejects a non-WebM output before preparation or provider start', async () => { + const start = vi.fn(async () => {}); + const prepare = vi.fn(async () => {}); + const recording = await runtime({ start, stop: async () => {} }, undefined, undefined, prepare); + + await expect( + recording.operations.screenRecordingStart?.({ + ...input(), + outputPath: '/tmp/capture.mp4', + }), + ).rejects.toThrow('web recordings require a .webm output path'); + expect(prepare).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); +}); + +test('compensates a finalizer failure without stopping the browser recorder twice', async () => { + const stop = vi.fn(async () => {}); + const recording = await runtime({ start: async () => {}, stop }, async () => { + throw new Error('finalizer failed after browser stop'); + }); + const started = await recording.operations.screenRecordingStart?.(input()); + if (!started) throw new Error('missing recording operation'); + const handle = started.pendingHandle.transfer(); + + await expect(handle.finish()).rejects.toThrow('finalizer failed after browser stop'); + await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); + expect(stop).toHaveBeenCalledOnce(); +}); + +function input() { + return { + sessionId: 'one', + outputPath: '/tmp/capture.webm', + scope: 'app' as const, + showTouches: false, + hideTouchesRequested: false, + recordOnlySession: false, + fence: { token: 'fence', generation: 1 }, + }; +} + +async function runtime( + transport: NonNullable>>, + complete: ScreenRecordingRuntimeHost['finalize']['complete'] = async () => ({}), + signal: AbortSignal = new AbortController().signal, + prepare: ScreenRecordingRuntimeHost['outputs']['prepare'] = async () => {}, +) { + return await bindWebScreenRecordingRuntime({ + host: { + screenRecording: { + web: { resolve: async () => transport }, + finalize: { complete }, + outputs: { prepare }, + }, + }, + device, + owner: localRuntimeOwner('web'), + signal, + }); +} diff --git a/packages/platform-web/src/recording/runtime.ts b/packages/platform-web/src/recording/runtime.ts new file mode 100644 index 0000000000..94605e04ce --- /dev/null +++ b/packages/platform-web/src/recording/runtime.ts @@ -0,0 +1,173 @@ +import { deviceIdentity, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + CleanupOutcome, + RuntimeOwnerRef, + ScreenRecordingRuntimeHost, + ScreenRecordingRuntimeOperations, + ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { + PendingTransferGuard, + SCREEN_RECORDING_RESOURCE_KIND, +} from '@agent-device/contracts/platform'; +import { + assertScreenRecordingOptionsSupported, + createScreenRecordingCompletion, + createDurableResourceEnvelope, + createScreenRecordingLiveHandle, + encodeDurableDescriptor, +} from '@agent-device/capture-kit'; + +type WebScreenRecordingOperationHost = Readonly<{ + screenRecording: Pick; +}>; + +const descriptorCodec = Object.freeze({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + version: 1, + encode: (descriptor: WebRecordingDescriptor) => ({ ...descriptor }), + decode: (body: Record) => + body.backend === 'agent-browser' && typeof body.outputPath === 'string' + ? ({ + status: 'decoded', + descriptor: Object.freeze({ backend: 'agent-browser', outputPath: body.outputPath }), + } as const) + : ({ status: 'invalid', message: 'Invalid web screen-recording descriptor' } as const), +}); + +type WebRecordingDescriptor = Readonly<{ backend: 'agent-browser'; outputPath: string }>; + +export async function bindWebScreenRecordingRuntime(params: { + host: WebScreenRecordingOperationHost; + device: DeviceInfo; + owner: RuntimeOwnerRef; + signal: AbortSignal; +}): Promise<{ + available: boolean; + operations: Partial; +}> { + const { host, device, owner, signal } = params; + const transport = await host.screenRecording.web.resolve(device); + if (!transport) return { available: false, operations: {} }; + return { + available: true, + operations: { + screenRecordingStart: async (input) => + await startWebRecording({ host, transport, device, owner, input, signal }), + screenRecordingReattach: async () => ({ + status: 'unreattachable', + reason: 'transport-not-reattachable', + message: 'Web recordings cannot be reattached after daemon restart.', + }), + screenRecordingCleanup: async () => pendingWebCleanup(), + }, + }; +} + +async function startWebRecording(params: { + host: WebScreenRecordingOperationHost; + transport: NonNullable>>; + device: DeviceInfo; + owner: RuntimeOwnerRef; + input: ScreenRecordingStartInput; + signal: AbortSignal; +}) { + const { host, transport, device, owner, input, signal } = params; + if (input.recordOnlySession) { + throw new AppError( + 'INVALID_ARGS', + 'record on web requires an active browser session; run open --platform web first', + ); + } + if (!input.outputPath.toLowerCase().endsWith('.webm')) { + throw new AppError( + 'INVALID_ARGS', + 'web recordings require a .webm output path; agent-browser records WebM directly', + ); + } + assertScreenRecordingOptionsSupported( + input, + { scopes: ['app'], fps: false, exportQuality: false, hideTouches: false }, + (unsupported) => + `web recordings do not support ${unsupported.join(', ')}; agent-browser records WebM directly`, + ); + signal.throwIfAborted(); + await host.screenRecording.outputs.prepare(input.outputPath); + let acquired = false; + try { + await transport.start(input.outputPath, signal); + acquired = true; + signal.throwIfAborted(); + } catch (error) { + if (acquired) await transport.stop().catch(() => {}); + signal.throwIfAborted(); + throw error; + } + let stopped = false; + const stop = async () => { + if (stopped) return; + await transport.stop(); + stopped = true; + }; + const handle = createScreenRecordingLiveHandle( + { + backend: 'agent-browser', + outPath: input.outputPath, + ...(input.clientOutputPath === undefined ? {} : { clientOutPath: input.clientOutputPath }), + startedAt: Date.now(), + scope: input.scope, + showTouches: false, + recordOnlySession: input.recordOnlySession, + ...(input.activeSessionApp === undefined ? {} : { activeSessionApp: input.activeSessionApp }), + gestureEvents: [], + }, + { + finish: async (snapshot) => { + await stop(); + const finalization = await host.screenRecording.finalize.complete({ + outputPath: snapshot.outPath, + showTouches: false, + gestureEvents: snapshot.gestureEvents, + targetLabel: 'web recording', + }); + return createScreenRecordingCompletion(snapshot, finalization, false); + }, + forceCleanup: async () => { + try { + await stop(); + return { status: 'cleaned' } as const; + } catch (error) { + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: error instanceof Error ? error.message : 'Web recording cleanup failed', + } as const; + } + }, + }, + ); + return Object.freeze({ + pendingHandle: new PendingTransferGuard(handle), + envelope: createDurableResourceEnvelope({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + sessionId: input.sessionId, + device: deviceIdentity(device), + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: encodeDurableDescriptor(descriptorCodec, { + backend: 'agent-browser', + outputPath: input.outputPath, + }), + }), + }); +} + +function pendingWebCleanup(): CleanupOutcome { + return { + status: 'cleanup-pending', + reason: 'manual-recovery-required', + message: 'Web recordings cannot be cleaned after daemon restart.', + }; +} diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index dfce090c55..7ed199d6dd 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { expect, test, vi } from 'vitest'; import type { NetworkProviderDump, PlatformRuntimeHost } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -48,6 +49,49 @@ test('keeps a web transport without dumpNetwork unavailable instead of throwing }); }); +test('binds agent-browser recording only through the focused web transport', async () => { + const calls: string[] = []; + const binding = await createWebPlatformRuntime( + host( + { mode: 'local' }, + { + start: async (outputPath) => { + calls.push(`start:${outputPath}`); + }, + stop: async () => { + calls.push('stop'); + }, + }, + ), + ).bind({ device, intent: { kind: 'ordinary' }, scope: scope() }); + const started = await binding.operations.screenRecordingStart?.({ + sessionId: 'one', + outputPath: '/tmp/recording.webm', + scope: 'app', + showTouches: false, + hideTouchesRequested: false, + recordOnlySession: false, + fence: { token: 'one', generation: 1 }, + }); + assert.ok(started); + await started.pendingHandle.transfer().forceCleanup(); + expect(calls).toEqual(['start:/tmp/recording.webm', 'stop']); + expect(binding.facts.operations.screenRecordingStart).toEqual({ available: true }); +}); + +test('does not advertise recording without the active agent-browser transport', async () => { + const binding = await createWebPlatformRuntime(host({ mode: 'local' })).bind({ + device, + intent: { kind: 'ordinary' }, + scope: scope(), + }); + expect(binding.operations.screenRecordingStart).toBeUndefined(); + expect(binding.facts.operations.screenRecordingStart).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); +}); + function input() { return { sessionId: 'one', @@ -68,6 +112,9 @@ function scope() { function host( transport: Awaited>, + webRecording: Awaited< + ReturnType + > = undefined, ): PlatformRuntimeHost { return { appleTools: { @@ -112,5 +159,38 @@ function host( readProcessMarker: async () => ({ status: 'missing' }), }, networkTransports: { resolve: async () => transport }, + screenRecording: { + outputs: { prepare: async () => {} }, + apple: { + availability: async () => ({ available: true }), + runRunner: async () => ({}), + startSimulator: async () => { + throw new Error('unused'); + }, + inspectProcess: async () => 'missing', + terminateProcess: async () => 'already-missing', + inspectRunner: async () => 'missing', + retrieveRunnerRecording: async () => {}, + captureClockAnchor: async () => undefined, + isRunnerBundleId: async () => false, + }, + android: { + resolve: async () => { + throw new Error('unused'); + }, + }, + harmony: { + start: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + stop: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + findMedia: async () => undefined, + stageMedia: async () => false, + stagedFileSize: async () => undefined, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + removeMedia: async () => true, + }, + web: { resolve: async () => webRecording }, + finalize: { complete: async () => ({}) }, + }, }; } diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 623925d631..31b7a93d50 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -8,6 +8,7 @@ import type { import { localRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { bindWebScreenRecordingRuntime } from './recording/runtime.ts'; const owner = localRuntimeOwner('web'); const available = Object.freeze({ available: true } as const); @@ -15,6 +16,11 @@ const appLogUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', } as const); +const recordingUnavailable = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'record is not supported by this web provider', +} as const); export function createWebPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { return Object.freeze({ @@ -31,7 +37,13 @@ export function createWebPlatformRuntime(host: PlatformRuntimeHost): PlatformRun ); } const transport = await host.networkTransports.resolve(request.device); - return bindWebRuntime(request.device, request.scope.signal, transport); + const recording = await bindWebScreenRecordingRuntime({ + host, + device: request.device, + owner, + signal: request.scope.signal, + }); + return bindWebRuntime(request.device, request.scope.signal, transport, recording); }, shutdown: async () => undefined, }); @@ -41,6 +53,7 @@ function bindWebRuntime( device: DeviceInfo, signal: AbortSignal, transport: Awaited>, + recording: Awaited>, ): DeviceBinding { const networkUnavailable = Object.freeze({ available: false, @@ -48,17 +61,20 @@ function bindWebRuntime( hint: 'network is not supported by this web provider', } as const); const dump = transport.dump; - const operations: DeviceBinding['operations'] = dump - ? { - networkDump: async (input) => { - const result = await dump( - { maxEntries: input.maxEntries, include: input.include }, - signal, - ); - return Object.freeze({ source: 'provider' as const, ...result }); - }, - } - : {}; + const operations: DeviceBinding['operations'] = { + ...(dump + ? { + networkDump: async (input) => { + const result = await dump( + { maxEntries: input.maxEntries, include: input.include }, + signal, + ); + return Object.freeze({ source: 'provider' as const, ...result }); + }, + } + : {}), + ...recording.operations, + }; const facts: RuntimeFacts = Object.freeze({ device: { family: 'web', @@ -73,6 +89,9 @@ function bindWebRuntime( appLogReattach: appLogUnavailable, appLogCleanup: appLogUnavailable, networkDump: transport.dump ? available : networkUnavailable, + screenRecordingStart: recording.available ? available : recordingUnavailable, + screenRecordingReattach: recording.available ? available : recordingUnavailable, + screenRecordingCleanup: recording.available ? available : recordingUnavailable, }, }); return Object.freeze({ diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index ac2278f0b7..59d6cb27aa 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -258,5 +258,42 @@ function unusedHost(): PlatformRuntimeHost { readProcessMarker: async () => ({ status: 'missing' }), }, networkTransports: { resolve: async () => ({ mode: 'local' }) }, + screenRecording: unusedScreenRecordingHost(), + }; +} + +function unusedScreenRecordingHost(): PlatformRuntimeHost['screenRecording'] { + return { + outputs: { prepare: async () => {} }, + apple: { + availability: async () => ({ available: true }), + runRunner: async () => ({}), + startSimulator: async () => { + throw new Error('unused'); + }, + inspectProcess: async () => 'missing', + terminateProcess: async () => 'already-missing', + inspectRunner: async () => 'missing', + retrieveRunnerRecording: async () => {}, + captureClockAnchor: async () => undefined, + isRunnerBundleId: async () => false, + }, + android: { + resolve: async () => { + throw new Error('unused'); + }, + }, + harmony: { + start: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + stop: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + findMedia: async () => undefined, + stageMedia: async () => false, + stagedFileSize: async () => undefined, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + removeMedia: async () => true, + }, + web: { resolve: async () => undefined }, + finalize: { complete: async () => ({}) }, }; } diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 7e7a55fc4d..a1627b8cee 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -40,6 +40,11 @@ export type LimrunPlatformRuntimeOwnerOptions = Readonly<{ }>; const available = Object.freeze({ available: true } as const); +const recordingUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun does not expose an exact-owner screen-recording runtime.', +} as const); export function createLimrunPlatformRuntimeOwner( options: LimrunPlatformRuntimeOwnerOptions, @@ -170,7 +175,7 @@ function bindLimrunAppLogs( : []; return Object.freeze({ source: 'app-log' as const, backend, dump, notes }); }, - } satisfies PlatformRuntimeOperations; + } satisfies DeviceBinding['operations']; return Object.freeze({ device, owner, @@ -215,6 +220,9 @@ function facts(device: DeviceInfo): RuntimeFacts { appLogReattach: available, appLogCleanup: available, networkDump: available, + screenRecordingStart: recordingUnavailable, + screenRecordingReattach: recordingUnavailable, + screenRecordingCleanup: recordingUnavailable, }, }); } diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 3673daf0ae..6b2187f970 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -130,5 +130,38 @@ function host(run: PlatformRuntimeHost['commands']['run']): PlatformRuntimeHost readProcessMarker: async () => ({ status: 'missing' }), }, networkTransports: { resolve: async () => ({ mode: 'local' }) }, + screenRecording: { + outputs: { prepare: async () => {} }, + apple: { + availability: async () => ({ available: true }), + runRunner: async () => ({}), + startSimulator: async () => { + throw new Error('unused'); + }, + inspectProcess: async () => 'missing', + terminateProcess: async () => 'already-missing', + inspectRunner: async () => 'missing', + retrieveRunnerRecording: async () => {}, + captureClockAnchor: async () => undefined, + isRunnerBundleId: async () => false, + }, + android: { + resolve: async () => { + throw new Error('unused'); + }, + }, + harmony: { + start: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + stop: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + findMedia: async () => undefined, + stageMedia: async () => false, + stagedFileSize: async () => undefined, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + removeMedia: async () => true, + }, + web: { resolve: async () => undefined }, + finalize: { complete: async () => ({}) }, + }, }; } diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index b3cdd11b8f..dcfa9fe858 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -19,6 +19,11 @@ const appLogUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', } as const); +const recordingUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'WebDriver provider runtimes do not expose screen recording.', +} as const); export function createWebDriverPlatformRuntimeOwner( options: Readonly<{ @@ -58,6 +63,7 @@ function bindWebDriverPlatformRuntime( const unavailable = createUnavailablePlatformRuntimeBinding(device, owner, { appLog: appLogUnavailable, network: appLogUnavailable, + screenRecording: recordingUnavailable, }); const facts: RuntimeFacts = Object.freeze({ device: unavailable.facts.device, @@ -68,6 +74,9 @@ function bindWebDriverPlatformRuntime( appLogReattach: appLogUnavailable, appLogCleanup: appLogUnavailable, networkDump: available, + screenRecordingStart: recordingUnavailable, + screenRecordingReattach: recordingUnavailable, + screenRecordingCleanup: recordingUnavailable, }, }); const operations: DeviceBinding['operations'] = Object.freeze({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e068b7e98..e63a9d25a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: packages/platform-web: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 3b26f77c93..69cc23468c 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -35,8 +35,8 @@ // composition file; premature implementation loading and forbidden cross-boundary edges fail (R13). // - Over the DEVICES COMMAND CUTOVER: the handler calls the neutral inventory gateway and no // superseded inventory module, import, or identifier remains in production (R13). -// - Over COMMAND-ATOMIC RUNTIME CUTOVERS: retired logs and network routes/admission cannot coexist -// with their operation-fact-derived descriptor and handler paths (R14-R15). +// - Over COMMAND-ATOMIC RUNTIME CUTOVERS: retired logs, network, and record routes/admission cannot +// coexist with their operation-fact-derived descriptor and handler paths (R14-R16). // Only `(root)` is unranked among src/ zones (see `UNRANKED_ZONES` in model.ts): // it holds entrypoints and composition roots. Extracted workspace package zones // are classified separately and held behind R11 instead of the src folder spine. @@ -107,6 +107,13 @@ import { networkRuntimeNarrowingViolations, networkRuntimeRouteViolations, } from './network-runtime-cutover-policy.ts'; +import { + recordLegacyRouteViolations, + recordRuntimeNarrowingViolations, + recordRuntimeRouteViolations, +} from './record-runtime-cutover-policy.ts'; +import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; +import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -219,6 +226,25 @@ function checkNetworkRuntimeCutover(sources: ReadonlyMap): Layer }); } +function checkRecordRuntimeCutover(sources: ReadonlyMap): LayeringViolation[] { + const production = [...sources].map(([file, source]) => ({ path: file, source })); + return [ + ...recordLegacyRouteViolations(production), + ...recordRuntimeDaemonMechanicsViolations(production), + ...recordRuntimeNarrowingViolations(production), + ...recordRuntimeRegistryJoinViolations(production), + ...recordRuntimeRouteViolations(production), + ].map((violation) => { + const separator = violation.indexOf(': '); + return { + rule: 'R16 record-runtime-cutover', + file: separator < 0 ? '(record runtime)' : violation.slice(0, separator), + line: 1, + message: separator < 0 ? violation : violation.slice(separator + 2), + }; + }); +} + function checkBackEdges(edges: readonly ResolvedImportEdge[]): LayeringViolation[] { const seen = new Set(); return edges.flatMap((edge) => { @@ -634,6 +660,7 @@ export function main(): number { ...checkLogsRuntimeCutover(sources), ...checkContractsImplementationAuthority(sources), ...checkNetworkRuntimeCutover(sources), + ...checkRecordRuntimeCutover(sources), ...checkContractsImplementationAuthority(sources), ...checkBackEdges(edges), ...checkTypeInversions(edges), diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 583755c459..d14932d1e5 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -13,8 +13,8 @@ const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly> = { export const DAEMON_MODULARITY_BASELINE = { sessionState: { - writerOwnedFields: 23, - ownerFileClaims: 29, + writerOwnedFields: 22, + ownerFileClaims: 28, }, largestTypeCycle: { zoneMembers: LARGEST_TYPE_CYCLE_ZONE_CEILINGS, diff --git a/scripts/layering/record-runtime-cutover-policy.test.ts b/scripts/layering/record-runtime-cutover-policy.test.ts new file mode 100644 index 0000000000..7f9b14de2c --- /dev/null +++ b/scripts/layering/record-runtime-cutover-policy.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + recordLegacyRouteViolations, + recordRuntimeNarrowingViolations, + recordRuntimeRouteViolations, +} from './record-runtime-cutover-policy.ts'; + +test('R16 catches a planted legacy recording backend, admission, and plugin facet', () => { + assert.deepEqual( + recordLegacyRouteViolations([ + { + path: 'src/daemon/handlers/planted.ts', + source: ` + await resolveRecordingBackendForDevice(device).start(input); + requireCommandSupported('record', device); + requireCommandSupported(PUBLIC_COMMANDS.record, device); + const backend = RECORDING_BACKENDS_BY_TAG.android; + type LegacyTag = RecordingBackendTag; + `, + }, + { + path: 'src/core/command-descriptor/registry.ts', + source: `const commands = [{ name: 'record', capability: { apple: {} } }];`, + }, + { + path: 'src/platforms/apple/plugin.ts', + source: `const plugin = { recording: { resolveBackendTag() {} } } satisfies PlatformPlugin;`, + }, + ]), + [ + 'src/daemon/handlers/planted.ts: legacy recording route resolveRecordingBackendForDevice', + 'src/daemon/handlers/planted.ts: legacy record capability admission requireCommandSupported', + 'src/daemon/handlers/planted.ts: legacy record capability admission requireCommandSupported', + 'src/daemon/handlers/planted.ts: legacy recording backend map RECORDING_BACKENDS_BY_TAG', + 'src/daemon/handlers/planted.ts: legacy recording backend tag RecordingBackendTag', + 'src/core/command-descriptor/registry.ts: record descriptor retains legacy capability admission', + 'src/platforms/apple/plugin.ts: legacy PlatformPlugin recording facet', + ], + ); +}); + +test('R16 excludes trace-only mechanics from the record cutover scan', () => { + assert.deepEqual( + recordLegacyRouteViolations([ + { + path: 'src/daemon/handlers/record-trace.ts', + source: ` + const traceRecording = { startTrace() {}, stopTrace() {} }; + traceRecording.startTrace(); + `, + }, + ]), + [], + ); +}); + +test('R16 preserves the one record-trace gateway while requiring exact recording operation routes', () => { + assert.deepEqual( + recordRuntimeRouteViolations([ + { + path: 'src/daemon/handlers/session.ts', + source: ` + handleRecordTraceCommands(input); + runtime.operations.screenRecordingStart(input); + runtime.operations.screenRecordingReattach(input); + runtime.operations.screenRecordingCleanup(input); + `, + }, + ]), + [], + ); + assert.deepEqual(recordRuntimeRouteViolations([]), [ + '(record runtime): expected one handleRecordTraceCommands route, found 0', + '(record runtime): expected one narrowed screenRecordingStart call, found 0', + '(record runtime): expected one narrowed screenRecordingReattach call, found 0', + '(record runtime): expected one narrowed screenRecordingCleanup call, found 0', + ]); +}); + +test('R16 rejects the retired recording-provider scope while allowing the focused transport', () => { + const violations = recordLegacyRouteViolations([ + { + path: 'src/daemon/request-platform-providers.ts', + source: ` + import type { RecordingProvider } from './recording-provider.ts'; + const recordingProvider = resolveRecordingProvider(); + await withRecordingProvider(undefined, task); + `, + }, + { + path: 'src/platform-runtime-screen-recording-apple-transport.ts', + source: ` + // recordingProvider and withRecordingProvider are retired prose. + const prose = 'src/daemon/recording-provider.ts'; + withAppleSimulatorScreenRecordingTransport(transport, task); + `, + }, + { + path: 'src/daemon/recording-provider.ts', + source: `export const retired = true;`, + }, + { + path: 'src/daemon/dynamic-provider.ts', + source: ` + const providers = { ['recordingProvider']: resolver }; + await import('./recording-provider.ts'); + `, + }, + ]); + assert.equal(violations.length, 8); + assert.match(violations.join('\n'), /retired recording provider module/); + assert.match(violations.join('\n'), /RecordingProvider/); + assert.match(violations.join('\n'), /recordingProvider/); + assert.match(violations.join('\n'), /resolveRecordingProvider/); + assert.match(violations.join('\n'), /withRecordingProvider/); +}); + +test('R16 rejects proof repair for a narrowed screen-recording operation', () => { + const violations = recordRuntimeNarrowingViolations([ + { + path: 'src/daemon/handlers/planted.ts', + source: ` + const widened = runtime as BoundDeviceRuntime; + widened.operations.screenRecordingStart!({}); + widened.operations['screenRecordingReattach']({}); + `, + }, + ]); + assert.equal(violations.length, 3); + assert.match(violations.join('\n'), /type assertion/); + assert.match(violations.join('\n'), /non-null/); + assert.match(violations.join('\n'), /bracketed/); +}); diff --git a/scripts/layering/record-runtime-cutover-policy.ts b/scripts/layering/record-runtime-cutover-policy.ts new file mode 100644 index 0000000000..d46404ffbe --- /dev/null +++ b/scripts/layering/record-runtime-cutover-policy.ts @@ -0,0 +1,335 @@ +import { parseSync } from 'oxc-parser'; +import { + memberName, + propertyName, + type RecordRuntimeProductionSource, + visitAst, +} from './record-runtime-policy-ast.ts'; + +const LEGACY_RECORD_ROUTE_NAMES = new Set([ + 'resolveRecordingBackendForDevice', + 'stopActiveRecording', + 'RecordingBackend', + 'RecordingStartBackend', +]); +const RETIRED_RECORDING_PROVIDER_NAMES = new Set([ + 'RecordingProvider', + 'RecordingProviderResolver', + 'recordingProvider', + 'resolveRecordingProvider', + 'withRecordingProvider', + 'createLocalRecordingProvider', +]); +const SCREEN_RECORDING_OPERATION_NAMES = new Set([ + 'screenRecordingStart', + 'screenRecordingReattach', + 'screenRecordingCleanup', +]); + +/** Record owns one runtime-backed route: legacy tag/backend selection may not survive it. */ +export function recordLegacyRouteViolations( + sources: readonly RecordRuntimeProductionSource[], +): string[] { + const violations: string[] = []; + for (const file of sources) { + if (file.path === 'src/daemon/recording-provider.ts') { + violations.push(`${file.path}: retired recording provider module`); + } + const parsed = parseSync(file.path, file.source); + const seen = new Set(); + visitAst(parsed.program, (node) => { + if (isRetiredRecordingProviderImport(node)) { + pushOnce(violations, seen, node, file, 'retired recording provider module'); + } + if (node.type === 'Identifier' && RETIRED_RECORDING_PROVIDER_NAMES.has(String(node.name))) { + pushOnce( + violations, + seen, + node, + file, + `retired recording provider name ${String(node.name)}`, + ); + } + if (isRetiredRecordingProviderComputedProperty(node)) { + pushOnce( + violations, + seen, + node, + file, + `retired recording provider name ${String(propertyName(node.key))}`, + ); + } + if (isNamedIdentifier(node, 'RECORDING_BACKENDS_BY_TAG')) { + pushOnce( + violations, + seen, + node, + file, + 'legacy recording backend map RECORDING_BACKENDS_BY_TAG', + ); + } + if (isNamedIdentifier(node, 'RecordingBackendTag')) { + pushOnce(violations, seen, node, file, 'legacy recording backend tag RecordingBackendTag'); + } + const route = legacyRecordRouteName(node); + if (route) pushOnce(violations, seen, node, file, `legacy recording route ${route}`); + if (isLegacyRecordAdmission(node)) { + pushOnce( + violations, + seen, + node, + file, + 'legacy record capability admission requireCommandSupported', + ); + } + if (isRecordDescriptorWithCapability(node)) { + pushOnce( + violations, + seen, + node, + file, + 'record descriptor retains legacy capability admission', + ); + } + if (isPlatformPluginRecordingDeclaration(node)) { + pushOnce(violations, seen, node, file, 'legacy PlatformPlugin recording facet'); + } + }); + } + return violations; +} + +function isNamedIdentifier(node: Record, name: string): boolean { + return node.type === 'Identifier' && node.name === name; +} + +/** Daemon consumers must not manufacture proof for screen-recording runtime operations. */ +export function recordRuntimeNarrowingViolations( + sources: readonly RecordRuntimeProductionSource[], +): string[] { + const violations: string[] = []; + for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) { + const parsed = parseSync(file.path, file.source); + visitAst(parsed.program, (node) => { + if (node.type === 'TSAsExpression' && containsRuntimeTypeRepair(node.typeAnnotation)) { + violations.push(`${file.path}: widened screen-recording runtime type assertion`); + } + if ( + node.type === 'TSNonNullExpression' && + isScreenRecordingOperationAccess(node.expression) + ) { + violations.push(`${file.path}: non-null repair of screen-recording operation`); + } + if (isBracketedScreenRecordingOperationAccess(node)) { + violations.push(`${file.path}: bracketed screen-recording operation access`); + } + }); + } + return violations; +} + +/** Record keeps the one existing record-trace gateway; trace mechanics are otherwise out of scope. */ +export function recordRuntimeRouteViolations( + sources: readonly RecordRuntimeProductionSource[], +): string[] { + let recordTraceGatewayCalls = 0; + let startCalls = 0; + let reattachCalls = 0; + let cleanupCalls = 0; + for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) { + const parsed = parseSync(file.path, file.source); + visitAst(parsed.program, (node) => { + if (node.type !== 'CallExpression') return; + const callee = node.callee as Record | undefined; + if (callee?.type === 'Identifier' && callee.name === 'handleRecordTraceCommands') { + recordTraceGatewayCalls += 1; + } + if (callee?.type !== 'MemberExpression' || !isRuntimeScreenRecordingOperation(callee)) return; + switch (memberName(callee)) { + case 'screenRecordingStart': + startCalls += 1; + break; + case 'screenRecordingReattach': + reattachCalls += 1; + break; + case 'screenRecordingCleanup': + cleanupCalls += 1; + break; + } + }); + } + return [ + exactRouteViolation('handleRecordTraceCommands', recordTraceGatewayCalls), + exactOperationViolation('screenRecordingStart', startCalls), + exactOperationViolation('screenRecordingReattach', reattachCalls), + exactOperationViolation('screenRecordingCleanup', cleanupCalls), + ].filter((violation): violation is string => violation !== null); +} + +function isRetiredRecordingProviderImport(node: Record): boolean { + if (node.type !== 'ImportDeclaration' && node.type !== 'ImportExpression') return false; + const source = node.source as Record | undefined; + return ( + source?.type === 'Literal' && + /(?:^|\/)recording-provider(?:\.[cm]?[jt]s)?$/.test(String(source.value)) + ); +} + +function isRetiredRecordingProviderComputedProperty(node: Record): boolean { + return ( + (node.type === 'Property' || node.type === 'TSPropertySignature') && + node.computed === true && + RETIRED_RECORDING_PROVIDER_NAMES.has(propertyName(node.key) ?? '') + ); +} + +function legacyRecordRouteName(node: Record): string | undefined { + if (node.type === 'Identifier' && LEGACY_RECORD_ROUTE_NAMES.has(String(node.name))) { + return String(node.name); + } + if (node.type === 'Property' || node.type === 'TSPropertySignature') { + const name = propertyName(node.key); + return name && LEGACY_RECORD_ROUTE_NAMES.has(name) ? name : undefined; + } + return undefined; +} + +function isLegacyRecordAdmission(node: Record): boolean { + if (node.type !== 'CallExpression') return false; + const callee = node.callee as Record | undefined; + const args = node.arguments as readonly Record[] | undefined; + return ( + callee?.type === 'Identifier' && + callee.name === 'requireCommandSupported' && + isRecordCommandExpression(args?.[0]) + ); +} + +function isRecordCommandExpression(node: Record | undefined): boolean { + if (node?.type === 'Literal') return node.value === 'record'; + if (node?.type !== 'MemberExpression') return false; + const object = node.object as Record | undefined; + return ( + object?.type === 'Identifier' && + object.name === 'PUBLIC_COMMANDS' && + memberName(node) === 'record' + ); +} + +function isRecordDescriptorWithCapability(node: Record): boolean { + if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return false; + let record = false; + let capability = false; + for (const property of node.properties as Record[]) { + if (property.type !== 'Property' || property.computed === true) continue; + const key = propertyName(property.key); + const value = property.value as Record | undefined; + if (key === 'name' && value?.type === 'Literal' && value.value === 'record') record = true; + if (key === 'capability') capability = true; + } + return record && capability; +} + +function isPlatformPluginRecordingDeclaration(node: Record): boolean { + if (node.type === 'TSTypeAliasDeclaration' || node.type === 'TSInterfaceDeclaration') { + const id = node.id as Record | undefined; + return id?.type === 'Identifier' && id.name === 'PlatformPlugin' && astContainsRecording(node); + } + if (node.type === 'TSSatisfiesExpression' || node.type === 'TSAsExpression') { + return ( + isNamedType(node.typeAnnotation, 'PlatformPlugin') && astContainsRecording(node.expression) + ); + } + if (node.type !== 'VariableDeclarator') return false; + const id = node.id as Record | undefined; + return isNamedType(id?.typeAnnotation, 'PlatformPlugin') && astContainsRecording(node.init); +} + +function astContainsRecording(node: unknown): boolean { + let found = false; + visitAst(node, (candidate) => { + if ( + (candidate.type === 'Property' || candidate.type === 'TSPropertySignature') && + candidate.computed !== true && + propertyName(candidate.key) === 'recording' + ) { + found = true; + } + }); + return found; +} + +function containsRuntimeTypeRepair(node: unknown): boolean { + let found = false; + visitAst(node, (candidate) => { + if ( + candidate.type === 'Identifier' && + (candidate.name === 'BoundDeviceRuntime' || + candidate.name === 'ScreenRecordingRuntimeOperations' || + candidate.name === 'ScreenRecordingLiveHandle') + ) { + found = true; + } + }); + return found; +} + +function isScreenRecordingOperationAccess(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const member = node as Record; + return ( + member.type === 'MemberExpression' && + SCREEN_RECORDING_OPERATION_NAMES.has(memberName(member) ?? '') + ); +} + +function isBracketedScreenRecordingOperationAccess(node: Record): boolean { + if (node.type !== 'MemberExpression' || node.computed !== true) return false; + const object = node.object as Record | undefined; + return ( + SCREEN_RECORDING_OPERATION_NAMES.has(memberName(node) ?? '') && + object?.type === 'MemberExpression' && + memberName(object) === 'operations' + ); +} + +function isRuntimeScreenRecordingOperation(node: Record): boolean { + const object = node.object as Record | undefined; + return ( + SCREEN_RECORDING_OPERATION_NAMES.has(memberName(node) ?? '') && + object?.type === 'MemberExpression' && + memberName(object) === 'operations' + ); +} + +function exactRouteViolation(name: string, calls: number): string | null { + return calls === 1 ? null : `(record runtime): expected one ${name} route, found ${calls}`; +} + +function exactOperationViolation(name: string, calls: number): string | null { + return calls === 1 + ? null + : `(record runtime): expected one narrowed ${name} call, found ${calls}`; +} + +function pushOnce( + violations: string[], + seen: Set, + node: Record, + file: RecordRuntimeProductionSource, + message: string, +): void { + const identity = `${String(node.start ?? '')}:${message}`; + if (seen.has(identity)) return; + seen.add(identity); + violations.push(`${file.path}: ${message}`); +} + +function isNamedType(node: unknown, expected: string): boolean { + if (node === null || typeof node !== 'object') return false; + const record = node as Record; + if (record.type === 'TSTypeAnnotation') return isNamedType(record.typeAnnotation, expected); + if (record.type !== 'TSTypeReference') return false; + const name = record.typeName as Record | undefined; + return name?.type === 'Identifier' && name.name === expected; +} diff --git a/scripts/layering/record-runtime-mechanics-policy.test.ts b/scripts/layering/record-runtime-mechanics-policy.test.ts new file mode 100644 index 0000000000..ef1336844f --- /dev/null +++ b/scripts/layering/record-runtime-mechanics-policy.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; + +test('R16 rejects native mechanics in the daemon record owner without scanning prose', () => { + const violations = recordRuntimeDaemonMechanicsViolations([ + { + path: 'src/daemon/handlers/record-runtime.ts', + source: ` + import fs from 'node:fs'; + import { runCmdBackground } from '../../utils/exec.ts'; + // setTimeout and runCmd are mechanics only when executable. + const prose = 'spawn xcrun'; + setTimeout(() => runCmdBackground('xcrun', []), 1); + `, + }, + { + path: 'packages/platform-apple/src/recording/runtime.ts', + source: `setTimeout(() => runCmdBackground('xcrun', []), 1);`, + }, + ]); + assert.deepEqual(violations, [ + 'src/daemon/handlers/record-runtime.ts: daemon record owner imports native mechanic node:fs', + 'src/daemon/handlers/record-runtime.ts: daemon record owner imports native mechanic ../../utils/exec.ts', + 'src/daemon/handlers/record-runtime.ts: daemon record owner calls native mechanic setTimeout', + 'src/daemon/handlers/record-runtime.ts: daemon record owner calls native mechanic runCmdBackground', + ]); +}); + +test('R16 rejects child termination and platform branching in daemon record owners', () => { + const violations = recordRuntimeDaemonMechanicsViolations([ + { + path: 'src/daemon/handlers/record-runtime.ts', + source: ` + // child.kill(), process.kill(), and device.platform === 'apple' are prose. + const prose = 'switch (device.platform)'; + child.kill('SIGINT'); + process.kill(pid, 'SIGTERM'); + if (device.platform === 'apple') runApple(); + switch (session.device.platform) { + case 'android': runAndroid(); + } + const family = session.device.platform; + if (family !== 'web') runNative(); + resolveRecordingOutputPaths(req, session.device.platform); + `, + }, + ]); + assert.equal(violations.length, 5); + assert.match(violations.join('\n'), /child termination kill/); + assert.match(violations.join('\n'), /platform comparison/); + assert.match(violations.join('\n'), /platform switch/); +}); diff --git a/scripts/layering/record-runtime-mechanics-policy.ts b/scripts/layering/record-runtime-mechanics-policy.ts new file mode 100644 index 0000000000..d0649af84d --- /dev/null +++ b/scripts/layering/record-runtime-mechanics-policy.ts @@ -0,0 +1,116 @@ +import { parseSync } from 'oxc-parser'; +import { + memberName, + type RecordRuntimeProductionSource, + visitAst, +} from './record-runtime-policy-ast.ts'; + +const DAEMON_RECORD_MECHANIC_CALLS = new Set([ + 'runCmd', + 'runCmdBackground', + 'runCmdDetached', + 'runCmdStreaming', + 'runCmdSync', + 'setInterval', + 'setTimeout', + 'spawn', + 'spawnSync', +]); + +/** The daemon record owner coordinates runtime operations; native mechanics stay behind owners. */ +export function recordRuntimeDaemonMechanicsViolations( + sources: readonly RecordRuntimeProductionSource[], +): string[] { + const violations: string[] = []; + for (const file of sources.filter(({ path }) => isDaemonRecordOwner(path))) { + const parsed = parseSync(file.path, file.source); + const platformAliases = collectPlatformAliases(parsed.program); + visitAst(parsed.program, (node) => { + if (node.type === 'ImportDeclaration') { + const source = node.source as Record | undefined; + const imported = source?.type === 'Literal' ? String(source.value) : ''; + if (isDaemonRecordMechanicImport(imported)) { + violations.push(`${file.path}: daemon record owner imports native mechanic ${imported}`); + } + } + if (node.type !== 'CallExpression') return; + const callee = node.callee as Record | undefined; + if (callee?.type === 'Identifier' && DAEMON_RECORD_MECHANIC_CALLS.has(String(callee.name))) { + violations.push( + `${file.path}: daemon record owner calls native mechanic ${String(callee.name)}`, + ); + } + if (callee?.type === 'MemberExpression' && memberName(callee) === 'kill') { + violations.push(`${file.path}: daemon record owner calls child termination kill`); + } + }); + visitAst(parsed.program, (node) => { + if (isPlatformComparison(node, platformAliases)) { + violations.push(`${file.path}: daemon record owner contains platform comparison`); + } + if ( + node.type === 'SwitchStatement' && + isPlatformSelector(node.discriminant, platformAliases) + ) { + violations.push(`${file.path}: daemon record owner contains platform switch`); + } + }); + } + return violations; +} + +function isDaemonRecordOwner(filePath: string): boolean { + return /^src\/daemon\/handlers\/record-runtime(?:-[^/]+)?\.ts$/.test(filePath); +} + +function isDaemonRecordMechanicImport(imported: string): boolean { + return ( + imported === 'node:child_process' || + imported === 'node:fs' || + imported.includes('/platforms/') || + imported.includes('/provider-') || + imported.includes('/utils/exec') || + imported.includes('/recording/overlay') + ); +} + +function isPlatformComparison( + node: Record, + platformAliases: ReadonlySet, +): boolean { + if (node.type !== 'BinaryExpression') return false; + if (!['===', '!==', '==', '!='].includes(String(node.operator))) return false; + return ( + isPlatformSelector(node.left, platformAliases) || + isPlatformSelector(node.right, platformAliases) + ); +} + +function collectPlatformAliases(node: unknown): Set { + const aliases = new Set(); + visitAst(node, (candidate) => { + if (candidate.type !== 'VariableDeclarator' || !isPlatformAccess(candidate.init)) return; + const id = candidate.id as Record | undefined; + if (id?.type === 'Identifier') aliases.add(String(id.name)); + }); + return aliases; +} + +function isPlatformSelector(node: unknown, aliases: ReadonlySet): boolean { + if (isPlatformAccess(node)) return true; + return ( + node !== null && + typeof node === 'object' && + (node as Record).type === 'Identifier' && + aliases.has(String((node as Record).name)) + ); +} + +function isPlatformAccess(node: unknown): boolean { + return ( + node !== null && + typeof node === 'object' && + (node as Record).type === 'MemberExpression' && + memberName(node as Record) === 'platform' + ); +} diff --git a/scripts/layering/record-runtime-policy-ast.ts b/scripts/layering/record-runtime-policy-ast.ts new file mode 100644 index 0000000000..a0818f4fcc --- /dev/null +++ b/scripts/layering/record-runtime-policy-ast.ts @@ -0,0 +1,30 @@ +export type RecordRuntimeProductionSource = Readonly<{ path: string; source: string }>; + +export function memberName(node: Record): string | undefined { + const property = node.property as Record | undefined; + if (!property) return undefined; + return node.computed === true + ? propertyName(property) + : property.type === 'Identifier' + ? String(property.name) + : undefined; +} + +export function propertyName(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const value = node as Record; + return value.type === 'Identifier' || value.type === 'Literal' + ? ((value.name as string | undefined) ?? (value.value as string | undefined)) + : undefined; +} + +export function visitAst(node: unknown, visitor: (node: Record) => void): void { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visitAst(child, visitor); + return; + } + const record = node as Record; + visitor(record); + for (const child of Object.values(record)) visitAst(child, visitor); +} diff --git a/scripts/layering/record-runtime-registry-policy.test.ts b/scripts/layering/record-runtime-registry-policy.test.ts new file mode 100644 index 0000000000..947179abb0 --- /dev/null +++ b/scripts/layering/record-runtime-registry-policy.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts'; + +test('R16 requires the real registry normalization path to assert the record use join', () => { + assert.deepEqual( + recordRuntimeRegistryJoinViolations([ + { + path: 'src/core/command-descriptor/registry.ts', + source: ` + import { assertRecordRuntimeExecution as assertRecord } from '@agent-device/contracts/platform'; + if (descriptor.name === 'record') assertRecord(platformExecution); + `, + }, + ]), + [], + ); + assert.deepEqual( + recordRuntimeRegistryJoinViolations([ + { + path: 'src/core/command-descriptor/registry.ts', + source: ` + // assertRecordRuntimeExecution(platformExecution) is only prose. + const prose = 'record descriptor assertion'; + normalize(platformExecution); + `, + }, + ]), + ['src/core/command-descriptor/registry.ts: missing record runtime descriptor join assertion'], + ); +}); diff --git a/scripts/layering/record-runtime-registry-policy.ts b/scripts/layering/record-runtime-registry-policy.ts new file mode 100644 index 0000000000..13b6b2729f --- /dev/null +++ b/scripts/layering/record-runtime-registry-policy.ts @@ -0,0 +1,103 @@ +import { parseSync } from 'oxc-parser'; +import { + memberName, + type RecordRuntimeProductionSource, + visitAst, +} from './record-runtime-policy-ast.ts'; + +export function recordRuntimeRegistryJoinViolations( + sources: readonly RecordRuntimeProductionSource[], +): string[] { + const file = sources.find(({ path }) => path === 'src/core/command-descriptor/registry.ts'); + if (!file) return [missingRecordRuntimeRegistryJoin()]; + const parsed = parseSync(file.path, file.source); + const assertionName = importedRecordRuntimeAssertionName(parsed.program); + if (!assertionName) return [missingRecordRuntimeRegistryJoin()]; + let joined = false; + visitAst(parsed.program, (node) => { + if ( + node.type === 'IfStatement' && + isRecordDescriptorGuard(node.test) && + containsRecordRuntimeAssertion(node.consequent, assertionName) + ) { + joined = true; + } + }); + return joined ? [] : [missingRecordRuntimeRegistryJoin()]; +} + +function missingRecordRuntimeRegistryJoin(): string { + return 'src/core/command-descriptor/registry.ts: missing record runtime descriptor join assertion'; +} + +function importedRecordRuntimeAssertionName(node: unknown): string | undefined { + let localName: string | undefined; + visitAst(node, (candidate) => { + if (candidate.type !== 'ImportDeclaration') return; + const source = candidate.source as Record | undefined; + if (source?.type !== 'Literal' || source.value !== '@agent-device/contracts/platform') return; + const specifiers = candidate.specifiers as readonly Record[] | undefined; + const assertion = specifiers?.find((specifier) => { + const imported = specifier.imported as Record | undefined; + return imported?.type === 'Identifier' && imported.name === 'assertRecordRuntimeExecution'; + }); + const local = assertion?.local as Record | undefined; + if (local?.type === 'Identifier') localName = String(local.name); + }); + return localName; +} + +function isRecordDescriptorGuard(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const expression = node as Record; + if ( + expression.type !== 'BinaryExpression' || + !['===', '=='].includes(String(expression.operator)) + ) { + return false; + } + return ( + (isDescriptorNameAccess(expression.left) && isRecordLiteral(expression.right)) || + (isDescriptorNameAccess(expression.right) && isRecordLiteral(expression.left)) + ); +} + +function isDescriptorNameAccess(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const member = node as Record; + const object = member.object as Record | undefined; + return ( + member.type === 'MemberExpression' && + memberName(member) === 'name' && + object?.type === 'Identifier' && + object.name === 'descriptor' + ); +} + +function isRecordLiteral(node: unknown): boolean { + return ( + node !== null && + typeof node === 'object' && + (node as Record).type === 'Literal' && + (node as Record).value === 'record' + ); +} + +function containsRecordRuntimeAssertion(node: unknown, assertionName: string): boolean { + let found = false; + visitAst(node, (candidate) => { + if (candidate.type !== 'CallExpression') return; + const callee = candidate.callee as Record | undefined; + const args = candidate.arguments as readonly Record[] | undefined; + if ( + callee?.type === 'Identifier' && + callee.name === assertionName && + args?.length === 1 && + args[0]?.type === 'Identifier' && + args[0].name === 'platformExecution' + ) { + found = true; + } + }); + return found; +} diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index c453f1efb6..db3892ed94 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -73,8 +73,7 @@ export const SESSION_STATE_FIELD_OWNERS: Readonly = new Set([ 'device', 'name', 'recordOnlySession', + 'screenRecording', 'sessionScope', 'snapshotDiagnostics', 'surface', diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts index 6952f9f828..a6f486bded 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts @@ -73,7 +73,6 @@ const SUPPORTS_REF: Record boolean> = { reinstall: supportsAppInstallation, 'install-from-source': supportsAppInstallation, perf: supportsCoreDevicePhysicalOperation, - record: supportsCoreDevicePhysicalOperation, push: isNotMacOs, home: isNotMacOs, 'app-switcher': isNotMacOs, @@ -105,7 +104,6 @@ const HINT_REF: Record string | undefined> = { reinstall: coreDeviceOnlyPhysicalOperationHint, 'install-from-source': coreDeviceOnlyPhysicalOperationHint, perf: coreDeviceOnlyPhysicalOperationHint, - record: coreDeviceOnlyPhysicalOperationHint, 'tv-remote': (device) => { if (device.platform === 'android') { return device.target === 'tv' diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 98635ae0db..545ec8ac1d 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -11,6 +11,7 @@ import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { Interactor } from '@agent-device/contracts/interaction'; import type { SimulatorLease } from '../daemon/lease-registry.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppleRunnerScreenRecordingTransport } from '../platform-runtime-screen-recording-apple-runner-transport.ts'; afterEach(() => { setActiveProviderDeviceRuntimes([]); @@ -53,6 +54,48 @@ test('provider device runtime registry rejects duplicate provider owners', () => ); }); +test('provider device runtime composition exposes focused runner recording authority only for its exact device', () => { + const device: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + kind: 'device', + target: 'desktop', + id: 'provider:macos:lease-a', + name: 'Provider Mac', + booted: true, + }; + const transport: AppleRunnerScreenRecordingTransport = Object.freeze({ + authority: 'scoped-provider', + available: true, + start: async () => ({ runnerSessionId: 'external-session-1' }), + inspect: async (_device, runnerSessionId) => + runnerSessionId === 'external-session-1' ? 'owned-alive' : 'ownership-lost', + stop: async () => undefined, + }); + const runtime = { + ...makeRuntime({ + provider: 'mac-provider', + leaseResult: undefined, + devices: [device], + interactor: undefined, + installResult: undefined, + portReverseResult: undefined, + }), + getAppleRunnerScreenRecordingTransport: (candidate: DeviceInfo) => + candidate.id === device.id ? transport : undefined, + }; + const resolver = createProviderDeviceRuntimeRequestProviders([ + runtime, + ]).appleRunnerScreenRecordingTransport; + const req = { token: 'token', session: 'default', command: 'record', positionals: [], flags: {} }; + + assert.equal(resolver?.({ req, device }), transport); + assert.equal( + resolver?.({ req, device: { ...device, id: 'provider:macos:replacement' } }), + undefined, + ); +}); + test('provider inventory composition forwards cancellation into the legacy provider callback', async () => { let observedSignal: AbortSignal | undefined; const runtime: ProviderDeviceRuntime = { diff --git a/src/__tests__/test-utils/index.ts b/src/__tests__/test-utils/index.ts index 03ddec2af1..379c9c04ba 100644 --- a/src/__tests__/test-utils/index.ts +++ b/src/__tests__/test-utils/index.ts @@ -52,6 +52,8 @@ export { export { withNoColor } from './color.ts'; +export { likelyPlayableWebmContainer } from './video-fixtures.ts'; + export { closeLoopbackServer, listenOnLoopback, diff --git a/src/__tests__/test-utils/screen-recording-live-handle.ts b/src/__tests__/test-utils/screen-recording-live-handle.ts new file mode 100644 index 0000000000..1e97a6822a --- /dev/null +++ b/src/__tests__/test-utils/screen-recording-live-handle.ts @@ -0,0 +1,59 @@ +import { + createDurableResourceEnvelope, + createScreenRecordingLiveHandle, +} from '@agent-device/capture-kit'; +import { + localRuntimeOwner, + type ScreenRecordingLiveSnapshot, +} from '@agent-device/contracts/platform'; +import { deviceIdentity } from '@agent-device/kernel/device'; +import type { SessionState } from '../../daemon/types.ts'; + +type ScreenRecordingOverrides = Partial; + +/** Honest mutable screen-recording handle plus its ownership-qualified durable envelope. */ +export function makeTestScreenRecordingResource( + session: Pick, + overrides: ScreenRecordingOverrides = {}, +): NonNullable { + const initial: ScreenRecordingLiveSnapshot = { + backend: 'fixture', + outPath: '/tmp/recording.mp4', + startedAt: 1_000, + scope: 'app', + showTouches: true, + recordOnlySession: false, + gestureEvents: [], + ...overrides, + }; + const handle = createScreenRecordingLiveHandle(initial, { + finish: async (snapshot) => ({ + status: 'completed', + result: { + backend: snapshot.backend, + outPath: snapshot.outPath, + ...(snapshot.clientOutPath ? { clientOutPath: snapshot.clientOutPath } : {}), + startedAt: snapshot.startedAt, + completedAt: snapshot.startedAt + 1, + scope: snapshot.scope, + showTouches: snapshot.showTouches, + recordOnlySession: snapshot.recordOnlySession, + ...(snapshot.activeSessionApp ? { activeSessionApp: snapshot.activeSessionApp } : {}), + }, + }), + forceCleanup: async () => ({ status: 'cleaned' }), + }); + return { + handle, + envelope: createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: session.name, + device: deviceIdentity(session.device), + owner: localRuntimeOwner(session.device.platform), + fence: { token: 'test-screen-recording', generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: { fixture: true } }, + metadata: { phase: 'active' }, + }), + }; +} diff --git a/src/__tests__/test-utils/video-fixtures.ts b/src/__tests__/test-utils/video-fixtures.ts new file mode 100644 index 0000000000..54f1beba63 --- /dev/null +++ b/src/__tests__/test-utils/video-fixtures.ts @@ -0,0 +1,8 @@ +export function likelyPlayableWebmContainer(): Buffer { + // One 16x16 VP8 keyframe generated by ffmpeg. Unlike a marker-only synthetic fixture, + // ffprobe identifies this as a WebM video stream with a 40ms duration. + return Buffer.from( + 'GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAHpEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHYTbuMU6uEElTDZ1OsggElTbuMU6uEHFO7a1OsggHT7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsirXsYMPQkBNgI1MYXZmNjIuMTIuMTAxV0GNTGF2ZjYyLjEyLjEwMUSJiEBEAAAAAAAAFlSua8iuAQAAAAAAAD/XgQFzxYgNIomR4viG6pyBACK1nIN1bmSIgQCGhVZfVlA4g4EBI+ODhAJiWgDgkLCBELqBEJqBAlWwhFW5gQESVMNn/HNzoGPAgGfImkWjh0VOQ09ERVJEh41MYXZmNjIuMTIuMTAxc3PWY8CLY8WIDSKJkeL4hupnyKFFo4dFTkNPREVSRIeUTGF2YzYyLjI4LjEwMSBsaWJ2cHhnyKFFo4hEVVJBVElPTkSHkzAwOjAwOjAwLjA0MDAwMDAwMAAfQ7Z1qOeBAKOjgQAAgBACAJ0BKhAAEAAARwiFhYiFhIgCAgAMDWAA/v+rUIAcU7trkbuPs4EAt4r3gQHxggGm8IED', + 'base64', + ); +} diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index b41c176862..7ebbb4260e 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -253,16 +253,9 @@ test('viewport resizing is admitted only on web, where a backend exists', () => }); test('capabilities reject CoreDevice-only commands for XCTest-backed devices', () => { - // Runtime-backed logs admission is proven from exact device facts in - // session-capabilities.test.ts, never through this legacy matrix projection. - const coreDeviceOnlyCommands = [ - 'apps', - 'install', - 'install-from-source', - 'perf', - 'record', - 'reinstall', - ]; + // Runtime-backed logs and record admission are proven from exact device facts in + // their handler/runtime tests, never through this legacy matrix projection. + const coreDeviceOnlyCommands = ['apps', 'install', 'install-from-source', 'perf', 'reinstall']; assertCommandSupport(coreDeviceOnlyCommands, [ { device: iosDevice, expected: true, label: 'on CoreDevice' }, { device: xctestIosDevice, expected: false, label: 'on XCTest backend' }, @@ -409,7 +402,6 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'keyboard', 'perf', 'push', - 'record', 'reinstall', 'orientation', 'settings', diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 3b4f5d2167..43e16b5341 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -137,7 +137,6 @@ const SUPPORTS_REF: Record boolean> = { reinstall: supportsAppInstallation, 'install-from-source': supportsAppInstallation, perf: supportsCoreDevicePhysicalOperation, - record: supportsCoreDevicePhysicalOperation, push: isNotMacOs, home: isNotMacOs, 'app-switcher': isNotMacOs, @@ -165,7 +164,6 @@ const HINT_REF: Record string | undefined> = { reinstall: coreDeviceOnlyPhysicalOperationHint, 'install-from-source': coreDeviceOnlyPhysicalOperationHint, perf: coreDeviceOnlyPhysicalOperationHint, - record: coreDeviceOnlyPhysicalOperationHint, 'tv-remote': (device) => { if (device.platform === 'android') { return device.target === 'tv' diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 9234847d67..d30ae002c0 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -36,7 +36,6 @@ export type CommandCapability = { const WEB_DEVICE: KindMatrix = { device: true }; const HARMONYOS_ALL: KindMatrix = { emulator: true, device: true }; -const HARMONYOS_PHYSICAL_DEVICE: KindMatrix = { device: true }; const HARMONYOS_SUPPORTED_COMMANDS = new Set([ 'open', 'perf', @@ -67,7 +66,6 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set([ 'wait', ]); const WEB_RUNTIME_COMMANDS = ['open', 'close'] as const; -const WEB_RECORDING_COMMANDS = ['record'] as const; const WEB_QUERY_COMMANDS = [ 'audio', 'find', @@ -81,7 +79,6 @@ const WEB_INTERACTION_COMMANDS = ['click', 'fill', 'focus', 'press', 'scroll', ' const WEB_SETTING_COMMANDS = ['viewport'] as const; const WEB_SUPPORTED_COMMANDS = new Set([ ...WEB_RUNTIME_COMMANDS, - ...WEB_RECORDING_COMMANDS, ...WEB_QUERY_COMMANDS, ...WEB_INTERACTION_COMMANDS, ...WEB_SETTING_COMMANDS, @@ -108,9 +105,7 @@ function addHarmonyAndWebCommandCapabilities( for (const [command, capability] of Object.entries(matrix)) { withHarmony[command] = HARMONYOS_SUPPORTED_COMMANDS.has(command) ? { ...capability, harmonyos: HARMONYOS_ALL } - : command === 'record' - ? { ...capability, harmonyos: HARMONYOS_PHYSICAL_DEVICE } - : capability; + : capability; } return addWebCommandCapabilities(withHarmony); } diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 6066f6dd31..92def136ff 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -42,7 +42,7 @@ const DAEMON_FUNCTION_TRAITS = [ const UNROUTED_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSource]); // Public commands that intentionally carry no legacy capability entry. Most are -// pure control-plane or always-admitted commands; logs and network are admitted from +// pure control-plane or always-admitted commands; logs, network, and record are admitted from // exact runtime facts and therefore belong to the capability catalog without matrix rows. const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.appState, @@ -55,6 +55,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.network, PUBLIC_COMMANDS.prepare, + PUBLIC_COMMANDS.record, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test, PUBLIC_COMMANDS.trace, diff --git a/src/core/command-descriptor/__tests__/record-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/record-runtime-execution.test.ts new file mode 100644 index 0000000000..060e20a7ca --- /dev/null +++ b/src/core/command-descriptor/__tests__/record-runtime-execution.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest'; +import { + assertRecordRuntimeExecution, + screenRecordingRuntimePlanUses, +} from '@agent-device/contracts/platform'; +import { commandDescriptors } from '../registry.ts'; + +test('record descriptor declares exactly the distinct uses selected by all three plans', () => { + const record = commandDescriptors.find(({ name }) => name === 'record'); + expect(record?.platformExecution).toEqual({ + kind: 'device-runtime', + uses: screenRecordingRuntimePlanUses, + }); + expect(() => assertRecordRuntimeExecution(record?.platformExecution)).not.toThrow(); + expect(() => + assertRecordRuntimeExecution({ + kind: 'device-runtime', + use: screenRecordingRuntimePlanUses[0], + }), + ).toThrow(/two runtime-bearing plans/); +}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 84eb1f0e96..d406cd47c8 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -15,8 +15,10 @@ import type { PostActionObservationSupport } from './post-action-observation.ts' import { appLogRuntimePlanUses, assertCommandPlatformExecution, + assertRecordRuntimeExecution, inventoryUse, networkDumpUse, + screenRecordingRuntimePlanUses, } from '@agent-device/contracts/platform'; import type { CommandCatalogGroup, @@ -877,7 +879,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ allowInvalidRecording: true, allowSessionlessDefaultDevice: isRecordingStartRequest, }, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, + platformExecution: { kind: 'device-runtime', uses: screenRecordingRuntimePlanUses }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, @@ -1371,6 +1373,7 @@ export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => { ? descriptor.platformExecution : ({ kind: 'legacy' } as const); assertCommandPlatformExecution(platformExecution); + if (descriptor.name === 'record') assertRecordRuntimeExecution(platformExecution); if (!ownerFilesEnabled) { return { ...descriptor, diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index 1092876283..3e2e6904c3 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -37,9 +37,6 @@ const androidPlugin = { // former `buildPerfResponseData` sampling branch: every supported Android device // routes to the Android `perf metrics` sampler. perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'android' }, - // Wraps the Android arm of `resolveRecordingBackendForDevice`: every Android device - // resolves to the android recording backend. - recording: { resolveBackendTag: () => 'android' }, // Declares the platform-gated request provider resolver the Android family owns (the // adb provider, formerly gated by `device.platform === 'android'`). providers: { platformGatedResolvers: ['androidAdbProvider'] }, @@ -54,11 +51,6 @@ const harmonyosPlugin = { platforms: ['harmonyos'], capability: { bucket: 'harmonyos' }, perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'harmonyos' }, - // HarmonyOS exposes the system recorder only on physical devices. The backend - // validates its whole-screen-only scope before starting the service. - recording: { - resolveBackendTag: (device) => (device.kind === 'device' ? 'harmonyos' : 'unsupported'), - }, createInteractor: async (device: DeviceInfo, runner: RunnerContext) => { const { createHarmonyInteractor } = await import('./harmonyos.ts'); return createHarmonyInteractor(device, runner); @@ -69,8 +61,6 @@ const linuxPlugin = { id: 'linux', platforms: ['linux'], capability: { bucket: 'linux' }, - // No recording facet: linux historically fell through to the unsupported recording - // backend; the daemon lookup preserves that (`?? 'unsupported'`). // Declares the platform-gated request provider resolver the linux family owns (the // linux tool provider, formerly gated by `device.platform === 'linux'`). providers: { platformGatedResolvers: ['linuxToolProvider'] }, @@ -84,9 +74,6 @@ const webPlugin = { id: 'web', platforms: ['web'], capability: { bucket: 'web' }, - // Wraps the web arm of `resolveRecordingBackendForDevice`: the web device resolves to - // the web (agent-browser) recording backend. - recording: { resolveBackendTag: () => 'web' }, // Declares the platform-gated request provider resolver the web family owns (the web // provider, formerly gated by `device.platform === 'web'`). providers: { platformGatedResolvers: ['webProvider'] }, diff --git a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts index ecac49847d..4858e2d574 100644 --- a/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts +++ b/src/daemon/__tests__/android-system-dialog-ref-frame.test.ts @@ -9,6 +9,7 @@ import { snapshotAndroid } from '../../platforms/android/snapshot.ts'; import { runAndroidAdb } from '../../platforms/android/adb.ts'; import { recoverAndroidBlockingSystemDialog } from '../android-system-dialog.ts'; import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; test('android blocking-dialog recovery expires the ref frame before its recovery tap', async () => { const dialog = { @@ -32,7 +33,11 @@ test('android blocking-dialog recovery expires the ref frame before its recovery vi.mocked(runAndroidAdb).mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' } as never); const session = makeAndroidSession('anr-recovery'); - session.recording = { outPath: '/tmp/anr.mp4', startedAt: 0 } as never; + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'adb screenrecord', + outPath: '/tmp/anr.mp4', + startedAt: 0, + }); expect(session.refFrameState).toBeUndefined(); // active const result = await recoverAndroidBlockingSystemDialog({ session }); diff --git a/src/daemon/__tests__/app-log-admission-ledger.test.ts b/src/daemon/__tests__/app-log-admission-ledger.test.ts index fb4626f583..2aee7e2b31 100644 --- a/src/daemon/__tests__/app-log-admission-ledger.test.ts +++ b/src/daemon/__tests__/app-log-admission-ledger.test.ts @@ -59,6 +59,17 @@ test('retained legacy markers fail closed only in the daemon ledger that observe ).toBe(1); }); +test('an unclassified legacy marker does not globally wedge unrelated device admission', () => { + const ledger = createAppLogAdmissionLedger({ markerExists: () => true }); + const sessionStore = makeSessionStore('app-log-admission-unclassified-legacy-'); + const resourcePath = resolveAppLogResourcePath(sessionStore.resolveSessionDir('session')); + + ledger.retainLegacyMarkers([{ markerPath: '/sessions/corrupt/app-log.pid' }]); + + expect(createNextAppLogFence({ ledger, resourcePath, device: DEVICE }).generation).toBe(1); + expect(createNextAppLogFence({ ledger, resourcePath, device: OTHER_DEVICE }).generation).toBe(1); +}); + test('removing a retained legacy marker unblocks the matching device without a daemon restart', () => { let markerExists = true; const ledger = createAppLogAdmissionLedger({ markerExists: () => markerExists }); diff --git a/src/daemon/__tests__/app-log-resource-recovery.test.ts b/src/daemon/__tests__/app-log-resource-recovery.test.ts index 06b71754bd..7bddc54a93 100644 --- a/src/daemon/__tests__/app-log-resource-recovery.test.ts +++ b/src/daemon/__tests__/app-log-resource-recovery.test.ts @@ -4,6 +4,7 @@ import { expect, test, vi } from 'vitest'; import { localRuntimeOwner, type CleanupOutcome, + type DeviceBinding, type DeviceRuntimeGateway, type PlatformRuntimeOperations, type ReattachOutcome, @@ -305,7 +306,7 @@ function makeGateway( const handle = createHandle(forceCleanup); const bindingDispose = vi.fn(async () => {}); const cleanup = vi.fn(async () => ({ status: 'cleaned' as const })); - const operations: PlatformRuntimeOperations = { + const operations: DeviceBinding['operations'] = { appLogInspect: async () => ({ backend: 'android' }), appLogDoctor: async () => ({ backend: 'android', checks: {}, notes: [] }), appLogStart: async () => { @@ -349,6 +350,9 @@ function makeGateway( appLogReattach: { available: true as const }, appLogCleanup: { available: true as const }, networkDump: { available: true as const }, + screenRecordingStart: unavailableRecording, + screenRecordingReattach: unavailableRecording, + screenRecordingCleanup: unavailableRecording, }, }, operations, @@ -362,6 +366,11 @@ function makeGateway( return { gateway, bind, forceCleanup, cleanup, bindingDispose, boundSignals }; } +const unavailableRecording = Object.freeze({ + available: false as const, + reason: 'owner-capability-missing' as const, +}); + function createHandle( forceCleanup: () => Promise = vi.fn( async (): Promise => ({ status: 'cleaned' }), diff --git a/src/daemon/__tests__/durable-capture-recovery-authority.test.ts b/src/daemon/__tests__/durable-capture-recovery-authority.test.ts index 98f9718638..fc96ba023f 100644 --- a/src/daemon/__tests__/durable-capture-recovery-authority.test.ts +++ b/src/daemon/__tests__/durable-capture-recovery-authority.test.ts @@ -71,3 +71,76 @@ test('deadline abort disposes authority that becomes active after the caller has vi.useRealTimers(); } }); + +test('request cancellation wins before the deadline and disposes late exact-owner control', async () => { + const controller = new AbortController(); + const cancellation = new Error('request canceled'); + const disposeControl = vi.fn(async () => {}); + let publishControl!: (control: { + reattach: () => Promise<{ status: 'missing' }>; + cleanup: () => Promise<{ status: 'already-missing' }>; + [Symbol.asyncDispose]: () => Promise; + }) => void; + let observedSignal: AbortSignal | undefined; + let rejection: unknown; + const acquisition = acquireDurableCaptureRecoveryAuthorityBeforeDeadline({ + displayName: 'app-log', + envelope, + scope: { + signal: controller.signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + deadlineMs: 10_000, + acquireControl: async (_candidate, scope) => { + observedSignal = scope.signal; + return await new Promise((resolve) => { + publishControl = resolve; + }); + }, + onLateCleanupFailure: () => {}, + }); + void acquisition.catch((error: unknown) => { + rejection = error; + }); + await Promise.resolve(); + controller.abort(cancellation); + await Promise.resolve(); + + expect(observedSignal?.aborted).toBe(true); + expect(observedSignal?.reason).toBe(cancellation); + await vi.waitFor(() => expect(rejection).toBe(cancellation)); + + publishControl({ + reattach: async () => ({ status: 'missing' }), + cleanup: async () => ({ status: 'already-missing' }), + [Symbol.asyncDispose]: disposeControl, + }); + await expect(acquisition).rejects.toBe(cancellation); + expect(disposeControl).toHaveBeenCalledOnce(); +}); + +test('already-canceled recovery acquires no exact-owner authority', async () => { + const controller = new AbortController(); + const cancellation = new Error('request already canceled'); + controller.abort(cancellation); + const acquireControl = vi.fn(async () => { + throw new Error('must not acquire'); + }); + + await expect( + acquireDurableCaptureRecoveryAuthorityBeforeDeadline({ + displayName: 'app-log', + envelope, + scope: { + signal: controller.signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + deadlineMs: 10_000, + acquireControl, + onLateCleanupFailure: () => {}, + }), + ).rejects.toBe(cancellation); + expect(acquireControl).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/__tests__/durable-capture-resource-transitions.test.ts b/src/daemon/__tests__/durable-capture-resource-transitions.test.ts index 730b4b6e75..9516ec779d 100644 --- a/src/daemon/__tests__/durable-capture-resource-transitions.test.ts +++ b/src/daemon/__tests__/durable-capture-resource-transitions.test.ts @@ -1,12 +1,45 @@ import { expect, test } from 'vitest'; +import { countDiagnosticEventsByPhase, withDiagnosticsScope } from '../../utils/diagnostics.ts'; import { + createTestCaptureResource, makeDurableCaptureContext, makeDurableCaptureStartResult, testCaptureResource, testCaptureStore, } from './durable-capture-resource.fixtures.ts'; -test('an uncertain finish retains both the live slot and cleanup-pending durable truth', async () => { +test('finish failure remains primary when cleanup and cleanup-pending persistence both fail', async () => { + const context = makeDurableCaptureContext(); + const finishError = new Error('finalizer failed'); + const cleanupError = new Error('cleanup failed'); + const persistenceError = new Error('cleanup-pending persistence failed'); + const resource = createTestCaptureResource({ + ...testCaptureStore, + write(resourcePath, envelope) { + if (envelope.metadata?.phase === 'cleanup-pending') throw persistenceError; + testCaptureStore.write(resourcePath, envelope); + }, + }); + const start = makeDurableCaptureStartResult(context, { finishError, cleanupError }); + await resource.adoptStarted({ ...context, ...start, throwIfCanceled: () => {} }); + const active = context.sessionStore.get(context.sessionName); + if (!active) throw new Error('Expected adopted test capture session'); + + await withDiagnosticsScope({ command: 'record' }, async () => { + await expect( + resource.finishLive({ + session: active, + sessionName: context.sessionName, + sessionStore: context.sessionStore, + }), + ).rejects.toBe(finishError); + expect(countDiagnosticEventsByPhase(['app_log_finish_cleanup_failed'])).toBe(1); + }); + expect(start.forceCleanup).toHaveBeenCalledOnce(); + expect(context.sessionStore.get(context.sessionName)?.appLog?.handle).toBe(start.handle); +}); + +test('an uncertain finish preserves its error after confirmed compensating cleanup', async () => { const context = makeDurableCaptureContext(); const start = makeDurableCaptureStartResult(context, { finish: { status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }, @@ -26,9 +59,46 @@ test('an uncertain finish retains both the live slot and cleanup-pending durable sessionStore: context.sessionStore, }), ).rejects.toMatchObject({ details: { reason: 'cleanup-unconfirmed' } }); + expect(start.forceCleanup).toHaveBeenCalledOnce(); + expect(context.sessionStore.get(context.sessionName)?.appLog).toBeUndefined(); + expect(testCaptureStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } }, + }); +}); + +test('an uncertain finish retains live evidence when compensating cleanup is unconfirmed', async () => { + const context = makeDurableCaptureContext(); + const finishError = new Error('finalizer failed after native stop'); + const start = makeDurableCaptureStartResult(context, { + finishError, + cleanup: { status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }, + }); + await testCaptureResource.adoptStarted({ + ...context, + ...start, + throwIfCanceled: () => {}, + }); + const active = context.sessionStore.get(context.sessionName); + if (!active) throw new Error('Expected adopted test capture session'); + + await withDiagnosticsScope({ command: 'record' }, async () => { + await expect( + testCaptureResource.finishLive({ + session: active, + sessionName: context.sessionName, + sessionStore: context.sessionStore, + }), + ).rejects.toBe(finishError); + expect(countDiagnosticEventsByPhase(['app_log_finish_cleanup_failed'])).toBe(1); + }); + expect(start.forceCleanup).toHaveBeenCalledOnce(); expect(context.sessionStore.get(context.sessionName)?.appLog?.handle).toBe(start.handle); expect(testCaptureStore.read(context.resourcePath)).toMatchObject({ status: 'decoded', - envelope: { lifecycle: 'open', metadata: { phase: 'cleanup-pending' } }, + envelope: { + lifecycle: 'open', + metadata: { phase: 'cleanup-pending', cleanupPendingReason: 'cleanup-unconfirmed' }, + }, }); }); diff --git a/src/daemon/__tests__/durable-capture-resource.fixtures.ts b/src/daemon/__tests__/durable-capture-resource.fixtures.ts index 80f52d29af..bef36fdd50 100644 --- a/src/daemon/__tests__/durable-capture-resource.fixtures.ts +++ b/src/daemon/__tests__/durable-capture-resource.fixtures.ts @@ -12,7 +12,10 @@ import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; import { createDurableCaptureAdmissionLedger } from '../durable-capture-admission-ledger.ts'; import { createDurableCaptureResource } from '../durable-capture-resource.ts'; -import { createDurableCaptureResourceStore } from '../durable-capture-resource-store.ts'; +import { + createDurableCaptureResourceStore, + type DurableCaptureResourceStore, +} from '../durable-capture-resource-store.ts'; import type { SessionState } from '../types.ts'; export const testCaptureStore = createDurableCaptureResourceStore({ @@ -21,27 +24,29 @@ export const testCaptureStore = createDurableCaptureResourceStore({ displayName: 'test capture', }); -export const testCaptureResource = createDurableCaptureResource< - 'app-log', - AppLogLiveHandle, - AppLogCompletion ->({ - resourceKind: 'app-log', - displayName: 'test capture', - store: testCaptureStore, - sessionSlot: { - read: (session) => session.appLog, - replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), - }, - completionMetadata: (completion) => ({ - outputPath: completion.outputPath, - completedAt: completion.completedAt, - }), - messages: { - noActive: 'no test capture active', - cleanupPendingHint: 'Keep the test capture manifest for exact-owner recovery.', - }, -}); +export function createTestCaptureResource( + store: DurableCaptureResourceStore<'app-log'> = testCaptureStore, +) { + return createDurableCaptureResource<'app-log', AppLogLiveHandle, AppLogCompletion>({ + resourceKind: 'app-log', + displayName: 'test capture', + store, + sessionSlot: { + read: (session) => session.appLog, + replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), + }, + completionMetadata: (completion) => ({ + outputPath: completion.outputPath, + completedAt: completion.completedAt, + }), + messages: { + noActive: 'no test capture active', + cleanupPendingHint: 'Keep the test capture manifest for exact-owner recovery.', + }, + }); +} + +export const testCaptureResource = createTestCaptureResource(); export function makeDurableCaptureContext( device: DeviceInfo = { @@ -76,18 +81,25 @@ export function makeDurableCaptureStartResult( context: ReturnType, options: { cleanup?: CleanupOutcome; + cleanupError?: Error; finish?: FinishOutcome; + finishError?: Error; } = {}, ) { - const forceCleanup = vi.fn(async () => options.cleanup ?? ({ status: 'cleaned' } as const)); - const finish = vi.fn( - async () => + const forceCleanup = vi.fn(async () => { + if (options.cleanupError) throw options.cleanupError; + return options.cleanup ?? ({ status: 'cleaned' } as const); + }); + const finish = vi.fn(async () => { + if (options.finishError) throw options.finishError; + return ( options.finish ?? ({ status: 'completed', result: { backend: 'android', outputPath: '/tmp/app.log', completedAt: 2 }, - } as const), - ); + } as const) + ); + }); const handle = createTestAppLogLiveHandle({ inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), finish, diff --git a/src/daemon/__tests__/durable-capture-resource.test.ts b/src/daemon/__tests__/durable-capture-resource.test.ts index 7a49dbc90b..27163f78f9 100644 --- a/src/daemon/__tests__/durable-capture-resource.test.ts +++ b/src/daemon/__tests__/durable-capture-resource.test.ts @@ -1,8 +1,9 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import { makeDurableCaptureContext, makeDurableCaptureStartResult, testCaptureResource, + testCaptureStore, } from './durable-capture-resource.fixtures.ts'; test('one coordinator exposes the typed manifest and all lifecycle entrypoints', async () => { @@ -27,3 +28,130 @@ test('one coordinator exposes the typed manifest and all lifecycle entrypoints', ).resolves.toMatchObject({ outputPath: '/tmp/app.log', completedAt: 2 }); expect(context.sessionStore.get(context.sessionName)?.appLog).toBeUndefined(); }); + +test('explicit recovery finishes through caller-supplied exact-owner authority', async () => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + testCaptureStore.write(context.resourcePath, start.envelope); + const disposeControl = vi.fn(async () => {}); + + await expect( + testCaptureResource.finishRecovered({ + resourcePath: context.resourcePath, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + acquireControl: async () => ({ + reattach: async () => ({ status: 'active', handle: start.handle }), + cleanup: async () => ({ status: 'cleaned' }), + [Symbol.asyncDispose]: disposeControl, + }), + }), + ).resolves.toMatchObject({ outputPath: '/tmp/app.log', completedAt: 2 }); + + expect(start.finish).toHaveBeenCalledOnce(); + expect(disposeControl).toHaveBeenCalledOnce(); + expect(testCaptureStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } }, + }); +}); + +test('explicit recovery never converts missing authority into a replacement start', async () => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + testCaptureStore.write(context.resourcePath, start.envelope); + + await expect( + testCaptureResource.finishRecovered({ + resourcePath: context.resourcePath, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + acquireControl: async () => ({ + reattach: async () => ({ status: 'missing' }), + cleanup: async () => ({ status: 'already-missing' }), + [Symbol.asyncDispose]: async () => {}, + }), + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'resource-missing' }, + }); + expect(testCaptureStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open' }, + }); +}); + +test('explicit recovery terminalizes cleanup-safe unreattachable descriptors', async () => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + testCaptureStore.write(context.resourcePath, start.envelope); + const cleanup = vi.fn(async () => ({ status: 'cleaned' as const })); + + await expect( + testCaptureResource.finishRecovered({ + resourcePath: context.resourcePath, + scope: testScope(), + acquireControl: async () => ({ + reattach: async () => ({ + status: 'unreattachable', + reason: 'transport-not-reattachable', + }), + cleanup, + [Symbol.asyncDispose]: async () => {}, + }), + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'transport-not-reattachable' }, + }); + expect(cleanup).toHaveBeenCalledWith(start.envelope); + expect(testCaptureStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } }, + }); +}); + +test('explicit recovery retains ownership-fence failures without attempting cleanup', async () => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + testCaptureStore.write(context.resourcePath, start.envelope); + const cleanup = vi.fn(async () => ({ status: 'cleaned' as const })); + + await expect( + testCaptureResource.finishRecovered({ + resourcePath: context.resourcePath, + scope: testScope(), + acquireControl: async () => ({ + reattach: async () => ({ + status: 'unreattachable', + reason: 'ownership-fence-lost', + }), + cleanup, + [Symbol.asyncDispose]: async () => {}, + }), + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'ownership-fence-lost' }, + }); + expect(cleanup).not.toHaveBeenCalled(); + expect(testCaptureStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open' }, + }); +}); + +function testScope() { + return { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }; +} diff --git a/src/daemon/__tests__/providers-plugin-routing-parity.test.ts b/src/daemon/__tests__/providers-plugin-routing-parity.test.ts index 5b357a15b4..67ad516e92 100644 --- a/src/daemon/__tests__/providers-plugin-routing-parity.test.ts +++ b/src/daemon/__tests__/providers-plugin-routing-parity.test.ts @@ -51,7 +51,10 @@ const GATED_KEYS: PlatformGatedProviderResolverKey[] = [ 'linuxToolProvider', 'webProvider', ]; -const UNGATED_KEYS = ['recordingProvider'] as const; +const UNGATED_KEYS = [ + 'appleRunnerScreenRecordingTransport', + 'appleSimulatorScreenRecordingTransport', +] as const; // --- INDEPENDENT verbatim copy of the former per-descriptor platform gates --- function gatedResolversByHand(device: DeviceInfo): Set { @@ -155,7 +158,8 @@ test('withRequestPlatformProviderScope invokes exactly the resolvers the former vegaToolProvider: spy('vegaToolProvider'), linuxToolProvider: spy('linuxToolProvider'), webProvider: spy('webProvider'), - recordingProvider: spy('recordingProvider'), + appleRunnerScreenRecordingTransport: spy('appleRunnerScreenRecordingTransport'), + appleSimulatorScreenRecordingTransport: spy('appleSimulatorScreenRecordingTransport'), }; await withRequestPlatformProviderScope( diff --git a/src/daemon/__tests__/recording-gestures.test.ts b/src/daemon/__tests__/recording-gestures.test.ts index 81c220cbe4..365b23809d 100644 --- a/src/daemon/__tests__/recording-gestures.test.ts +++ b/src/daemon/__tests__/recording-gestures.test.ts @@ -6,9 +6,11 @@ import { } from '../recording-gestures.ts'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts'; +import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; +import type { ScreenRecordingLiveSnapshot } from '@agent-device/contracts/platform'; -function makeSession() { - return makeIosSession('default', { +function makeSession(recording: Partial = {}) { + const session = makeIosSession('default', { snapshot: makeSnapshotState( [ { @@ -19,16 +21,14 @@ function makeSession() { ], { backend: 'xctest' }, ), - recording: { - platform: 'ios', - outPath: '/tmp/demo.mp4', - startedAt: 1_000, - showTouches: true, - gestureEvents: [], - child: { kill: () => {} } as any, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, }); + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'simctl recordVideo', + outPath: '/tmp/demo.mp4', + startedAt: 1_000, + ...recording, + }); + return session; } test('scroll records a semantic scroll gesture for visualization telemetry', () => { @@ -39,8 +39,8 @@ test('scroll records a semantic scroll gesture for visualization telemetry', () recordTouchVisualizationEvent(session, 'scroll', ['down'], result, {}, 1_500, 1_920); - assert.equal(session.recording?.gestureEvents.length, 1); - const event = session.recording?.gestureEvents[0]; + assert.equal(session.screenRecording?.handle.inspect().gestureEvents.length, 1); + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'scroll'); if (!event || event.kind !== 'scroll') return; @@ -64,7 +64,7 @@ test('scroll amount scales swipe travel for visualization', () => { recordTouchVisualizationEvent(session, 'scroll', ['right', '0.6'], result, {}, 1_500); - const event = session.recording?.gestureEvents[0]; + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'scroll'); if (!event || event.kind !== 'scroll') return; @@ -85,7 +85,7 @@ test('scroll augmentation preserves explicit duration for visualization', () => recordTouchVisualizationEvent(session, 'scroll', ['up', '0.6'], result, {}, 1_500); - const event = session.recording?.gestureEvents[0]; + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'scroll'); assert.equal(event?.durationMs, 100); }); @@ -130,7 +130,7 @@ test('scroll visualization preserves absolute travel in its zero-origin referenc assert.equal(augmented.x2, 211); assert.equal(augmented.y2, 337); assert.equal(augmented.pixels, 240); - const event = session.recording?.gestureEvents[0]; + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'scroll'); assert.equal(event?.referenceWidth, 412); assert.equal(event?.referenceHeight, 894); @@ -142,15 +142,10 @@ test('scroll visualization preserves absolute travel in its zero-origin referenc test('gesture recording prefers native runner timing when available', () => { const session = makeSession(); - session.recording = { - platform: 'ios-device-runner', - outPath: '/tmp/demo.mp4', - remotePath: 'tmp/demo.mp4', - startedAt: 1_000, - showTouches: true, - gestureEvents: [], + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', runnerStartedAtUptimeMs: 5_000, - }; + }); recordTouchVisualizationEvent( session, @@ -161,7 +156,7 @@ test('gesture recording prefers native runner timing when available', () => { 9_999, ); - const event = session.recording?.gestureEvents[0]; + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'tap'); assert.equal(event?.tMs, 180); }); @@ -171,7 +166,7 @@ test('ios tap visualization anchors near completion when command execution stall recordTouchVisualizationEvent(session, 'click', [], { x: 201, y: 319 }, {}, 1_500, 3_700); - const event = session.recording?.gestureEvents[0]; + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'tap'); assert.equal(event?.tMs, 2_440); }); @@ -197,7 +192,7 @@ test('swipe visualization prefers native gesture duration when available', () => 2_300, ); - const event = session.recording?.gestureEvents[0]; + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'swipe'); if (!event || event.kind !== 'swipe') return; @@ -254,7 +249,7 @@ test('canonical gesture results record pan, fling, and pinch visualization telem 2_380, ); - assert.deepEqual(session.recording?.gestureEvents, [ + assert.deepEqual(session.screenRecording?.handle.inspect().gestureEvents, [ { kind: 'swipe', tMs: 500, @@ -309,7 +304,7 @@ test('canonical rotate records centroid visualization telemetry', () => { 1_800, ); - assert.deepEqual(session.recording?.gestureEvents, [ + assert.deepEqual(session.screenRecording?.handle.inspect().gestureEvents, [ { kind: 'swipe', tMs: 500, @@ -358,7 +353,7 @@ test('canonical multi-touch travel does not acquire one-finger back-swipe semant 2_600, ); - assert.deepEqual(session.recording?.gestureEvents, [ + assert.deepEqual(session.screenRecording?.handle.inspect().gestureEvents, [ { kind: 'swipe', tMs: 500, @@ -385,15 +380,12 @@ test('canonical multi-touch travel does not acquire one-finger back-swipe semant }); test('telemetry is still captured when touch overlays are hidden', () => { - const session = makeSession(); - if (session.recording) { - session.recording.showTouches = false; - } + const session = makeSession({ showTouches: false }); recordTouchVisualizationEvent(session, 'press', ['100', '200'], { x: 100, y: 200 }, {}, 1_500); - assert.equal(session.recording?.gestureEvents.length, 1); - assert.equal(session.recording?.gestureEvents[0]?.kind, 'tap'); + assert.equal(session.screenRecording?.handle.inspect().gestureEvents.length, 1); + assert.equal(session.screenRecording?.handle.inspect().gestureEvents[0]?.kind, 'tap'); }); test('explicit event reference frame overrides stale snapshot geometry', () => { @@ -413,7 +405,7 @@ test('explicit event reference frame overrides stale snapshot geometry', () => { 1_500, ); - const event = session.recording?.gestureEvents[0]; + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'tap'); assert.equal(event?.referenceWidth, 1344); assert.equal(event?.referenceHeight, 2992); @@ -432,7 +424,7 @@ test('edge swipe is classified as a back-swipe telemetry event', () => { 1_900, ); - const event = session.recording?.gestureEvents[0]; + const event = session.screenRecording?.handle.inspect().gestureEvents[0]; assert.equal(event?.kind, 'back-swipe'); if (!event || event.kind !== 'back-swipe') return; diff --git a/src/daemon/__tests__/recording-plugin-routing-parity.test.ts b/src/daemon/__tests__/recording-plugin-routing-parity.test.ts deleted file mode 100644 index 7c8dab7686..0000000000 --- a/src/daemon/__tests__/recording-plugin-routing-parity.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { RecordingBackendTag } from '@agent-device/contracts/recording'; -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { - isIosFamily, - isMacOs, - DEVICE_TARGETS, - PLATFORMS, - type DeviceInfo, - type DeviceKind, - type DeviceTarget, -} from '@agent-device/kernel/device'; -import { - ANDROID_EMULATOR, - ANDROID_TV_DEVICE, - IOS_DEVICE, - IOS_SIMULATOR, - IPADOS_SIMULATOR, - LINUX_DEVICE, - MACOS_DEVICE, - TVOS_SIMULATOR, - VISIONOS_SIMULATOR, - WEB_DESKTOP_DEVICE, -} from '../../__tests__/test-utils/index.ts'; -import { getPlugin, tryGetPlugin } from '../../core/platform-plugin-registry.ts'; -import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; -import { resolveRecordingBackendForDevice } from '../handlers/record-trace-recording-backends.ts'; - -// Phase 3 step b.3 (issue #974) parity gate for the daemon recording facet. The -// per-platform branch of `resolveRecordingBackendForDevice` now flows through the -// PlatformPlugin `recording.resolveBackendTag` facet (mapped daemon-side back to the -// concrete backend instance) instead of a hand branch. An INDEPENDENT verbatim copy -// of the former branch below is the BEFORE oracle: a plugin-vs-branch disagreement on -// any sample device fails this test. (Mirrors the verbatim-copy discipline in -// daemon/__tests__/applog-plugin-routing-parity.test.ts.) - -registerBuiltinPlatformPlugins(); - -// --- INDEPENDENT verbatim copy of the former `resolveRecordingBackendForDevice` -// branch, expressed as the backend TAG each arm returned --- -function recordingBackendTagByHand(device: DeviceInfo): RecordingBackendTag { - if (device.platform === 'web') return 'web'; - if (device.platform === 'android') return 'android'; - if (device.platform === 'harmonyos' && device.kind === 'device') return 'harmonyos'; - if (isMacOs(device)) return 'macos'; - if (isIosFamily(device) && device.kind === 'device') return 'ios-device'; - if (isIosFamily(device)) return 'ios-simulator'; - return 'unsupported'; -} - -// --- the exhaustive synthetic device matrix (every platform x kind x target) --- -const DEVICE_KINDS_ALL: DeviceKind[] = ['simulator', 'emulator', 'device']; -const DEVICE_TARGETS_ALL: (DeviceTarget | undefined)[] = [undefined, ...DEVICE_TARGETS]; - -function buildDeviceMatrix(): DeviceInfo[] { - const devices: DeviceInfo[] = []; - for (const platform of PLATFORMS) { - for (const kind of DEVICE_KINDS_ALL) { - for (const target of DEVICE_TARGETS_ALL) { - devices.push({ - platform, - id: `${platform}-${kind}-${target ?? 'none'}`, - name: `${platform} ${kind} ${target ?? 'none'}`, - kind, - ...(target ? { target } : {}), - booted: true, - }); - } - } - } - return devices; -} - -// The hand-authored fixtures (the real discovery shapes, incl. macOS / iOS-device / -// iOS-sim / tvOS / iPadOS / visionOS / android / web / linux) plus the exhaustive -// synthetic cross-product, so every off-nominal combination is pinned too. -const SAMPLE_DEVICES: DeviceInfo[] = [ - ANDROID_EMULATOR, - ANDROID_TV_DEVICE, - IOS_DEVICE, - IOS_SIMULATOR, - IPADOS_SIMULATOR, - LINUX_DEVICE, - MACOS_DEVICE, - TVOS_SIMULATOR, - VISIONOS_SIMULATOR, - WEB_DESKTOP_DEVICE, - ...buildDeviceMatrix(), -]; - -test('recording.resolveBackendTag facet is byte-identical to the former hand branch tag', () => { - for (const device of SAMPLE_DEVICES) { - const facet = tryGetPlugin(device.platform)?.recording; - if (!facet) continue; // linux carries no facet; the fallthrough is asserted below - assert.equal( - facet.resolveBackendTag(device), - recordingBackendTagByHand(device), - `facet tag for ${device.id}`, - ); - } -}); - -test('resolveRecordingBackendForDevice partitions devices identically to the former hand branch', () => { - // Table-equivalence WITHOUT exporting the module-private backend instances: two - // devices that hand-resolve to the SAME tag must route to the SAME backend instance, - // and distinct tags must route to distinct instances. Combined with the facet-tag - // parity above and the daemon's exhaustive tag->backend map, this pins the routed - // instance per device byte-for-byte. - const instanceByTag = new Map< - RecordingBackendTag, - ReturnType - >(); - for (const device of SAMPLE_DEVICES) { - const tag = recordingBackendTagByHand(device); - const backend = resolveRecordingBackendForDevice(device); - const seen = instanceByTag.get(tag); - if (seen) { - assert.equal(backend, seen, `same backend instance for tag '${tag}' (${device.id})`); - } else { - instanceByTag.set(tag, backend); - } - } - const instances = [...instanceByTag.values()]; - assert.equal( - new Set(instances).size, - instances.length, - 'each distinct tag maps to a distinct backend instance', - ); -}); - -test('only families with a recording backend carry the recording facet', () => { - // Apple owns ios + macos (SAME plugin instance); Android + web carry their own. - assert.equal(getPlugin('apple'), getPlugin('apple')); - assert.ok(getPlugin('apple').recording, 'apple plugin exposes recording'); - assert.ok(getPlugin('android').recording, 'android plugin exposes recording'); - assert.ok(getPlugin('harmonyos').recording, 'HarmonyOS plugin exposes physical-device recording'); - assert.ok(getPlugin('web').recording, 'web plugin exposes recording'); - // linux historically fell through to the unsupported backend; it gets NO facet, and - // the daemon lookup preserves that fallthrough (asserted below). - assert.equal(getPlugin('linux').recording, undefined, 'linux plugin has no recording'); -}); - -test('the factless family (linux) falls through to the unsupported backend', () => { - assert.equal(tryGetPlugin('linux')?.recording, undefined); - assert.equal(recordingBackendTagByHand(LINUX_DEVICE), 'unsupported'); - const linuxBackend = resolveRecordingBackendForDevice(LINUX_DEVICE); - // The routed linux backend is distinct from every family-owned backend; since the - // daemon's tag->backend map is exhaustive over the 6 tags and the other 5 are proven - // above, the remaining distinct instance is necessarily `unsupportedRecordingBackend`. - for (const device of [ - WEB_DESKTOP_DEVICE, - ANDROID_EMULATOR, - MACOS_DEVICE, - IOS_DEVICE, - IOS_SIMULATOR, - ]) { - assert.notEqual( - linuxBackend, - resolveRecordingBackendForDevice(device), - `linux (unsupported) backend must differ from ${device.id}`, - ); - } -}); diff --git a/src/daemon/__tests__/recording-provider.test.ts b/src/daemon/__tests__/recording-provider.test.ts deleted file mode 100644 index cb2bc4d70b..0000000000 --- a/src/daemon/__tests__/recording-provider.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import assert from 'node:assert/strict'; -import { test, vi } from 'vitest'; -import { IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts'; - -const { runCmdBackgroundMock } = vi.hoisted(() => ({ - runCmdBackgroundMock: vi.fn(() => ({ - child: { kill: () => true, pid: 1234 }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - })), -})); - -vi.mock('../../utils/exec.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runCmdBackground: runCmdBackgroundMock, - }; -}); - -import { createLocalRecordingProvider } from '../recording-provider.ts'; -import { runCmdBackground } from '../../utils/exec.ts'; - -const mockRunCmdBackground = vi.mocked(runCmdBackground); - -test('local recording provider starts iOS simulator recordVideo through simctl', () => { - const provider = createLocalRecordingProvider(); - - const result = provider.startIosSimulatorRecording({ - device: IOS_SIMULATOR, - outPath: '/tmp/simulator.mp4', - }); - - assert.equal(result.child.kill('SIGINT'), true); - assert.equal(result.child.pid, 1234); - assert.deepEqual(mockRunCmdBackground.mock.calls, [ - [ - 'xcrun', - ['simctl', 'io', IOS_SIMULATOR.id, 'recordVideo', '/tmp/simulator.mp4'], - { allowFailure: true }, - ], - ]); -}); diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index b07015ca87..f0298db1fd 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -22,6 +22,7 @@ import { import { resolveSessionRequestLogPath } from '../session-store.ts'; import type { DaemonRequest } from '../types.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; const TEST_ROOT = mkdtempForTestSync('agent-device-request-execution-scope-'); const LOG_PATH = path.join(TEST_ROOT, 'diagnostics.log'); @@ -599,20 +600,15 @@ test('prepareLockedRequestScope preserves existing-session selector validation', test('prepareLockedRequestScope blocks commands for invalidated recordings before handlers run', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set( - 'default', - makeIosSession('default', { - recording: { - platform: 'ios-device-runner', - outPath: '/tmp/recording.mp4', - remotePath: '/tmp/remote.mp4', - startedAt: Date.now(), - showTouches: true, - gestureEvents: [], - invalidatedReason: 'iOS runner session restarted during recording', - }, - }), - ); + const session = makeIosSession('default'); + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', + outPath: '/tmp/recording.mp4', + startedAt: Date.now(), + showTouches: true, + invalidatedReason: 'iOS runner session restarted during recording', + }); + sessionStore.set('default', session); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 156a7d2db1..7f7d80097b 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -11,9 +11,13 @@ import { contextFromFlags } from '../context.ts'; import { handleLeaseCommands } from '../handlers/lease.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { runRequestHandlerChain } from '../request-handler-chain.ts'; -import { unavailableBindDevice } from './test-device-runtime-gateway.ts'; +import { + unavailableBindDevice, + unavailableBindExactDevice, +} from './test-device-runtime-gateway.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; const SPECIALIZED_ROUTES = [ 'lease', @@ -256,6 +260,14 @@ async function runCatalogCommandThroughHandlerChain( invoke: async () => ({ ok: true, data: {} }), androidAdbExecutor: async () => ({ stdout: '', stderr: '', exitCode: 0 }), bindDevice: unavailableBindDevice, + bindExactDevice: unavailableBindExactDevice, + screenRecordingAdmissionLedger: createScreenRecordingAdmissionLedger(), + requestScope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + retainDeviceExecutionLock: async () => {}, throwIfCanceled: () => {}, contextFromFlags: (flags, appBundleId, traceLogPath) => contextFromFlags( diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 7c606e5447..586d491350 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -18,7 +18,11 @@ import { createLocalLinuxToolProvider, withLinuxToolProvider, } from '../../platforms/linux/tool-provider.ts'; -import { unavailableBindDevice } from './test-device-runtime-gateway.ts'; +import { + unavailableBindDevice, + unavailableBindExactDevice, +} from './test-device-runtime-gateway.ts'; +import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; function makeRequest(command: string, positionals: string[] = []): DaemonRequest { return { @@ -42,6 +46,14 @@ function makeChainParams(req: DaemonRequest) { leaseRegistry: new LeaseRegistry(), invoke: async (): Promise => ({ ok: true, data: {} }), bindDevice: unavailableBindDevice, + bindExactDevice: unavailableBindExactDevice, + screenRecordingAdmissionLedger: createScreenRecordingAdmissionLedger(), + requestScope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + retainDeviceExecutionLock: async () => {}, throwIfCanceled: () => {}, contextFromFlags: () => ({ logPath: '/tmp/agent-device-request-chain.log' }), }; diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index e087019fbc..16a0deb799 100644 --- a/src/daemon/__tests__/request-platform-providers.test.ts +++ b/src/daemon/__tests__/request-platform-providers.test.ts @@ -3,9 +3,11 @@ import { test } from 'vitest'; import { ANDROID_EMULATOR, IOS_SIMULATOR, + MACOS_DEVICE, WEB_DESKTOP_DEVICE, makeAndroidSession, makeIosSession, + makeMacOsSession, makeSession, } from '../../__tests__/test-utils/index.ts'; import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '../../__tests__/test-utils/device-inventory-gateways.ts'; @@ -14,6 +16,10 @@ import { runXcrun, } from '../../platforms/apple/core/tool-provider.ts'; import { resolveWebProvider, type WebProvider } from '../../platforms/web/provider.ts'; +import { + resolveAppleRunnerScreenRecordingTransport, + type AppleRunnerScreenRecordingTransport, +} from '../../platform-runtime-screen-recording-apple-runner-transport.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { withRequestPlatformProviderScope } from '../request-platform-providers.ts'; import type { DaemonRequest } from '../types.ts'; @@ -228,6 +234,80 @@ test('request platform provider scope applies web provider only for web sessions assert.deepEqual(calls, ['web-session:agent-browser-chrome', 'open:https://example.test']); }); +test('generic Apple runner provider cannot fall back to local recording authority', async () => { + await withRequestPlatformProviderScope( + { + req: request('record'), + existingSession: makeMacOsSession('macos-session'), + providers: { + appleRunnerProvider: () => ({ runCommand: async () => ({}) }), + }, + }, + async () => { + const transport = resolveAppleRunnerScreenRecordingTransport(); + assert.equal(transport.authority, 'scoped-provider'); + assert.equal(transport.available, false); + }, + ); +}); + +test('focused Apple runner recording authority remains exact across recreated request scopes', async () => { + let activeSessionId: string | undefined; + const transport: AppleRunnerScreenRecordingTransport = Object.freeze({ + authority: 'scoped-provider', + available: true, + start: async () => { + activeSessionId = 'provider-runner-session-1'; + return { runnerSessionId: activeSessionId }; + }, + inspect: async (device, runnerSessionId) => + device.id === MACOS_DEVICE.id && runnerSessionId === activeSessionId + ? 'owned-alive' + : 'ownership-lost', + stop: async ({ device, runnerSessionId }) => { + assert.equal(device.id, MACOS_DEVICE.id); + assert.equal(runnerSessionId, activeSessionId); + activeSessionId = undefined; + }, + }); + const providers = { + appleRunnerProvider: () => ({ runCommand: async () => ({}) }), + appleRunnerScreenRecordingTransport: () => transport, + }; + const runnerSessionId = await withRequestPlatformProviderScope( + { + req: request('record'), + existingSession: makeMacOsSession('macos-session'), + providers, + }, + async () => { + const resolved = resolveAppleRunnerScreenRecordingTransport(); + assert.equal(resolved, transport); + return ( + await resolved.start({ + device: MACOS_DEVICE, + appBundleId: 'com.example.app', + outputPath: '/tmp/capture.mp4', + }) + ).runnerSessionId; + }, + ); + + await withRequestPlatformProviderScope( + { + req: request('record'), + existingSession: makeMacOsSession('macos-session'), + providers, + }, + async () => { + const resolved = resolveAppleRunnerScreenRecordingTransport(); + assert.equal(await resolved.inspect(MACOS_DEVICE, runnerSessionId), 'owned-alive'); + assert.equal(await resolved.inspect(MACOS_DEVICE, 'replacement-session'), 'ownership-lost'); + await resolved.stop({ device: MACOS_DEVICE, runnerSessionId }); + }, + ); +}); + test('request platform provider scope follows explicit web selector', async () => { const seenDevices: string[] = []; diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index 9c9310a0f9..e187ef518e 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -1,5 +1,6 @@ import { test, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../types.ts'; +import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; vi.mock('../../platforms/apple/core/runner/runner-client.ts', () => ({ getRunnerSessionSnapshot: vi.fn(), @@ -15,43 +16,38 @@ beforeEach(() => { }); function makeIosSimulatorSession(showTouches: boolean): SessionState { - return { + const session: SessionState = { name: 'default', createdAt: Date.now(), actions: [], device: { platform: 'apple', + appleOs: 'ios', target: 'mobile', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', booted: true, }, - recording: { - platform: 'ios', - outPath: '/tmp/demo.mp4', - startedAt: Date.now() - 1_000, - showTouches, - gestureEvents: [], - runnerSessionId: 'runner-before', - child: { kill: () => true }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, }; + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'simctl recordVideo', + outPath: '/tmp/demo.mp4', + startedAt: Date.now() - 1_000, + showTouches, + runnerSessionId: 'runner-before', + }); + return session; } test('runner-backed iOS recordings still invalidate on runner restarts', () => { const session = makeIosSimulatorSession(true); session.device.kind = 'device'; - session.recording = { - platform: 'ios-device-runner', - outPath: '/tmp/demo.mp4', - remotePath: '/tmp/demo.mp4', - startedAt: Date.now() - 1_000, + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', showTouches: true, - gestureEvents: [], runnerSessionId: 'runner-before', - }; + }); mockGetRunnerSessionSnapshot.mockReturnValue({ alive: true, sessionId: 'runner-after', @@ -60,7 +56,7 @@ test('runner-backed iOS recordings still invalidate on runner restarts', () => { refreshRecordingHealth(session); expect(mockGetRunnerSessionSnapshot).toHaveBeenCalledWith('sim-1'); - expect(session.recording?.invalidatedReason).toBe( + expect(session.screenRecording?.handle.inspect().invalidatedReason).toBe( 'iOS runner session restarted during recording', ); }); diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index 65a7109d94..c825d58505 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -24,6 +24,7 @@ import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../provider-device-runtime.ts'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; vi.mock('../../platforms/android/snapshot.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -81,7 +82,7 @@ vi.mock('../../utils/exec.ts', async (importOriginal) => { }); function makeAndroidSession(name: string): SessionState { - return { + const session: SessionState = { name, createdAt: Date.now(), appBundleId: 'com.android.settings', @@ -94,16 +95,14 @@ function makeAndroidSession(name: string): SessionState { kind: 'emulator', booted: true, }, - recording: { - platform: 'android', - outPath: '/tmp/demo.mp4', - remotePath: '/sdcard/demo.mp4', - remotePid: '4242', - startedAt: Date.now() - 1_000, - showTouches: true, - gestureEvents: [], - }, }; + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'adb screenrecord', + outPath: '/tmp/demo.mp4', + startedAt: Date.now() - 1_000, + showTouches: true, + }); + return session; } test('generic Android gesture commands dismiss blocking system dialogs during recording', async () => { diff --git a/src/daemon/__tests__/request-router-record-runtime-lock.test.ts b/src/daemon/__tests__/request-router-record-runtime-lock.test.ts new file mode 100644 index 0000000000..801a38064d --- /dev/null +++ b/src/daemon/__tests__/request-router-record-runtime-lock.test.ts @@ -0,0 +1,217 @@ +import { expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + PendingTransferGuard, + type DeviceBinding, + type DeviceRuntimeGateway, + type PlatformRuntimeOperations, + type ScreenRecordingLiveHandle, +} from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +const DEVICE: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', +}; + +vi.mock('../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveTargetDevice: vi.fn(async () => DEVICE) }; +}); + +vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); + +test('fresh default-device recording starts serialize before durable admission', async () => { + let releaseFirstStart: () => void = () => {}; + const firstStartBlocked = new Promise((resolve) => { + releaseFirstStart = resolve; + }); + const runtime = makeRecordingGateway(firstStartBlocked); + const sessionStore = makeSessionStore('request-router-record-runtime-lock-'); + const handler = createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 'token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: runtime.gateway, + trackDownloadableArtifact: () => 'artifact', + }); + + const first = handler(recordStartRequest('session-a', 'record-start-a')); + await vi.waitFor(() => expect(runtime.start).toHaveBeenCalledOnce()); + + const second = handler(recordStartRequest('session-b', 'record-start-b')); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(runtime.bind).toHaveBeenCalledOnce(); + expect(runtime.start).toHaveBeenCalledOnce(); + + releaseFirstStart(); + await expect(first).resolves.toMatchObject({ ok: true, data: { recording: 'started' } }); + await expect(second).resolves.toMatchObject({ + ok: false, + error: { code: 'COMMAND_FAILED', details: { reason: 'cleanup-unconfirmed' } }, + }); + expect(runtime.start).toHaveBeenCalledOnce(); +}); + +test.each([ + { platform: 'linux', id: 'linux', name: 'Linux', kind: 'device', target: 'desktop' }, + { platform: 'vega', id: 'vega', name: 'Vega', kind: 'device', target: 'tv' }, +] satisfies readonly DeviceInfo[])( + 'record start on $platform routes real unavailable runtime facts into public guidance', + async (device) => { + const sessionName = `record-${device.platform}-unsupported`; + const sessionStore = makeSessionStore(`request-router-record-${device.platform}-unsupported-`); + sessionStore.set(sessionName, { + name: sessionName, + device, + createdAt: 1, + actions: [], + }); + const sessionsDir = mkdtempForTestSync(`record-${device.platform}-runtime-host-`); + const gateway = createPlatformRuntimeGateway({ + sessionsDir, + resolveSessionArtifacts: (candidate) => ({ + outputPath: `${sessionsDir}/${candidate}/app.log`, + pidPath: `${sessionsDir}/${candidate}/app-log.pid`, + }), + }); + const bind = vi.fn(async (request) => await gateway.bind(request)); + const handler = createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 'token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: { bind, shutdown: gateway.shutdown }, + trackDownloadableArtifact: () => 'artifact', + }); + + const response = await handler(recordStartRequest(sessionName, `record-${device.platform}`)); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'record is not supported on this device', + hint: 'Select an Apple, Android, physical HarmonyOS, or web target that supports screen recording.', + details: { reason: 'unsupported-platform-leaf' }, + }, + }); + expect(bind).toHaveBeenCalledOnce(); + await gateway.shutdown(); + }, +); + +function recordStartRequest(session: string, requestId: string) { + return { + token: 'token', + session, + command: 'record', + positionals: ['start', `/tmp/${session}.mp4`], + flags: { recordingScope: 'device' as const }, + meta: { requestId }, + }; +} + +function makeRecordingGateway(firstStartBlocked: Promise) { + const owner = localRuntimeOwner('android'); + let startCount = 0; + const start = vi.fn( + async (input: Parameters[0]) => { + startCount += 1; + if (startCount === 1) await firstStartBlocked; + const handle = recordingHandle(input.outputPath); + return { + pendingHandle: new PendingTransferGuard(handle), + envelope: createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: input.sessionId, + device: { id: DEVICE.id, family: 'android', kind: 'emulator' }, + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: { version: 1, body: { recordingId: input.sessionId } }, + }), + }; + }, + ); + const bind = vi.fn( + async (): Promise> => ({ + device: DEVICE, + owner, + facts: { + device: { family: 'android', kind: 'emulator', providerMode: 'local' }, + operations: { + appLogInspect: unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + networkDump: unavailable, + screenRecordingStart: { available: true }, + screenRecordingReattach: { available: true }, + screenRecordingCleanup: { available: true }, + }, + }, + operations: { + screenRecordingStart: start, + screenRecordingReattach: async () => ({ status: 'missing' }), + screenRecordingCleanup: async () => ({ status: 'already-missing' }), + }, + [Symbol.asyncDispose]: async () => {}, + }), + ); + const gateway: DeviceRuntimeGateway = { + bind, + shutdown: async () => {}, + }; + return { gateway, bind, start }; +} + +function recordingHandle(outPath: string): ScreenRecordingLiveHandle { + return { + inspect: () => ({ + backend: 'adb screenrecord', + outPath, + startedAt: 1, + scope: 'device', + showTouches: true, + recordOnlySession: true, + gestureEvents: [], + }), + appendGestureEvents: () => {}, + setTouchReferenceFrame: () => {}, + setRunnerSessionId: () => {}, + invalidate: () => {}, + finish: async () => ({ + status: 'completed', + result: { + backend: 'adb screenrecord', + outPath, + startedAt: 1, + completedAt: 2, + scope: 'device', + showTouches: true, + recordOnlySession: true, + }, + }), + forceCleanup: async () => ({ status: 'cleaned' }), + [Symbol.asyncDispose]: async () => {}, + }; +} + +const unavailable = Object.freeze({ + available: false as const, + reason: 'owner-capability-missing' as const, +}); diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index 3905f41001..1cc3fd1861 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -27,6 +27,7 @@ import { createRequestHandler } from './test-device-runtime-gateway.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockDispatchGesturePlan = vi.mocked(dispatchGesturePlan); @@ -52,23 +53,20 @@ test('router blocks non-record commands when recording was invalidated', async ( appBundleId: 'com.apple.Preferences', device: { platform: 'apple', + appleOs: 'ios', target: 'mobile', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', booted: true, }, - recording: { - platform: 'ios', - outPath: '/tmp/demo.mp4', - startedAt: Date.now() - 1_000, - showTouches: true, - gestureEvents: [], - invalidatedReason: 'iOS runner session restarted during recording', - child: { kill: () => {} } as any, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, }; + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', + outPath: '/tmp/demo.mp4', + startedAt: Date.now() - 1_000, + invalidatedReason: 'iOS runner session restarted during recording', + }); sessionStore.set('default', session); const handler = createRequestHandler({ @@ -106,23 +104,20 @@ test('router allows canonical iOS simulator gestures during overlay recording af appBundleId: 'com.apple.Preferences', device: { platform: 'apple', + appleOs: 'ios', target: 'mobile', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', booted: true, }, - recording: { - platform: 'ios', - outPath: '/tmp/demo.mp4', - startedAt: Date.now() - 1_000, - showTouches: true, - gestureEvents: [], - runnerSessionId: 'runner-before', - child: { kill: () => {} } as any, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, }; + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'simctl recordVideo', + outPath: '/tmp/demo.mp4', + startedAt: Date.now() - 1_000, + runnerSessionId: 'runner-before', + }); sessionStore.set('default', session); mockGetRunnerSessionSnapshot.mockReturnValue({ alive: true, @@ -150,7 +145,7 @@ test('router allows canonical iOS simulator gestures during overlay recording af expect(mockGetRunnerSessionSnapshot).not.toHaveBeenCalled(); expect(mockDispatchGestureViewport).toHaveBeenCalledOnce(); expect(mockDispatchGesturePlan).toHaveBeenCalledOnce(); - const recording = sessionStore.get('default')?.recording; + const recording = sessionStore.get('default')?.screenRecording?.handle.inspect(); expect(recording?.invalidatedReason).toBeUndefined(); expect(recording?.gestureEvents).toHaveLength(1); expect(recording?.gestureEvents[0]?.kind).toBe('pinch'); diff --git a/src/daemon/__tests__/request-runtime-binding-router.test.ts b/src/daemon/__tests__/request-runtime-binding-router.test.ts index fe678d9f4d..e51286fa3a 100644 --- a/src/daemon/__tests__/request-runtime-binding-router.test.ts +++ b/src/daemon/__tests__/request-runtime-binding-router.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { expect, test, vi } from 'vitest'; import { localRuntimeOwner, + type DeviceBinding, type DeviceRuntimeGateway, type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; @@ -90,7 +91,7 @@ function makeGateway(disposeError?: Error) { }), forceCleanup, }); - const operations: PlatformRuntimeOperations = { + const operations: DeviceBinding['operations'] = { appLogInspect: async () => ({ backend: 'android' }), appLogDoctor: async () => ({ backend: 'android', checks: {}, notes: [] }), appLogStart: async (input) => @@ -142,6 +143,9 @@ function makeGateway(disposeError?: Error) { appLogReattach: { available: true as const }, appLogCleanup: { available: true as const }, networkDump: { available: true as const }, + screenRecordingStart: unavailableRecording, + screenRecordingReattach: unavailableRecording, + screenRecordingCleanup: unavailableRecording, }, }, operations, @@ -153,3 +157,8 @@ function makeGateway(disposeError?: Error) { }; return { gateway, bind, bindingDispose, forceCleanup, handle }; } + +const unavailableRecording = Object.freeze({ + available: false as const, + reason: 'owner-capability-missing' as const, +}); diff --git a/src/daemon/__tests__/request-runtime-binding.test.ts b/src/daemon/__tests__/request-runtime-binding.test.ts index 6e155c1025..f78732fe9a 100644 --- a/src/daemon/__tests__/request-runtime-binding.test.ts +++ b/src/daemon/__tests__/request-runtime-binding.test.ts @@ -4,11 +4,14 @@ import { localRuntimeOwner, networkDumpUse, resolveLogsRuntimePlan, + screenRecordingRecoveryUse, type DeviceBinding, type DeviceRuntimeGateway, type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { acquireDurableCaptureRecoveryAuthorityBeforeDeadline } from '../durable-capture-recovery-authority.ts'; import { createRequestRuntimeBindings } from '../request-runtime-binding.ts'; const inspectPlan = resolveLogsRuntimePlan({ action: 'path' }); @@ -81,6 +84,202 @@ test('preferred absence is visible without failing while required absence fails await bindings[Symbol.asyncDispose](); }); +test('exact-owner recovery binds the persisted owner and fence without ordinary arbitration', async () => { + const runtime = makeGateway(); + const bindings = createRequestRuntimeBindings({ gateway: runtime.gateway, scope }); + const selected = device('one'); + const owner = localRuntimeOwner('android'); + const fence = { token: 'recording-fence', generation: 4 } as const; + const recoveryScope = { + signal: new AbortController().signal, + diagnostics: { emit: vi.fn() }, + progress: { report: vi.fn() }, + }; + + const recovered = await bindings.bindExactDevice( + selected, + owner, + fence, + screenRecordingRecoveryUse, + recoveryScope, + ); + + expect(recovered.operations.screenRecordingReattach).toBe( + runtime.operations.screenRecordingReattach, + ); + expect(runtime.bind).toHaveBeenCalledWith({ + device: selected, + intent: { kind: 'exact-owner', owner, fence }, + scope: recoveryScope, + }); + await bindings[Symbol.asyncDispose](); + expect(runtime.disposals).toEqual(['one']); +}); + +test('late exact-owner binding is rolled back when request cleanup already began', async () => { + const runtime = makeGateway(); + const selected = device('late'); + const owner = localRuntimeOwner('android'); + const fence = { token: 'recording-fence', generation: 4 } as const; + const published = await runtime.gateway.bind({ + device: selected, + intent: { kind: 'exact-owner', owner, fence }, + scope, + }); + const disposePublished = vi.fn(async () => {}); + const lateBinding = { ...published, [Symbol.asyncDispose]: disposePublished }; + let publish: (binding: DeviceBinding) => void = () => {}; + const gateway: DeviceRuntimeGateway = { + bind: vi.fn( + async () => + await new Promise>((resolve) => { + publish = resolve; + }), + ), + shutdown: async () => {}, + }; + const bindings = createRequestRuntimeBindings({ gateway, scope }); + const binding = bindings.bindExactDevice( + selected, + owner, + fence, + screenRecordingRecoveryUse, + scope, + ); + await bindings[Symbol.asyncDispose](); + publish(lateBinding); + + await expect(binding).rejects.toThrow('Cannot register cleanup after disposal has begun'); + expect(disposePublished).toHaveBeenCalledOnce(); +}); + +test('late exact-owner rollback failure is secondary diagnostic evidence', async () => { + const runtime = makeGateway(); + const selected = device('late-cleanup-failure'); + const owner = localRuntimeOwner('android'); + const fence = { token: 'recording-fence', generation: 4 } as const; + const published = await runtime.gateway.bind({ + device: selected, + intent: { kind: 'exact-owner', owner, fence }, + scope, + }); + const lateBinding = { + ...published, + [Symbol.asyncDispose]: vi.fn(async () => { + throw new Error('rollback failed'); + }), + }; + let publish: (binding: DeviceBinding) => void = () => {}; + const gateway: DeviceRuntimeGateway = { + bind: vi.fn( + async () => + await new Promise>((resolve) => { + publish = resolve; + }), + ), + shutdown: async () => {}, + }; + const emit = vi.fn(); + const recoveryScope = { + signal: new AbortController().signal, + diagnostics: { emit }, + progress: { report: () => {} }, + }; + const bindings = createRequestRuntimeBindings({ gateway, scope }); + const binding = bindings.bindExactDevice( + selected, + owner, + fence, + screenRecordingRecoveryUse, + recoveryScope, + ); + await bindings[Symbol.asyncDispose](); + publish(lateBinding); + + await expect(binding).rejects.toThrow('Cannot register cleanup after disposal has begun'); + expect(emit).toHaveBeenCalledWith({ + level: 'error', + phase: 'request_runtime_late_binding_cleanup_failed', + data: { + error: 'rollback failed', + primaryError: 'Cannot register cleanup after disposal has begun', + }, + }); +}); + +test('request cancellation aborts deferred exact recovery and late publication is disposed', async () => { + const runtime = makeGateway(); + const selected = device('canceled-recovery'); + const owner = localRuntimeOwner('android'); + const fence = { token: 'recording-fence', generation: 4 } as const; + const envelope = createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: 'session', + device: { id: selected.id, family: 'android', kind: 'emulator' }, + owner, + fence, + lifecycle: 'open', + descriptor: { version: 1, body: {} }, + }); + const published = await runtime.gateway.bind({ + device: selected, + intent: { kind: 'exact-owner', owner, fence }, + scope, + }); + const disposePublished = vi.fn(async () => {}); + const lateBinding = { ...published, [Symbol.asyncDispose]: disposePublished }; + let publish: (binding: DeviceBinding) => void = () => {}; + let observedScope: typeof scope | undefined; + const gateway: DeviceRuntimeGateway = { + bind: vi.fn( + async (request) => + await new Promise>((resolve) => { + observedScope = request.scope; + publish = resolve; + }), + ), + shutdown: async () => {}, + }; + const controller = new AbortController(); + const cancellation = new Error('request canceled during exact bind'); + const requestScope = { + signal: controller.signal, + diagnostics: { emit: vi.fn() }, + progress: { report: () => {} }, + }; + const bindings = createRequestRuntimeBindings({ gateway, scope: requestScope }); + const acquisition = acquireDurableCaptureRecoveryAuthorityBeforeDeadline({ + displayName: 'screen recording', + envelope, + scope: requestScope, + deadlineMs: 10_000, + acquireControl: async (_candidate, recoveryScope) => { + const bound = await bindings.bindExactDevice( + selected, + owner, + fence, + screenRecordingRecoveryUse, + recoveryScope, + ); + return { + reattach: async () => await bound.operations.screenRecordingReattach({ envelope }), + cleanup: async () => await bound.operations.screenRecordingCleanup({ envelope }), + [Symbol.asyncDispose]: async () => {}, + }; + }, + onLateCleanupFailure: () => {}, + }); + await vi.waitFor(() => expect(gateway.bind).toHaveBeenCalledOnce()); + controller.abort(cancellation); + + await expect(acquisition).rejects.toBe(cancellation); + expect(observedScope?.signal.aborted).toBe(true); + expect(observedScope?.signal.reason).toBe(cancellation); + await bindings[Symbol.asyncDispose](); + publish(lateBinding); + await vi.waitFor(() => expect(disposePublished).toHaveBeenCalledOnce()); +}); + function makeGateway(options: { inspectAvailable?: boolean } = {}) { const disposals: string[] = []; const operations: PlatformRuntimeOperations = { @@ -105,6 +304,11 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) { }, notes: [], })), + screenRecordingStart: vi.fn(async () => { + throw new Error('not used'); + }), + screenRecordingReattach: vi.fn(async () => ({ status: 'missing' as const })), + screenRecordingCleanup: vi.fn(async () => ({ status: 'already-missing' as const })), }; const bind = vi.fn( async ({ device: selected }): Promise> => ({ @@ -122,6 +326,9 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) { appLogReattach: { available: true }, appLogCleanup: { available: true }, networkDump: { available: true }, + screenRecordingStart: { available: true }, + screenRecordingReattach: { available: true }, + screenRecordingCleanup: { available: true }, }, }, operations: @@ -132,6 +339,9 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) { appLogReattach: operations.appLogReattach, appLogCleanup: operations.appLogCleanup, networkDump: operations.networkDump, + screenRecordingStart: operations.screenRecordingStart, + screenRecordingReattach: operations.screenRecordingReattach, + screenRecordingCleanup: operations.screenRecordingCleanup, } : operations, [Symbol.asyncDispose]: async () => { diff --git a/src/daemon/__tests__/screen-recording-session-resource.test.ts b/src/daemon/__tests__/screen-recording-session-resource.test.ts new file mode 100644 index 0000000000..00acc46bb0 --- /dev/null +++ b/src/daemon/__tests__/screen-recording-session-resource.test.ts @@ -0,0 +1,102 @@ +import { expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + PendingTransferGuard, + type ScreenRecordingLiveHandle, +} from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import { + adoptStartedScreenRecording, + finishLiveScreenRecording, +} from '../screen-recording-session-resource.ts'; +import { screenRecordingResourceStore } from '../screen-recording-resource-store.ts'; +import type { SessionState } from '../types.ts'; + +test('screen recording persists durable truth before adopting only handle and envelope', async () => { + const sessionStore = makeSessionStore('screen-recording-session-resource-'); + const sessionName = 'recording'; + const session: SessionState = { + name: sessionName, + device: { platform: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' }, + createdAt: 1, + actions: [], + }; + sessionStore.set(sessionName, session); + const owner = localRuntimeOwner('android'); + const fence = { token: 'recording-fence', generation: 1 } as const; + const finish = vi.fn(async () => ({ + status: 'completed' as const, + result: { + backend: 'android', + outPath: '/tmp/recording.mp4', + startedAt: 1, + completedAt: 2, + scope: 'app' as const, + showTouches: true, + recordOnlySession: false, + }, + })); + const handle: ScreenRecordingLiveHandle = { + inspect: () => ({ + backend: 'android', + outPath: '/tmp/recording.mp4', + startedAt: 1, + scope: 'app', + showTouches: true, + recordOnlySession: false, + gestureEvents: [], + }), + appendGestureEvents: () => {}, + setTouchReferenceFrame: () => {}, + setRunnerSessionId: () => {}, + invalidate: () => {}, + finish, + forceCleanup: async () => ({ status: 'cleaned' }), + [Symbol.asyncDispose]: async () => {}, + }; + const envelope = createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: sessionName, + device: { id: session.device.id, family: 'android', kind: 'emulator' }, + owner, + fence, + lifecycle: 'open', + descriptor: { version: 1, body: { recordingId: 'recording-id' } }, + }); + + await adoptStartedScreenRecording({ + admissionLedger: createScreenRecordingAdmissionLedger(), + session, + sessionName, + sessionStore, + device: session.device, + owner, + fence, + pendingHandle: new PendingTransferGuard(handle), + envelope, + throwIfCanceled: () => {}, + }); + + expect(sessionStore.get(sessionName)?.screenRecording).toMatchObject({ + handle, + envelope: { ...envelope, metadata: { phase: 'active' } }, + }); + expect(screenRecordingResourceStore.read(resourcePath(sessionStore, sessionName))).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open', metadata: { phase: 'active' } }, + }); + + const active = sessionStore.get(sessionName); + if (!active) throw new Error('Expected screen-recording session'); + await expect( + finishLiveScreenRecording({ session: active, sessionName, sessionStore }), + ).resolves.toMatchObject({ backend: 'android', outPath: '/tmp/recording.mp4' }); + expect(finish).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)?.screenRecording).toBeUndefined(); +}); + +function resourcePath(sessionStore: ReturnType, sessionName: string) { + return screenRecordingResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)); +} diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 6d6cc15b48..c96f9003e2 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -8,7 +8,7 @@ import { createRequestHandler as createProductionRequestHandler, type RequestRouterDeps, } from '../request-router.ts'; -import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway = Object.freeze({ @@ -33,6 +33,9 @@ export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway use, ); +export const unavailableBindExactDevice: BindExactDeviceRuntime = async ( + device, + owner, + fence, + use, + scope, +) => + narrowDeviceBinding( + await unavailableDeviceRuntimeGateway.bind({ + device, + intent: { kind: 'exact-owner', owner, fence }, + scope, + }), + use, + ); + export function createRequestHandler( deps: Omit & Partial>, diff --git a/src/daemon/android-system-dialog.ts b/src/daemon/android-system-dialog.ts index 03cd31d40d..1438939ca4 100644 --- a/src/daemon/android-system-dialog.ts +++ b/src/daemon/android-system-dialog.ts @@ -43,7 +43,7 @@ export async function recoverAndroidBlockingSystemDialog(params: { }): Promise { const { session } = params; - if (session.device.platform !== 'android' || !session.recording) { + if (session.device.platform !== 'android' || !session.screenRecording) { return { status: 'absent' }; } diff --git a/src/daemon/app-log-admission-ledger.ts b/src/daemon/app-log-admission-ledger.ts index 7cd2f7f4b4..d15663e586 100644 --- a/src/daemon/app-log-admission-ledger.ts +++ b/src/daemon/app-log-admission-ledger.ts @@ -36,9 +36,7 @@ function findRetainedLegacyMarker( ): RetainedLegacyAppLogMarker | undefined { let retained: RetainedLegacyAppLogMarker | undefined; for (const [markerPath, marker] of retainedMarkers) { - const matchesDevice = - marker.device === undefined || deviceIdentityKey(marker.device) === identityKey; - if (!matchesDevice) continue; + if (!marker.device || deviceIdentityKey(marker.device) !== identityKey) continue; if (!markerExists(markerPath)) { retainedMarkers.delete(markerPath); continue; @@ -62,7 +60,11 @@ export function createAppLogAdmissionLedger( return Object.freeze({ ...durableLedger, retainLegacyMarkers(markers: readonly RetainedLegacyAppLogMarker[]): void { - for (const marker of markers) retainedLegacyMarkers.set(marker.markerPath, marker); + for (const marker of markers) { + // Corrupt legacy markers cannot safely claim a device. They remain on disk as path-local + // no-overwrite evidence, but must not wedge app-log admission for every unrelated device. + if (marker.device) retainedLegacyMarkers.set(marker.markerPath, marker); + } }, assertStartAllowed(device: DeviceInfo): void { const identityKey = deviceIdentityKey(deviceIdentity(device)); diff --git a/src/daemon/durable-capture-recovery-authority.ts b/src/daemon/durable-capture-recovery-authority.ts index d0e26af8f2..b87f834650 100644 --- a/src/daemon/durable-capture-recovery-authority.ts +++ b/src/daemon/durable-capture-recovery-authority.ts @@ -5,6 +5,7 @@ import type { PlatformRequestScope, ReattachOutcome, } from '@agent-device/contracts/platform'; +import { capitalizeDurableCaptureLabel } from './durable-capture-resource-labels.ts'; export type DurableCaptureRecoveryControl< K extends string, @@ -48,6 +49,7 @@ export async function acquireDurableCaptureRecoveryAuthorityBeforeDeadline< >( params: DurableCaptureRecoveryAuthorityParams, ): Promise> { + params.scope.signal.throwIfAborted(); const controller = new AbortController(); const scope = { ...params.scope, @@ -57,6 +59,23 @@ export async function acquireDurableCaptureRecoveryAuthorityBeforeDeadline< const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; }); + let stopListeningForCancellation = () => {}; + const cancellation = new Promise((_resolve, reject) => { + const rejectCancellation = () => { + try { + params.scope.signal.throwIfAborted(); + } catch (error) { + reject(error); + } + }; + if (params.scope.signal.aborted) { + rejectCancellation(); + return; + } + params.scope.signal.addEventListener('abort', rejectCancellation, { once: true }); + stopListeningForCancellation = () => + params.scope.signal.removeEventListener('abort', rejectCancellation); + }); const timer = setTimeout(() => { const error = new DurableCaptureRecoveryDeadlineError(params.displayName, params.deadlineMs); controller.abort(error); @@ -65,9 +84,10 @@ export async function acquireDurableCaptureRecoveryAuthorityBeforeDeadline< timer.unref?.(); const acquisition = acquireRecoveryAuthority(params, scope); try { - return await Promise.race([acquisition, deadline]); + return await Promise.race([acquisition, deadline, cancellation]); } finally { clearTimeout(timer); + stopListeningForCancellation(); void acquisition.catch(() => {}); } } @@ -112,12 +132,10 @@ export class DurableCaptureRecoveryDeadlineError extends Error { readonly deadlineMs: number; constructor(displayName: string, deadlineMs: number) { - super(`${capitalize(displayName)} recovery exceeded its ${deadlineMs}ms deadline`); + super( + `${capitalizeDurableCaptureLabel(displayName)} recovery exceeded its ${deadlineMs}ms deadline`, + ); this.name = 'DurableCaptureRecoveryDeadlineError'; this.deadlineMs = deadlineMs; } } - -function capitalize(value: string): string { - return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1); -} diff --git a/src/daemon/durable-capture-resource-adoption.ts b/src/daemon/durable-capture-resource-adoption.ts index e5996350ab..5d65c86ea2 100644 --- a/src/daemon/durable-capture-resource-adoption.ts +++ b/src/daemon/durable-capture-resource-adoption.ts @@ -16,6 +16,10 @@ import type { AdoptStartedDurableCaptureParams, DurableCaptureResourceDefinition, } from './durable-capture-resource.ts'; +import { + capitalizeDurableCaptureLabel, + durableCaptureDiagnosticPrefix, +} from './durable-capture-resource-labels.ts'; type AdoptionState = | { kind: 'pending' } @@ -93,7 +97,7 @@ function confirmFailedAdoptionTransition ): void { emitDiagnostic({ level: 'error', - phase: `${diagnosticPrefix(definition.resourceKind)}_pending_adoption_cleanup_failed`, + phase: `${durableCaptureDiagnosticPrefix(definition.resourceKind)}_pending_adoption_cleanup_failed`, data: { session: params.sessionName, primaryError: primaryError instanceof Error ? primaryError.message : String(primaryError), @@ -272,11 +276,3 @@ function withPhase( metadata: { ...(envelope.metadata ?? {}), phase }, }); } - -function capitalize(value: string): string { - return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1); -} - -function diagnosticPrefix(resourceKind: string): string { - return resourceKind.replaceAll('-', '_'); -} diff --git a/src/daemon/durable-capture-resource-finish-recovered.ts b/src/daemon/durable-capture-resource-finish-recovered.ts new file mode 100644 index 0000000000..04f057310e --- /dev/null +++ b/src/daemon/durable-capture-resource-finish-recovered.ts @@ -0,0 +1,145 @@ +import type { + DurableResourceEnvelope, + LiveResourceHandle, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { AppError } from '@agent-device/kernel/errors'; +import { + acquireDurableCaptureRecoveryAuthorityBeforeDeadline, + type DurableCaptureRecoveryControl, +} from './durable-capture-recovery-authority.ts'; +import { allowsDurableCaptureDescriptorCleanup } from './durable-capture-resource-recovery.ts'; +import { withDurableCaptureResourceFence } from './durable-capture-resource-fence.ts'; +import type { DurableCaptureResourceDefinition } from './durable-capture-resource.ts'; +import { + capitalizeDurableCaptureLabel, + durableCaptureDiagnosticPrefix, +} from './durable-capture-resource-labels.ts'; +import { + finishDurableCaptureHandle, + requireConfirmedDurableCaptureCleanup, + transitionCleanupOutcome, + transitionFinishOutcome, +} from './durable-capture-resource-transitions.ts'; + +const DEFAULT_FINISH_RECOVERY_DEADLINE_MS = 5_000; + +export type FinishRecoveredDurableCaptureParams< + K extends string, + H extends LiveResourceHandle, + C, +> = Readonly<{ + resourcePath: string; + scope: PlatformRequestScope; + acquireControl( + envelope: DurableResourceEnvelope, + scope: PlatformRequestScope, + ): Promise>; + deadlineMs?: number; +}>; + +export async function finishRecoveredDurableCapture< + K extends string, + H extends LiveResourceHandle, + C, +>( + definition: DurableCaptureResourceDefinition, + params: FinishRecoveredDurableCaptureParams, +): Promise { + const record = definition.store.read(params.resourcePath); + if (record.status !== 'decoded' || record.envelope.lifecycle !== 'open') { + throw noRecoverableResource(definition, record.status); + } + const envelope = record.envelope; + const authority = await acquireDurableCaptureRecoveryAuthorityBeforeDeadline({ + displayName: definition.displayName, + envelope, + scope: params.scope, + deadlineMs: params.deadlineMs ?? DEFAULT_FINISH_RECOVERY_DEADLINE_MS, + acquireControl: params.acquireControl, + onLateCleanupFailure: (phase, cleanupError, primaryError) => + params.scope.diagnostics.emit({ + level: 'error', + phase: `${durableCaptureDiagnosticPrefix(definition.resourceKind)}_recovery_${phase}`, + data: { + resourcePath: params.resourcePath, + error: errorMessage(cleanupError), + primaryError: errorMessage(primaryError), + }, + }), + }); + try { + switch (authority.reattached.status) { + case 'active': + return await finishDurableCaptureHandle(definition, { + handle: authority.reattached.handle, + fence: envelope.fence, + resourcePath: params.resourcePath, + }); + case 'completed': { + const result = authority.reattached.result; + await withDurableCaptureResourceFence({ + store: definition.store, + resourcePath: params.resourcePath, + expected: envelope.fence, + run: async (lease) => { + transitionFinishOutcome(definition, lease, { + status: 'completed', + result, + alreadyCompleted: true, + }); + }, + }); + return result; + } + case 'missing': + throw new AppError( + 'COMMAND_FAILED', + `${capitalizeDurableCaptureLabel(definition.displayName)} is missing`, + { + reason: 'resource-missing', + hint: definition.messages.cleanupPendingHint, + }, + ); + case 'unreattachable': { + if (allowsDurableCaptureDescriptorCleanup(authority.reattached.reason)) { + const cleanup = await authority.control.cleanup(envelope); + await withDurableCaptureResourceFence({ + store: definition.store, + resourcePath: params.resourcePath, + expected: envelope.fence, + run: async (lease) => transitionCleanupOutcome(lease, cleanup), + }); + requireConfirmedDurableCaptureCleanup(definition, cleanup); + } + throw new AppError( + 'COMMAND_FAILED', + authority.reattached.message ?? + `${capitalizeDurableCaptureLabel(definition.displayName)} cannot be reattached`, + { + reason: authority.reattached.reason, + hint: definition.messages.cleanupPendingHint, + }, + ); + } + } + } finally { + await authority.control[Symbol.asyncDispose](); + } +} + +function noRecoverableResource( + definition: DurableCaptureResourceDefinition, unknown>, + status: 'missing' | 'decoded' | 'unreattachable', +): AppError { + return new AppError( + 'INVALID_ARGS', + status === 'missing' + ? definition.messages.noActive + : `${capitalizeDurableCaptureLabel(definition.displayName)} recovery record is not open`, + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/daemon/durable-capture-resource-labels.ts b/src/daemon/durable-capture-resource-labels.ts new file mode 100644 index 0000000000..19c69885aa --- /dev/null +++ b/src/daemon/durable-capture-resource-labels.ts @@ -0,0 +1,8 @@ +/** User-facing and diagnostic naming shared by every durable-capture lifecycle phase. */ +export function capitalizeDurableCaptureLabel(value: string): string { + return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1); +} + +export function durableCaptureDiagnosticPrefix(resourceKind: string): string { + return resourceKind.replaceAll('-', '_'); +} diff --git a/src/daemon/durable-capture-resource-recovery.ts b/src/daemon/durable-capture-resource-recovery.ts index 7a605e73b7..20ce5bf9bc 100644 --- a/src/daemon/durable-capture-resource-recovery.ts +++ b/src/daemon/durable-capture-resource-recovery.ts @@ -18,6 +18,7 @@ import { type DurableCaptureResourceFenceLease, } from './durable-capture-resource-fence.ts'; import type { DurableCaptureResourceDefinition } from './durable-capture-resource.ts'; +import { durableCaptureDiagnosticPrefix } from './durable-capture-resource-labels.ts'; import { transitionCleanupOutcome } from './durable-capture-resource-transitions.ts'; import { safeSessionName } from './session-paths.ts'; @@ -180,7 +181,7 @@ async function settleRecoveryAuthority(lease: DurableCaptureResourceFenceLeas }); } -function retainsWithoutCleanup(reason: ResourceUnreattachableReason): boolean { +export function allowsDurableCaptureDescriptorCleanup( + reason: ResourceUnreattachableReason, +): boolean { switch (reason) { case 'descriptor-invalid': case 'descriptor-version-unsupported': case 'ownership-fence-lost': - return true; + return false; case 'owner-unavailable': case 'transport-not-reattachable': - return false; + return true; } } @@ -245,14 +248,10 @@ function report, C>( data: Record, ): void { const diagnostic = { - phase: `${diagnosticPrefix(params.definition.resourceKind)}_recovery_${suffix}`, + phase: `${durableCaptureDiagnosticPrefix(params.definition.resourceKind)}_recovery_${suffix}`, resourcePath, data, }; if (params.onDiagnostic) params.onDiagnostic(diagnostic); else emitDiagnostic({ level: 'warn', phase: diagnostic.phase, data: { resourcePath, ...data } }); } - -function diagnosticPrefix(resourceKind: string): string { - return resourceKind.replaceAll('-', '_'); -} diff --git a/src/daemon/durable-capture-resource-transitions.ts b/src/daemon/durable-capture-resource-transitions.ts index 850294c5de..4dda94cd65 100644 --- a/src/daemon/durable-capture-resource-transitions.ts +++ b/src/daemon/durable-capture-resource-transitions.ts @@ -5,11 +5,13 @@ import { type LiveResourceHandle, } from '@agent-device/contracts/platform'; import { AppError } from '@agent-device/kernel/errors'; +import { emitDiagnostic } from '../utils/diagnostics.ts'; import { withDurableCaptureResourceFence, type DurableCaptureResourceFenceLease, } from './durable-capture-resource-fence.ts'; import type { DurableCaptureResourceDefinition } from './durable-capture-resource.ts'; +import { capitalizeDurableCaptureLabel } from './durable-capture-resource-labels.ts'; import type { SessionStore } from './session-store.ts'; import type { SessionState } from './types.ts'; @@ -24,23 +26,146 @@ export async function finishLiveDurableCapture< ): Promise { const active = definition.sessionSlot.read(params.session); if (!active) throw new AppError('INVALID_ARGS', definition.messages.noActive); - const outcome = await withDurableCaptureResourceFence({ + try { + const result = await finishDurableCaptureHandle(definition, { + handle: active.handle, + fence: active.envelope.fence, + resourcePath, + }); + clearLiveSlot(definition, params); + return result; + } catch (error) { + const record = definition.store.read(resourcePath); + if (record.status === 'decoded' && record.envelope.lifecycle === 'completed') { + clearLiveSlot(definition, params); + } + throw error; + } +} + +export async function finishDurableCaptureHandle< + K extends string, + H extends LiveResourceHandle, + C, +>( + definition: DurableCaptureResourceDefinition, + params: { + handle: H; + fence: DurableCaptureResourceFenceLease['envelope']['fence']; + resourcePath: string; + }, +): Promise { + return await withDurableCaptureResourceFence({ store: definition.store, - resourcePath, - expected: active.envelope.fence, + resourcePath: params.resourcePath, + expected: params.fence, run: async (lease) => { markCompleting(lease); - const result = await active.handle.finish(); - transitionFinishOutcome(definition, lease, result); - return result; + let finishOutcome: FinishOutcome; + try { + finishOutcome = await params.handle.finish(); + } catch (finishError) { + await compensateFailedFinish(definition, lease, params, finishError); + throw finishError; + } + if (finishOutcome.status === 'completed') { + transitionFinishOutcome(definition, lease, finishOutcome); + return finishOutcome.result; + } + transitionFinishOutcome(definition, lease, finishOutcome); + const finishError = cleanupPendingError(definition, finishOutcome); + await compensateFailedFinish(definition, lease, params, finishError); + throw finishError; + }, + }); +} + +async function compensateFailedFinish, C>( + definition: DurableCaptureResourceDefinition, + lease: DurableCaptureResourceFenceLease, + params: { handle: H; resourcePath: string }, + finishError: unknown, +): Promise { + let cleanup: CleanupOutcome; + try { + cleanup = await params.handle.forceCleanup(); + } catch (cleanupError) { + const transitionError = persistCleanupPendingBestEffort(lease, cleanupError); + emitFailedFinishCleanupDiagnostic( + definition, + params, + finishError, + cleanupError, + transitionError, + ); + return; + } + try { + transitionCleanupOutcome(lease, cleanup); + } catch (transitionError) { + emitFailedFinishCleanupDiagnostic( + definition, + params, + finishError, + cleanup.status === 'cleanup-pending' + ? (cleanup.message ?? cleanup.reason) + : `cleanup returned ${cleanup.status}`, + transitionError, + ); + return; + } + if (isConfirmedCleanup(cleanup)) return; + emitFailedFinishCleanupDiagnostic(definition, params, finishError, cleanup.message); +} + +function persistCleanupPendingBestEffort( + lease: DurableCaptureResourceFenceLease, + cleanupError: unknown, +): unknown | undefined { + try { + transitionCleanupOutcome(lease, { + status: 'cleanup-pending', + reason: 'cleanup-unconfirmed', + message: errorMessage(cleanupError), + }); + return undefined; + } catch (transitionError) { + return transitionError; + } +} + +function emitFailedFinishCleanupDiagnostic, C>( + definition: DurableCaptureResourceDefinition, + params: { resourcePath: string }, + finishError: unknown, + cleanupError: unknown, + transitionError?: unknown, +): void { + emitDiagnostic({ + level: 'error', + phase: `${definition.resourceKind.replaceAll('-', '_')}_finish_cleanup_failed`, + data: { + resourcePath: params.resourcePath, + finishError: errorMessage(finishError), + cleanupError: + cleanupError === undefined ? 'cleanup could not be confirmed' : errorMessage(cleanupError), + ...(transitionError === undefined ? {} : { transitionError: errorMessage(transitionError) }), }, }); - if (outcome.status === 'cleanup-pending') throw cleanupPendingError(definition, outcome); +} + +function clearLiveSlot, C>( + definition: DurableCaptureResourceDefinition, + params: { session: SessionState; sessionName: string; sessionStore: SessionStore }, +): void { params.sessionStore.set( params.sessionName, definition.sessionSlot.replace(params.session, undefined), ); - return outcome.result; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } export async function forceCleanupLiveDurableCapture< @@ -69,7 +194,7 @@ export async function forceCleanupLiveDurableCapture< return result; }, }); - if (!isConfirmedCleanup(outcome)) throw cleanupPendingError(definition, outcome); + requireConfirmedDurableCaptureCleanup(definition, outcome); if (params.sessionStore && params.sessionName) { params.sessionStore.set( params.sessionName, @@ -97,6 +222,16 @@ export function transitionCleanupOutcome( }); } +export function requireConfirmedDurableCaptureCleanup( + definition: Pick< + DurableCaptureResourceDefinition, unknown>, + 'displayName' | 'messages' + >, + outcome: CleanupOutcome, +): void { + if (!isConfirmedCleanup(outcome)) throw cleanupPendingError(definition, outcome); +} + function cleanupPendingError( definition: Pick< DurableCaptureResourceDefinition, unknown>, @@ -106,7 +241,8 @@ function cleanupPendingError( ): AppError { return new AppError( 'COMMAND_FAILED', - outcome.message ?? `${capitalize(definition.displayName)} cleanup could not be confirmed`, + outcome.message ?? + `${capitalizeDurableCaptureLabel(definition.displayName)} cleanup could not be confirmed`, { reason: outcome.reason, retriable: outcome.reason !== 'ownership-fence-lost', @@ -115,7 +251,7 @@ function cleanupPendingError( ); } -function transitionFinishOutcome, C>( +export function transitionFinishOutcome, C>( definition: DurableCaptureResourceDefinition, lease: DurableCaptureResourceFenceLease, outcome: FinishOutcome, @@ -138,7 +274,3 @@ function markCompleting(lease: DurableCaptureResourceFenceLeas metadata: { ...(lease.envelope.metadata ?? {}), phase: 'completing' }, }); } - -function capitalize(value: string): string { - return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1); -} diff --git a/src/daemon/durable-capture-resource.ts b/src/daemon/durable-capture-resource.ts index d156e5f112..27cf6e0158 100644 --- a/src/daemon/durable-capture-resource.ts +++ b/src/daemon/durable-capture-resource.ts @@ -19,6 +19,10 @@ import { recoverDurableCaptureResourcesAfterDaemonLock, type DurableCaptureRecoveryParams, } from './durable-capture-resource-recovery.ts'; +import { + finishRecoveredDurableCapture, + type FinishRecoveredDurableCaptureParams, +} from './durable-capture-resource-finish-recovered.ts'; import type { SessionStore } from './session-store.ts'; import type { SessionState } from './types.ts'; @@ -97,6 +101,9 @@ export function createDurableCaptureResource): Promise { + return finishRecoveredDurableCapture(definition, params); + }, forceCleanupLive(params: { session: SessionState; sessionName?: string; diff --git a/src/daemon/durable-capture-start-preflight.ts b/src/daemon/durable-capture-start-preflight.ts index ffc97ba9c0..be9087eca4 100644 --- a/src/daemon/durable-capture-start-preflight.ts +++ b/src/daemon/durable-capture-start-preflight.ts @@ -5,6 +5,7 @@ import { deviceIdentity, deviceIdentityKey, type DeviceInfo } from '@agent-devic import { AppError } from '@agent-device/kernel/errors'; import type { DurableCaptureAdmissionLedger } from './durable-capture-admission-ledger.ts'; import type { DurableCaptureResourceDefinition } from './durable-capture-resource.ts'; +import { capitalizeDurableCaptureLabel } from './durable-capture-resource-labels.ts'; export function createNextDurableCaptureFence, C>( definition: DurableCaptureResourceDefinition, @@ -34,7 +35,7 @@ function assertNoConflictingManifest ({ mockRunAppleRunnerCommand: vi.fn(), @@ -119,6 +120,13 @@ function makeSession(name: string): SessionState { return makeIosSession(name); } +function installTestScreenRecording( + session: SessionState, + overrides: Parameters[1] = {}, +): void { + session.screenRecording = makeTestScreenRecordingResource(session, overrides); +} + function makeAndroidSession(name: string): SessionState { return makeBaseAndroidSession(name, { appBundleId: 'com.android.settings' }); } @@ -1038,15 +1046,12 @@ test('press coordinates appends touch-visualization events while recording', asy createdAt: Date.now(), backend: 'xctest', }; - session.recording = { - platform: 'ios', + installTestScreenRecording(session, { + backend: 'simctl recordVideo', outPath: '/tmp/demo.mp4', startedAt: Date.now() - 1_000, showTouches: true, - gestureEvents: [], - child: { kill: () => {} } as any, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }; + }); sessionStore.set(sessionName, session); mockDispatch.mockResolvedValue({ @@ -1069,7 +1074,7 @@ test('press coordinates appends touch-visualization events while recording', asy }); expect(response?.ok).toBe(true); - const recorded = sessionStore.get(sessionName)?.recording; + const recorded = sessionStore.get(sessionName)?.screenRecording?.handle.inspect(); expect(recorded).toBeTruthy(); expect(recorded?.gestureEvents.length).toBe(4); expect(recorded?.gestureEvents[0]?.kind).toBe('tap'); @@ -1091,15 +1096,12 @@ test('press coordinates on iOS recording captures a full snapshot for the touch const sessionName = 'ios-direct-press-frame'; const session = makeSession(sessionName); session.snapshot = undefined; - session.recording = { - platform: 'ios', + installTestScreenRecording(session, { + backend: 'simctl recordVideo', outPath: '/tmp/demo.mp4', startedAt: Date.now() - 1_000, showTouches: true, - gestureEvents: [], - child: { kill: () => {} } as any, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }; + }); sessionStore.set(sessionName, session); mockDispatch.mockResolvedValue({ x: 220, y: 600 }); @@ -1140,7 +1142,7 @@ test('press coordinates on iOS recording captures a full snapshot for the touch expect(mockCaptureSnapshotForSession.mock.calls[0]?.[4]).toEqual({ interactiveOnly: true, }); - const event = sessionStore.get(sessionName)?.recording?.gestureEvents[0]; + const event = sessionStore.get(sessionName)?.screenRecording?.handle.inspect().gestureEvents[0]; expect(event?.kind).toBe('tap'); expect(event?.referenceWidth).toBe(440); expect(event?.referenceHeight).toBe(956); @@ -1150,15 +1152,12 @@ test('press coordinates on Android recording uses physical screen size when no s const sessionStore = makeSessionStore(); const sessionName = 'android-direct-press-frame'; const session = makeAndroidSession(sessionName); - session.recording = { - platform: 'android', + installTestScreenRecording(session, { + backend: 'adb screenrecord', outPath: '/tmp/demo.mp4', - remotePath: '/sdcard/demo.mp4', - remotePid: '1234', startedAt: Date.now() - 1_000, showTouches: true, - gestureEvents: [], - }; + }); session.snapshot = undefined; sessionStore.set(sessionName, session); @@ -1178,7 +1177,7 @@ test('press coordinates on Android recording uses physical screen size when no s }); expect(response?.ok).toBe(true); - const event = sessionStore.get(sessionName)?.recording?.gestureEvents[0]; + const event = sessionStore.get(sessionName)?.screenRecording?.handle.inspect().gestureEvents[0]; expect(event?.kind).toBe('tap'); expect(event?.referenceWidth).toBe(1344); expect(event?.referenceHeight).toBe(2992); @@ -1188,15 +1187,12 @@ test('press coordinates on Android recording caches physical screen size across const sessionStore = makeSessionStore(); const sessionName = 'android-direct-press-frame-cache'; const session = makeAndroidSession(sessionName); - session.recording = { - platform: 'android', + installTestScreenRecording(session, { + backend: 'adb screenrecord', outPath: '/tmp/demo.mp4', - remotePath: '/sdcard/demo.mp4', - remotePid: '1234', startedAt: Date.now() - 1_000, showTouches: true, - gestureEvents: [], - }; + }); session.snapshot = undefined; sessionStore.set(sessionName, session); @@ -1231,7 +1227,7 @@ test('press coordinates on Android recording caches physical screen size across }); expect(mockGetAndroidScreenSize).toHaveBeenCalledTimes(1); - const recording = sessionStore.get(sessionName)?.recording; + const recording = sessionStore.get(sessionName)?.screenRecording?.handle.inspect(); expect(recording?.touchReferenceFrame).toEqual({ referenceWidth: 1344, referenceHeight: 2992, @@ -1268,15 +1264,12 @@ test('press coordinates during recording still dispatches when Android screen-si const sessionStore = makeSessionStore(); const sessionName = 'android-direct-press-screen-size-failure'; const session = makeAndroidSession(sessionName); - session.recording = { - platform: 'android', + installTestScreenRecording(session, { + backend: 'adb screenrecord', outPath: '/tmp/demo.mp4', - remotePath: '/sdcard/demo.mp4', - remotePid: '1234', startedAt: Date.now() - 1_000, showTouches: true, - gestureEvents: [], - }; + }); session.snapshot = undefined; sessionStore.set(sessionName, session); @@ -1298,7 +1291,7 @@ test('press coordinates during recording still dispatches when Android screen-si expect(response?.ok).toBe(true); expect(mockDispatch).toHaveBeenCalledTimes(1); - const event = sessionStore.get(sessionName)?.recording?.gestureEvents[0]; + const event = sessionStore.get(sessionName)?.screenRecording?.handle.inspect().gestureEvents[0]; expect(event?.kind).toBe('tap'); expect(event?.x).toBe(300); expect(event?.y).toBe(2300); @@ -1325,15 +1318,12 @@ test('press @ref preserves native timing in recorded result and touch visualizat createdAt: Date.now(), backend: 'xctest', }; - session.recording = { - platform: 'ios', + installTestScreenRecording(session, { + backend: 'simctl recordVideo', outPath: '/tmp/demo.mp4', startedAt: 1_000, showTouches: true, - gestureEvents: [], - child: { kill: () => {} } as any, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }; + }); sessionStore.set(sessionName, session); const originalNow = Date.now; @@ -1371,7 +1361,7 @@ test('press @ref preserves native timing in recorded result and touch visualizat const result = (stored?.actions[0]?.result ?? {}) as Record; expect(result.gestureStartUptimeMs).toBe(5_100); expect(result.gestureEndUptimeMs).toBe(5_180); - expect(stored?.recording?.gestureEvents[0]?.tMs).toBe(570); + expect(stored?.screenRecording?.handle.inspect().gestureEvents[0]?.tMs).toBe(570); }); test('press @ref stores resolved coordinate retry payload for lazy outcome retry', async () => { @@ -2372,15 +2362,12 @@ test('fill @ref preserves fallback coordinates for recording when platform resul createdAt: Date.now(), backend: 'xctest', }; - session.recording = { - platform: 'ios', + installTestScreenRecording(session, { + backend: 'simctl recordVideo', outPath: '/tmp/demo.mp4', startedAt: Date.now() - 1_000, showTouches: true, - gestureEvents: [], - child: { kill: () => {} } as any, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }; + }); sessionStore.set(sessionName, session); mockDispatch.mockResolvedValue({ filled: true }); @@ -2414,7 +2401,7 @@ test('fill @ref preserves fallback coordinates for recording when platform resul expect(result.y).toBe(40); expect(Array.isArray(result.selectorChain)).toBe(true); - const event = stored?.recording?.gestureEvents[0]; + const event = stored?.screenRecording?.handle.inspect().gestureEvents[0]; expect(event?.kind).toBe('tap'); expect(event?.x).toBe(60); expect(event?.y).toBe(40); diff --git a/src/daemon/handlers/__tests__/network-runtime-harness.ts b/src/daemon/handlers/__tests__/network-runtime-harness.ts index 921014369d..383daf9ce6 100644 --- a/src/daemon/handlers/__tests__/network-runtime-harness.ts +++ b/src/daemon/handlers/__tests__/network-runtime-harness.ts @@ -47,6 +47,9 @@ export function createNetworkRuntime( appLogReattach: unavailable, appLogCleanup: unavailable, networkDump: networkFact, + screenRecordingStart: unavailable, + screenRecordingReattach: unavailable, + screenRecordingCleanup: unavailable, }, }, operations: networkFact.available ? { networkDump } : {}, diff --git a/src/daemon/handlers/__tests__/record-runtime-request.test.ts b/src/daemon/handlers/__tests__/record-runtime-request.test.ts new file mode 100644 index 0000000000..ee422dfcfe --- /dev/null +++ b/src/daemon/handlers/__tests__/record-runtime-request.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'vitest'; +import { prepareRecordingRequest, readRecordingScope } from '../record-runtime-request.ts'; + +test('normalizes recording flags while retaining explicit hide-touch intent', () => { + expect( + prepareRecordingRequest({ + token: 'token', + session: 'default', + command: 'record', + positionals: [], + flags: { recordingScope: 'device', hideTouches: true, fps: 30, quality: 'medium' }, + }), + ).toMatchObject({ + scope: 'device', + showTouches: false, + hideTouchesRequested: true, + fps: 30, + exportQuality: 'medium', + }); + expect( + prepareRecordingRequest({ + token: 'token', + session: 'default', + command: 'record', + positionals: [], + flags: {}, + }), + ).toMatchObject({ + scope: 'app', + showTouches: true, + hideTouchesRequested: false, + }); +}); + +test('rejects invalid scope, fps, and quality before runtime binding', () => { + expect(() => readRecordingScope('window')).toThrow('record scope must be app, device, or system'); + expect(() => + prepareRecordingRequest({ + token: 'token', + session: 'default', + command: 'record', + positionals: [], + flags: { fps: 0 }, + }), + ).toThrow('fps must be an integer between 1 and 120'); + expect(() => + prepareRecordingRequest({ + token: 'token', + session: 'default', + command: 'record', + positionals: [], + flags: { quality: 'huge' }, + }), + ).toThrow('quality must be one of'); +}); diff --git a/src/daemon/handlers/__tests__/record-runtime-response.test.ts b/src/daemon/handlers/__tests__/record-runtime-response.test.ts new file mode 100644 index 0000000000..07b2c4f630 --- /dev/null +++ b/src/daemon/handlers/__tests__/record-runtime-response.test.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { + buildRecordingStartResponse, + buildRecordingStopResponse, +} from '../record-runtime-response.ts'; + +test('start response preserves client output and neutral recording metadata', () => { + expect( + buildRecordingStartResponse( + { + backend: 'adb screenrecord', + outPath: '/daemon/capture.mp4', + clientOutPath: '/client/capture.mp4', + startedAt: 10, + scope: 'app', + showTouches: true, + recordOnlySession: false, + gestureEvents: [], + }, + '/state/session', + '/requested/capture.mp4', + ), + ).toMatchObject({ + ok: true, + data: { + recording: 'started', + outPath: '/client/capture.mp4', + sessionStateDir: '/state/session', + recordingBackend: 'adb screenrecord', + }, + }); +}); + +test('stop response derives client telemetry and chunk artifact paths', () => { + const response = buildRecordingStopResponse({ + backend: 'adb screenrecord', + outPath: '/daemon/capture.mp4', + clientOutPath: '/client/capture.mp4', + startedAt: 10, + completedAt: 25, + scope: 'app', + showTouches: true, + recordOnlySession: false, + telemetryPath: '/daemon/capture.gesture-telemetry.json', + chunks: [ + { index: 0, path: '/daemon/capture.mp4', clientOutPath: '/client/capture.mp4' }, + { index: 1, path: '/daemon/capture-1.mp4', clientOutPath: '/client/capture-1.mp4' }, + ], + }); + + if (!response.ok) throw new Error(JSON.stringify(response.error)); + expect(response.data?.artifacts).toContainEqual( + expect.objectContaining({ + field: 'telemetryPath', + path: '/daemon/capture.gesture-telemetry.json', + localPath: path.join('/client', 'capture.gesture-telemetry.json'), + }), + ); + expect(response.data?.artifacts).toContainEqual( + expect.objectContaining({ + field: 'chunkPath', + path: '/daemon/capture-1.mp4', + localPath: '/client/capture-1.mp4', + }), + ); +}); diff --git a/src/daemon/handlers/__tests__/record-runtime-start.test.ts b/src/daemon/handlers/__tests__/record-runtime-start.test.ts new file mode 100644 index 0000000000..93f7f046f2 --- /dev/null +++ b/src/daemon/handlers/__tests__/record-runtime-start.test.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { makeRecordRuntimeHarness } from './record-runtime.fixtures.ts'; + +test('record start adopts the runtime pair and projects unchanged response metadata', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-', { appBundleId: 'com.example.app' }); + const outPath = path.join(mkdtempForTestSync('record-runtime-output-'), 'capture.mp4'); + + const response = await harness.run(['start', outPath]); + + if (!response.ok) throw new Error(JSON.stringify(response.error)); + expect(response).toMatchObject({ + ok: true, + data: { + recording: 'started', + outPath, + recordingBackend: 'adb screenrecord', + recordingScope: 'app', + showTouches: true, + }, + }); + expect(harness.runtime.start).toHaveBeenCalledOnce(); + expect(harness.sessionStore.get(harness.sessionName)?.screenRecording?.handle).toBe( + harness.runtime.handle, + ); +}); + +test('record start preserves the requested path while the runtime receives the expanded native path', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-relative-output-'); + const cwd = mkdtempForTestSync('record-runtime-relative-output-cwd-'); + + const response = await harness.run(['start', 'capture.mp4'], { cwd }); + + expect(response).toMatchObject({ ok: true, data: { outPath: 'capture.mp4' } }); + expect(harness.runtime.start).toHaveBeenCalledWith( + expect.objectContaining({ outputPath: path.join(cwd, 'capture.mp4') }), + ); +}); + +test.each(['linux', 'vega'] as const)( + 'record start on %s reports public unsupported guidance', + async (platform) => { + const harness = makeRecordRuntimeHarness(`record-runtime-${platform}-unsupported-`, { + platform, + runtime: { + screenRecordingStartFact: { + available: false, + reason: 'unsupported-platform-leaf', + }, + }, + }); + + const response = await harness.run(['start', 'capture.mp4']); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'record is not supported on this device', + hint: 'Select an Apple, Android, physical HarmonyOS, or web target that supports screen recording.', + details: { reason: 'unsupported-platform-leaf' }, + }, + }); + expect(harness.runtime.start).not.toHaveBeenCalled(); + }, +); diff --git a/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts b/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts new file mode 100644 index 0000000000..f790da01c6 --- /dev/null +++ b/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts @@ -0,0 +1,125 @@ +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { screenRecordingResourceStore } from '../../screen-recording-resource-store.ts'; +import { + expectDecodedCompletedRecording, + makeRecordRuntimeHarness, + recordingResourcePath, +} from './record-runtime.fixtures.ts'; + +test('record stop with a live handle binds no runtime and terminalizes the durable record', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-live-stop-'); + const outPath = path.join(mkdtempForTestSync('record-runtime-live-stop-output-'), 'capture.mp4'); + await harness.run(['start', outPath]); + + const stopped = await harness.run(['stop']); + + expect(stopped).toMatchObject({ + ok: true, + data: { recording: 'stopped', outPath, recordingBackend: 'adb screenrecord' }, + }); + expect(harness.runtime.finish).toHaveBeenCalledOnce(); + expect(harness.runtime.bindExactDeviceCalls).not.toHaveBeenCalled(); + expect(harness.sessionStore.get(harness.sessionName)?.screenRecording).toBeUndefined(); +}); + +test('record stop preserves a finish failure after confirmed compensating cleanup', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-failed-live-stop-', { + runtime: { finishError: new Error('final copy failed') }, + }); + await harness.run(['start', 'failed-copy.mp4']); + + const stopped = await harness.run(['stop']); + + expect(stopped).toMatchObject({ + ok: false, + error: { code: 'UNKNOWN', message: 'final copy failed' }, + }); + expect(harness.runtime.finish).toHaveBeenCalledOnce(); + expect(harness.runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(harness.sessionStore.get(harness.sessionName)?.screenRecording).toBeUndefined(); + expectDecodedCompletedRecording(harness.sessionStore, harness.sessionName); + + await expect(harness.run(['start', 'replacement.mp4'])).resolves.toMatchObject({ + ok: true, + data: { recording: 'started' }, + }); +}); + +test('record stop after daemon-state loss reattaches only through the persisted exact owner', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-recovered-stop-', { + runtime: { reattachActive: true }, + }); + const outPath = path.join( + mkdtempForTestSync('record-runtime-recovered-stop-output-'), + 'capture.mp4', + ); + await harness.run(['start', outPath]); + const adopted = harness.sessionStore.get(harness.sessionName); + if (!adopted) throw new Error('Expected adopted recording session'); + harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + + const stopped = await harness.run(['stop']); + + expect(stopped).toMatchObject({ ok: true, data: { recording: 'stopped', outPath } }); + expect(harness.runtime.bindExactDeviceCalls).toHaveBeenCalledOnce(); + expect(harness.runtime.reattach).toHaveBeenCalledOnce(); + expect(harness.runtime.finish).toHaveBeenCalledOnce(); +}); + +test('record stop terminalizes cleanup-only exact recovery without starting a replacement', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-cleanup-only-stop-', { + recordOnlySession: true, + runtime: { reattachUnreattachable: true }, + }); + const outPath = path.join( + mkdtempForTestSync('record-runtime-cleanup-only-output-'), + 'capture.mp4', + ); + await harness.run(['start', outPath]); + const adopted = harness.sessionStore.get(harness.sessionName); + if (!adopted) throw new Error('Expected adopted recording session'); + harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + + const stopped = await harness.run(['stop']); + + expect(stopped).toMatchObject({ + ok: false, + error: { code: 'COMMAND_FAILED', details: { reason: 'transport-not-reattachable' } }, + }); + expect(harness.runtime.cleanup).toHaveBeenCalledOnce(); + expectDecodedCompletedRecording(harness.sessionStore, harness.sessionName); + expect(harness.sessionStore.get(harness.sessionName)).toBeUndefined(); + expect(harness.runtime.start).toHaveBeenCalledOnce(); +}); + +test('record stop rejects a cross-session recovery manifest before exact-owner binding', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-cross-session-', { + sessionName: 'recording-a', + runtime: { reattachActive: true }, + }); + const outPath = path.join( + mkdtempForTestSync('record-runtime-cross-session-output-'), + 'capture.mp4', + ); + await harness.run(['start', outPath]); + const resourcePath = recordingResourcePath(harness.sessionStore, harness.sessionName); + const record = screenRecordingResourceStore.read(resourcePath); + if (record.status !== 'decoded') throw new Error('Expected decoded recording manifest'); + screenRecordingResourceStore.write(resourcePath, { + ...record.envelope, + sessionId: 'recording-b', + }); + const adopted = harness.sessionStore.get(harness.sessionName); + if (!adopted) throw new Error('Expected adopted recording session'); + harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + + const stopped = await harness.run(['stop']); + + expect(stopped).toMatchObject({ + ok: false, + error: { code: 'COMMAND_FAILED', details: { reason: 'runtime-contract-invalid' } }, + }); + expect(harness.runtime.bindExactDeviceCalls).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts new file mode 100644 index 0000000000..d92681f47e --- /dev/null +++ b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts @@ -0,0 +1,231 @@ +import { expect, vi } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + PendingTransferGuard, + type CleanupOutcome, + type DeviceBinding, + type PlatformRuntimeOperations, + type RuntimeOperationUnavailability, + type ScreenRecordingLiveHandle, +} from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { createScreenRecordingAdmissionLedger } from '../../screen-recording-admission-ledger.ts'; +import { screenRecordingResourceStore } from '../../screen-recording-resource-store.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../../request-runtime-binding.ts'; +import type { SessionState } from '../../types.ts'; +import { handleRecordCommand } from '../record-runtime.ts'; + +type RuntimeOptions = { + reattachActive?: boolean; + reattachUnreattachable?: boolean; + finishError?: Error; + cleanup?: CleanupOutcome; + screenRecordingStartFact?: RuntimeOperationUnavailability; +}; + +export function makeRecordRuntimeHarness( + prefix: string, + options: { + sessionName?: string; + appBundleId?: string; + recordOnlySession?: boolean; + platform?: 'android' | 'linux' | 'vega'; + runtime?: RuntimeOptions; + } = {}, +) { + const sessionStore = makeSessionStore(prefix); + const sessionName = options.sessionName ?? 'recording'; + const session: SessionState = { + name: sessionName, + device: + options.platform === 'linux' + ? { platform: 'linux', id: 'linux', name: 'Linux', kind: 'device', target: 'desktop' } + : options.platform === 'vega' + ? { platform: 'vega', id: 'vega', name: 'Vega', kind: 'device', target: 'tv' } + : { platform: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' }, + ...(options.appBundleId ? { appBundleId: options.appBundleId } : {}), + ...(options.recordOnlySession ? { recordOnlySession: true } : {}), + createdAt: 1, + actions: [], + }; + sessionStore.set(sessionName, session); + const runtime = makeRuntime(session, options.runtime); + const common = { + sessionName, + sessionStore, + bindDevice: runtime.bindDevice, + bindExactDevice: runtime.bindExactDevice, + admissionLedger: createScreenRecordingAdmissionLedger(), + requestScope: testRequestScope(), + retainDeviceExecutionLock: async () => {}, + throwIfCanceled: () => {}, + }; + + return { + session, + sessionName, + sessionStore, + runtime, + run: (positionals: string[], meta?: { cwd: string }) => + handleRecordCommand({ + ...common, + req: { + token: 'token', + session: sessionName, + command: 'record', + positionals, + flags: {}, + ...(meta ? { meta } : {}), + }, + }), + }; +} + +export function expectDecodedCompletedRecording( + sessionStore: ReturnType, + sessionName: string, +): void { + expect( + screenRecordingResourceStore.read(recordingResourcePath(sessionStore, sessionName)), + ).toMatchObject({ status: 'decoded', envelope: { lifecycle: 'completed' } }); +} + +export function recordingResourcePath( + sessionStore: ReturnType, + sessionName: string, +): string { + return screenRecordingResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)); +} + +function makeRuntime(session: SessionState, options: RuntimeOptions = {}) { + const owner = localRuntimeOwner(session.device.platform); + let currentOutPath = ''; + const handle: ScreenRecordingLiveHandle = { + inspect: () => ({ + backend: 'adb screenrecord', + outPath: currentOutPath, + startedAt: 1, + scope: 'app', + showTouches: true, + recordOnlySession: false, + gestureEvents: [], + }), + appendGestureEvents: () => {}, + setTouchReferenceFrame: () => {}, + setRunnerSessionId: () => {}, + invalidate: () => {}, + finish: vi.fn(async () => { + if (options.finishError) throw options.finishError; + return { + status: 'completed' as const, + result: { + backend: 'adb screenrecord', + outPath: currentOutPath, + startedAt: 1, + completedAt: 2, + scope: 'app' as const, + showTouches: true, + recordOnlySession: false, + }, + }; + }), + forceCleanup: vi.fn(async () => options.cleanup ?? { status: 'cleaned' as const }), + [Symbol.asyncDispose]: async () => {}, + }; + const start = vi.fn( + async (input: Parameters[0]) => { + currentOutPath = input.outputPath; + return { + pendingHandle: new PendingTransferGuard(handle), + envelope: createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: input.sessionId, + device: { + id: session.device.id, + family: session.device.platform, + kind: session.device.kind, + ...(session.device.target === undefined ? {} : { target: session.device.target }), + }, + owner, + fence: input.fence, + lifecycle: 'open', + descriptor: { version: 1, body: { recordingId: 'id' } }, + }), + }; + }, + ); + const reattach = vi.fn(async () => { + if (options.reattachActive) return { status: 'active', handle } as const; + if (options.reattachUnreattachable) { + return { + status: 'unreattachable', + reason: 'transport-not-reattachable', + message: 'Live recording control cannot be reconstructed', + } as const; + } + return { status: 'missing' } as const; + }); + const cleanup = vi.fn(async () => ({ status: 'cleaned' as const })); + const operations = { + screenRecordingStart: start, + screenRecordingReattach: reattach, + screenRecordingCleanup: cleanup, + }; + const binding: DeviceBinding = { + device: session.device, + owner, + facts: { + device: { + family: session.device.platform, + kind: session.device.kind, + ...(session.device.target === undefined ? {} : { target: session.device.target }), + providerMode: 'local', + }, + operations: { + appLogInspect: unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + networkDump: unavailable, + screenRecordingStart: options.screenRecordingStartFact ?? { available: true }, + screenRecordingReattach: { available: true }, + screenRecordingCleanup: { available: true }, + }, + }, + operations, + [Symbol.asyncDispose]: async () => {}, + }; + const bindDevice: BindDeviceRuntime = async (_device, use) => narrowDeviceBinding(binding, use); + const bindExactDeviceCalls = vi.fn(); + const bindExactDevice: BindExactDeviceRuntime = async (device, ownerRef, fence, use, scope) => { + bindExactDeviceCalls(device, ownerRef, fence, use, scope); + return narrowDeviceBinding(binding, use); + }; + return { + bindDevice, + bindExactDevice, + bindExactDeviceCalls, + handle, + start, + reattach, + cleanup, + finish: handle.finish, + forceCleanup: handle.forceCleanup, + }; +} + +const unavailable = Object.freeze({ + available: false as const, + reason: 'owner-capability-missing' as const, +}); + +function testRequestScope() { + return { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }; +} diff --git a/src/daemon/handlers/__tests__/record-trace-harmony.test.ts b/src/daemon/handlers/__tests__/record-trace-harmony.test.ts deleted file mode 100644 index 3348646fc1..0000000000 --- a/src/daemon/handlers/__tests__/record-trace-harmony.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { beforeEach, test, vi } from 'vitest'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; - -vi.mock('../../../utils/exec.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, runCmd: vi.fn() }; -}); - -vi.mock('../../../utils/video.ts', () => ({ - waitForStableFile: vi.fn(async () => {}), - isPlayableVideo: vi.fn(async () => true), -})); - -import { runCmd } from '../../../utils/exec.ts'; -import { isPlayableVideo } from '../../../utils/video.ts'; -import { SessionStore } from '../../session-store.ts'; -import type { DaemonRequest, SessionState } from '../../types.ts'; -import { handleRecordTraceCommands } from '../record-trace.ts'; -import { parseHarmonyFileSize, parseHarmonyMediaUri } from '../record-trace-harmony.ts'; - -const mockRunCmd = vi.mocked(runCmd); -const mockIsPlayableVideo = vi.mocked(isPlayableVideo); - -function makeStore(): SessionStore { - return new SessionStore( - path.join(mkdtempForTestSync('agent-device-harmony-record-'), 'sessions'), - ); -} - -function makeSession(kind: 'device' | 'emulator' = 'device'): SessionState { - return { - name: 'harmony-record', - device: { - platform: 'harmonyos', - id: `harmony-${kind}-1`, - name: `Harmony ${kind}`, - kind, - target: 'mobile', - booted: true, - }, - createdAt: Date.now(), - actions: [], - }; -} - -function request(action: 'start' | 'stop', outPath?: string): DaemonRequest { - return { - token: 'test', - session: 'harmony-record', - command: 'record', - positionals: [action, ...(outPath ? [outPath] : [])], - flags: { recordingScope: 'device' }, - }; -} - -function assertHdcCommand(calls: unknown[][], tokens: string[], message: string): void { - assert.ok( - calls.some((args) => tokens.every((token) => args.includes(token))), - message, - ); -} - -type RecordingResponseData = { - recording?: string; - recordingBackend?: string; - outPath?: string; -}; - -function requireRecordingResponse( - response: Awaited>, -): RecordingResponseData { - if (!response) { - assert.fail('Expected a recording response'); - } - if (!response.ok) { - assert.fail(response.error.message); - } - return response.data as RecordingResponseData; -} - -function recordingPlatform(store: SessionStore, sessionName: string): string | undefined { - return store.get(sessionName)?.recording?.platform; -} - -beforeEach(() => { - mockRunCmd.mockReset(); - mockIsPlayableVideo.mockReset(); - mockIsPlayableVideo.mockResolvedValue(true); - mockRunCmd.mockImplementation(async (_command, args) => { - if (args.includes('query')) { - return { - exitCode: 0, - stdout: 'find 1 result\nuri\n"file://media/Photo/1/VID_1/recording.mp4"\n', - stderr: '', - } as Awaited>; - } - if (args.includes('ls')) { - return { - exitCode: 0, - stdout: '-rw-r--r-- 1 shell shell 1234 2026-08-08 12:00 /data/local/tmp/recording.mp4\n', - stderr: '', - } as Awaited>; - } - return { exitCode: 0, stdout: 'start ability successfully.\n', stderr: '' } as Awaited< - ReturnType - >; - }); -}); - -test('parseHarmonyMediaUri extracts a quoted media-library URI', () => { - assert.equal( - parseHarmonyMediaUri('find 1 result\nuri\n"file://media/Photo/1/VID_1/clip.mp4"'), - 'file://media/Photo/1/VID_1/clip.mp4', - ); - assert.equal(parseHarmonyMediaUri('find 0 result'), undefined); -}); - -test('parseHarmonyFileSize accepts non-empty shell ls output only', () => { - assert.equal( - parseHarmonyFileSize('-rw-r--r-- 1 shell shell 1234 2026-08-08 12:00 /data/local/tmp/clip.mp4'), - 1234, - ); - assert.equal(parseHarmonyFileSize(''), undefined); -}); - -test('physical HarmonyOS recording starts, retrieves a playable MP4, and cleans device artifacts', async () => { - const store = makeStore(); - const session = makeSession(); - store.set(session.name, session); - const outPath = path.join(mkdtempForTestSync('agent-device-harmony-video-'), 'capture.mp4'); - - const started = await handleRecordTraceCommands({ - req: request('start', outPath), - sessionName: session.name, - sessionStore: store, - }); - const startedData = requireRecordingResponse(started); - assert.equal(startedData.recording, 'started'); - assert.equal(startedData.recordingBackend, 'HarmonyOS ScreenRecorder'); - assert.equal(recordingPlatform(store, session.name), 'harmonyos'); - - const stopped = await handleRecordTraceCommands({ - req: request('stop'), - sessionName: session.name, - sessionStore: store, - }); - const stoppedData = requireRecordingResponse(stopped); - assert.equal(stoppedData.recording, 'stopped'); - assert.equal(stoppedData.outPath, outPath); - assert.equal(store.get(session.name)?.recording, undefined); - assert.equal(mockIsPlayableVideo.mock.calls[0]![0], outPath); - - const hdcCalls = mockRunCmd.mock.calls.map(([, args]) => args); - assertHdcCommand( - hdcCalls, - ['CustomizedFileName'], - 'start passes the unique media-library filename to ScreenRecorder', - ); - assertHdcCommand( - hdcCalls, - ['mediatool', 'recv'], - 'stop exports media-library content to the temporary device path', - ); - assertHdcCommand(hdcCalls, ['file', 'recv'], 'stop retrieves the MP4 through HDC file transfer'); - assertHdcCommand( - hdcCalls, - ['mediatool', 'delete'], - 'successful retrieval deletes the media-library entry', - ); -}); - -test('HarmonyOS recording rejects app scope and simulator devices before HDC is invoked', async () => { - const physicalStore = makeStore(); - const physical = makeSession(); - physicalStore.set(physical.name, physical); - const appScope = await handleRecordTraceCommands({ - req: { ...request('start'), flags: {} }, - sessionName: physical.name, - sessionStore: physicalStore, - }); - assert.equal(appScope?.ok, false); - assert.match(appScope?.error?.message ?? '', /whole physical-device screen/); - - const emulatorStore = makeStore(); - const emulator = makeSession('emulator'); - emulatorStore.set(emulator.name, emulator); - const emulatorResponse = await handleRecordTraceCommands({ - req: request('start'), - sessionName: emulator.name, - sessionStore: emulatorStore, - }); - assert.equal(emulatorResponse?.ok, false); - assert.equal(emulatorResponse?.error?.code, 'UNSUPPORTED_OPERATION'); - assert.equal(mockRunCmd.mock.calls.length, 0); -}); diff --git a/src/daemon/handlers/__tests__/record-trace-ios-simulator-recording.test.ts b/src/daemon/handlers/__tests__/record-trace-ios-simulator-recording.test.ts deleted file mode 100644 index 38bf582931..0000000000 --- a/src/daemon/handlers/__tests__/record-trace-ios-simulator-recording.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; - -import { afterEach, test, vi } from 'vitest'; - -import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; -import type { RecordTraceDeps } from '../record-trace-types.ts'; -import { startIosSimulatorRecording } from '../record-trace-ios-simulator-recording.ts'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; - -const temporaryRoots: string[] = []; - -afterEach(() => { - vi.useRealTimers(); - for (const root of temporaryRoots.splice(0)) { - fs.rmSync(root, { force: true, recursive: true }); - } -}); - -test('startIosSimulatorRecording waits for the zero-byte simctl destination before reporting ready', async () => { - vi.useFakeTimers(); - const testStartedAt = Date.now(); - const root = makeTemporaryRoot('agent-device-record-ready-'); - const outPath = path.join(root, 'recording.mp4'); - const kill = vi.fn((_signal?: NodeJS.Signals) => true); - const wait = new Promise(() => {}); - setTimeout(() => fs.writeFileSync(outPath, ''), 600); - - const resultPromise = startIosSimulatorRecording({ - req: { - token: 'test-token', - session: 'record-ready', - command: 'record', - positionals: ['start', outPath], - flags: {}, - }, - activeSession: { - name: 'record-ready', - device: IOS_SIMULATOR, - createdAt: Date.now(), - actions: [], - appBundleId: 'com.example.app', - }, - device: IOS_SIMULATOR, - deps: makeDeps({ - startIosSimulatorRecording: () => ({ - child: { kill, pid: 1234 }, - wait, - }), - }), - recordingBase: { - outPath, - startedAt: 0, - showTouches: false, - gestureEvents: [], - }, - resolvedOut: outPath, - }); - - await vi.advanceTimersByTimeAsync(800); - const result = await resultPromise; - assert.ok(!('ok' in result), JSON.stringify(result)); - assert.equal(result.platform, 'ios'); - assert.equal(result.startedAt, testStartedAt + 750); - assert.equal(kill.mock.calls.length, 0); -}); - -test('startIosSimulatorRecording reports an early recorder exit instead of a false start', async () => { - const root = makeTemporaryRoot('agent-device-record-exit-'); - const outPath = path.join(root, 'recording.mp4'); - const result = await startIosSimulatorRecording({ - req: { - token: 'test-token', - session: 'record-exit', - command: 'record', - positionals: ['start', outPath], - flags: {}, - }, - activeSession: { - name: 'record-exit', - device: IOS_SIMULATOR, - createdAt: Date.now(), - actions: [], - appBundleId: 'com.example.app', - }, - device: IOS_SIMULATOR, - deps: makeDeps({ - startIosSimulatorRecording: () => ({ - child: { kill: vi.fn(() => true), pid: 1234 }, - wait: Promise.resolve({ stdout: '', stderr: 'capture unavailable', exitCode: 1 }), - }), - }), - recordingBase: { - outPath, - startedAt: 0, - showTouches: false, - gestureEvents: [], - }, - resolvedOut: outPath, - }); - - assert.equal('ok' in result && result.ok, false); - assert.match(JSON.stringify(result), /failed to start recording: capture unavailable/); -}); - -test('startIosSimulatorRecording escalates cleanup when its wait monitor rejects', async () => { - const root = makeTemporaryRoot('agent-device-record-wait-failed-'); - const outPath = path.join(root, 'recording.mp4'); - const kill = vi.fn((_signal?: NodeJS.Signals) => true); - const result = await startIosSimulatorRecording({ - req: { - token: 'test-token', - session: 'record-wait-failed', - command: 'record', - positionals: ['start', outPath], - flags: {}, - }, - activeSession: { - name: 'record-wait-failed', - device: IOS_SIMULATOR, - createdAt: Date.now(), - actions: [], - appBundleId: 'com.example.app', - }, - device: IOS_SIMULATOR, - deps: makeDeps({ - startIosSimulatorRecording: () => ({ - child: { kill, pid: 1234 }, - wait: Promise.reject(new Error('recorder wait monitor failed')), - }), - }), - recordingBase: { - outPath, - startedAt: 0, - showTouches: false, - gestureEvents: [], - }, - resolvedOut: outPath, - }); - - assert.equal('ok' in result && result.ok, false); - assert.match(JSON.stringify(result), /recorder wait monitor failed/); - assert.deepEqual(kill.mock.calls, [['SIGINT'], ['SIGTERM'], ['SIGKILL']]); -}); - -test.each([ - { exitCode: 0, exitDelayMs: 0 }, - { exitCode: 1, exitDelayMs: 0 }, - { exitCode: 1, exitDelayMs: 25 }, -])( - 'startIosSimulatorRecording rejects recorder exit code $exitCode after $exitDelayMs ms when its destination exists', - async ({ exitCode, exitDelayMs }) => { - if (exitDelayMs > 0) vi.useFakeTimers(); - const root = makeTemporaryRoot('agent-device-record-file-exit-'); - const outPath = path.join(root, 'recording.mp4'); - fs.writeFileSync(outPath, ''); - const wait = - exitDelayMs === 0 - ? Promise.resolve({ stdout: '', stderr: 'recorder exited', exitCode }) - : new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { - setTimeout( - () => resolve({ stdout: '', stderr: 'recorder exited', exitCode }), - exitDelayMs, - ); - }); - - const resultPromise = startIosSimulatorRecording({ - req: { - token: 'test-token', - session: 'record-file-exit', - command: 'record', - positionals: ['start', outPath], - flags: {}, - }, - activeSession: { - name: 'record-file-exit', - device: IOS_SIMULATOR, - createdAt: Date.now(), - actions: [], - appBundleId: 'com.example.app', - }, - device: IOS_SIMULATOR, - deps: makeDeps({ - startIosSimulatorRecording: () => ({ - child: { kill: vi.fn(() => true), pid: 1234 }, - wait, - }), - }), - recordingBase: { - outPath, - startedAt: 0, - showTouches: false, - gestureEvents: [], - }, - resolvedOut: outPath, - }); - if (exitDelayMs > 0) await vi.advanceTimersByTimeAsync(50); - const result = await resultPromise; - - assert.equal('ok' in result && result.ok, false); - assert.match(JSON.stringify(result), /failed to start recording/); - assert.equal(fs.existsSync(outPath), false); - }, -); - -test('startIosSimulatorRecording times out and cleans up a recorder that never becomes ready', async () => { - vi.useFakeTimers(); - const root = makeTemporaryRoot('agent-device-record-timeout-'); - const outPath = path.join(root, 'recording.mp4'); - const kill = vi.fn((_signal?: NodeJS.Signals) => true); - const resultPromise = startIosSimulatorRecording({ - req: { - token: 'test-token', - session: 'record-timeout', - command: 'record', - positionals: ['start', outPath], - flags: {}, - }, - activeSession: { - name: 'record-timeout', - device: IOS_SIMULATOR, - createdAt: Date.now(), - actions: [], - appBundleId: 'com.example.app', - }, - device: IOS_SIMULATOR, - deps: makeDeps({ - startIosSimulatorRecording: () => ({ - child: { kill, pid: undefined }, - wait: new Promise(() => {}), - }), - }), - recordingBase: { - outPath, - startedAt: 0, - showTouches: false, - gestureEvents: [], - }, - resolvedOut: outPath, - }); - - // Let the readiness loop schedule its first fake-timer poll before advancing - // the clock. Unlike the success case above, this test has no independent - // timer to yield that initial microtask turn. - await Promise.resolve(); - await vi.runAllTimersAsync(); - const result = await resultPromise; - - assert.equal('ok' in result && result.ok, false); - assert.match(JSON.stringify(result), /did not create its output within 15000ms/); - assert.deepEqual( - kill.mock.calls.map(([signal]) => signal), - ['SIGINT', 'SIGTERM', 'SIGKILL'], - ); - assert.equal(fs.existsSync(outPath), false); -}); - -function makeDeps(overrides: Pick): RecordTraceDeps { - return { - runCmd: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - startIosSimulatorRecording: overrides.startIosSimulatorRecording, - runAppleRunnerCommand: async () => ({}), - waitForRecordingTail: async () => {}, - waitForStableFile: async () => {}, - isPlayableVideo: async () => true, - trimRecordingStart: async () => {}, - overlayRecordingTouches: async () => {}, - }; -} - -function makeTemporaryRoot(prefix: string): string { - const root = mkdtempForTestSync(prefix); - temporaryRoots.push(root); - return root; -} diff --git a/src/daemon/handlers/__tests__/record-trace-ios.test.ts b/src/daemon/handlers/__tests__/record-trace-ios.test.ts deleted file mode 100644 index 05992f67cc..0000000000 --- a/src/daemon/handlers/__tests__/record-trace-ios.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { IOS_DEVICE } from '../../../__tests__/test-utils/device-fixtures.ts'; -import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; -import type { RunnerCommand } from '../../../platforms/apple/core/runner/runner-contract.ts'; -import type { RecordTraceDeps } from '../record-trace-types.ts'; -import { startIosDeviceRecording } from '../record-trace-ios.ts'; - -test('startIosDeviceRecording stops stale runner recording and retries with the same request id', async () => { - const sessionStore = makeSessionStore('agent-device-record-trace-ios-'); - const activeSession = { - name: 'default', - createdAt: Date.now(), - actions: [], - device: IOS_DEVICE, - appBundleId: 'com.example.app', - }; - sessionStore.set('default', activeSession); - - const runnerCalls: Array<{ command: RunnerCommand; requestId?: string }> = []; - const deps: RecordTraceDeps = { - runCmd: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - startIosSimulatorRecording: () => { - throw new Error('not used'); - }, - runAppleRunnerCommand: async (_device, command, options) => { - runnerCalls.push({ command, requestId: options?.requestId }); - if (command.command === 'recordStart' && runnerCalls.length === 1) { - throw new Error('recording already in progress'); - } - return { recorderStartUptimeMs: 100, targetAppReadyUptimeMs: 130 }; - }, - waitForRecordingTail: async () => {}, - waitForStableFile: async () => {}, - isPlayableVideo: async () => true, - trimRecordingStart: async () => {}, - overlayRecordingTouches: async () => {}, - }; - - const result = await startIosDeviceRecording({ - req: { - token: 'test-token', - session: 'default', - command: 'record', - positionals: ['start', '/tmp/recording.mp4'], - flags: {}, - meta: { requestId: 'req-stale-recording' }, - }, - activeSession, - sessionStore, - device: IOS_DEVICE, - deps, - fpsFlag: undefined, - recordingBase: { - outPath: '/tmp/recording.mp4', - startedAt: 1, - showTouches: true, - gestureEvents: [], - }, - appBundleId: 'com.example.app', - }); - - assert.deepEqual( - runnerCalls.map((call) => ({ - command: call.command.command, - requestId: call.requestId, - })), - [ - { command: 'recordStart', requestId: 'req-stale-recording' }, - { command: 'recordStop', requestId: 'req-stale-recording' }, - { command: 'recordStart', requestId: 'req-stale-recording' }, - ], - ); - if ('ok' in result) { - assert.fail(`expected recording state, got response: ${JSON.stringify(result)}`); - } - assert.equal(result.platform, 'ios-device-runner'); - assert.equal(result.runnerStartedAtUptimeMs, 100); - assert.equal(result.targetAppReadyUptimeMs, 130); -}); diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts deleted file mode 100644 index 6f0788762b..0000000000 --- a/src/daemon/handlers/__tests__/record-trace.test.ts +++ /dev/null @@ -1,2414 +0,0 @@ -import { test, expect, vi, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; - -vi.mock('../../../utils/exec.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), - runCmdBackground: vi.fn(() => ({ - child: { kill: vi.fn() }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - })), - }; -}); - -vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - runAppleRunnerCommand: vi.fn(async () => ({})), - }; -}); - -vi.mock('../../../utils/video.ts', () => ({ - waitForStableFile: vi.fn(async () => {}), - isPlayableVideo: vi.fn(async () => true), -})); - -vi.mock('../../../recording/overlay.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - trimRecordingStart: vi.fn(async () => {}), - overlayRecordingTouches: vi.fn(async () => {}), - }; -}); - -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveTargetDevice: vi.fn(async () => { - throw new Error('resolveTargetDevice should not run'); - }), - }; -}); - -vi.mock('../../device-ready.ts', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); - -import { handleRecordTraceCommands } from '../record-trace.ts'; -import { stopSessionRecordingForTeardown } from '../record-trace-recording.ts'; -import { deriveRecordingTelemetryPath } from '../../recording-telemetry.ts'; -import { SessionStore } from '../../session-store.ts'; -import type { DaemonRequest, SessionState } from '../../types.ts'; -import { - IOS_RUNNER_CONTAINER_BUNDLE_IDS, - runAppleRunnerCommand, -} from '../../../platforms/apple/core/runner/runner-client.ts'; -import { - getRecordingOverlaySupportWarning, - trimRecordingStart, - overlayRecordingTouches, -} from '../../../recording/overlay.ts'; -import { resolveTargetDevice } from '../../../core/dispatch.ts'; -import { ensureDeviceReady } from '../../device-ready.ts'; -import { runCmd, runCmdBackground } from '../../../utils/exec.ts'; -import { isPlayableVideo, waitForStableFile } from '../../../utils/video.ts'; -import { withWebProvider, type WebProvider } from '../../../platforms/web/provider.ts'; - -type RunnerCall = { - command: string; - outPath?: string; - fps?: number; - appBundleId?: string; - logPath?: string; - traceLogPath?: string; -}; - -const mockRunCmd = vi.mocked(runCmd); -const mockRunCmdBackground = vi.mocked(runCmdBackground); -const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); -const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); -const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); -const mockTrimRecordingStart = vi.mocked(trimRecordingStart); -const mockOverlayRecordingTouches = vi.mocked(overlayRecordingTouches); -const mockWaitForStableFile = vi.mocked(waitForStableFile); -const mockIsPlayableVideo = vi.mocked(isPlayableVideo); - -const overlaySupportWarning = getRecordingOverlaySupportWarning(); -const mockedIosRecordingOutputs: string[] = []; - -function makeSessionStore(): SessionStore { - const root = mkdtempForTestSync('agent-device-record-trace-'); - return new SessionStore(path.join(root, 'sessions')); -} - -function makeSession(name: string, device: SessionState['device']): SessionState { - return { - name, - device, - createdAt: Date.now(), - actions: [], - }; -} - -function makeIosDeviceSession(name: string, appBundleId?: string): SessionState { - const session = makeSession(name, { - platform: 'apple', - id: 'ios-device-1', - name: 'My iPhone', - kind: 'device', - booted: true, - }); - if (appBundleId) { - session.appBundleId = appBundleId; - } - return session; -} - -function makeIosSimulatorSession(name: string): SessionState { - return makeSession(name, { - platform: 'apple', - id: 'ios-sim-1', - name: 'iPhone 16', - kind: 'simulator', - booted: true, - }); -} - -function makeOpenedIosSimulatorSession(name: string): SessionState { - const session = makeIosSimulatorSession(name); - session.appBundleId = 'com.apple.Preferences'; - session.appName = 'Settings'; - return session; -} - -function makeWebSession(name: string): SessionState { - return makeSession(name, { - platform: 'web', - id: 'agent-browser-chrome', - name: 'Agent Browser Chrome', - kind: 'device', - target: 'desktop', - booted: true, - }); -} - -function makeWebProvider(overrides: Partial = {}): WebProvider { - return { - open: async () => {}, - close: async () => {}, - startRecording: async () => {}, - stopRecording: async () => {}, - snapshot: async () => ({ nodes: [] }), - screenshot: async () => {}, - setViewport: async () => {}, - click: async () => {}, - fill: async () => {}, - typeText: async () => {}, - scroll: async () => {}, - ...overrides, - }; -} - -function makeIosSimulatorRecordingSession( - name: string, - options: { - appBundleId?: string; - outPath?: string; - recordOnlySession?: boolean; - startedAt?: number; - } = {}, -): SessionState { - const session = makeIosSimulatorSession(name); - if (options.appBundleId) { - session.appBundleId = options.appBundleId; - } - if (options.recordOnlySession) { - session.recordOnlySession = true; - } - session.recording = { - platform: 'ios', - child: { kill: vi.fn(), pid: 123 }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - outPath: options.outPath ?? path.join(os.tmpdir(), `${name}.mp4`), - startedAt: options.startedAt ?? Date.now(), - showTouches: false, - gestureEvents: [], - }; - return session; -} - -function mockIosSimulatorRecordingStart( - options: { pid?: number; onStart?: () => void } = {}, -): void { - mockRunCmdBackground.mockImplementation((_cmd, args) => { - options.onStart?.(); - const outPath = args.at(-1); - if (!outPath) throw new Error('simctl recordVideo output path is required'); - const resolvedOutPath = path.resolve(outPath); - fs.writeFileSync(resolvedOutPath, ''); - mockedIosRecordingOutputs.push(resolvedOutPath); - let resolveWait: - | ((value: { stdout: string; stderr: string; exitCode: number }) => void) - | undefined; - const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { - resolveWait = resolve; - }); - return { - child: { - kill: () => { - resolveWait?.({ stdout: '', stderr: '', exitCode: 0 }); - return true; - }, - pid: options.pid, - } as any, - wait, - }; - }); -} - -function isAndroidScreenrecordStartCommand(command: string): boolean { - return /^-s emulator-5554 shell screenrecord --bit-rate (?:8000000|20000000) \/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ); -} - -async function runRecordCommand(params: { - sessionStore: SessionStore; - sessionName: string; - positionals: string[]; - logPath?: string; - cwd?: string; - flags?: { - fps?: number; - quality?: string; - hideTouches?: boolean; - recordingScope?: 'app' | 'device' | 'system'; - }; - rawFlags?: Record; - sessionExplicit?: boolean; - clientArtifactPaths?: Record; -}) { - return handleRecordTraceCommands({ - req: { - token: 't', - session: params.sessionName, - command: 'record', - positionals: params.positionals, - flags: (params.rawFlags ?? params.flags ?? {}) as DaemonRequest['flags'], - meta: - params.cwd || params.clientArtifactPaths || params.sessionExplicit - ? { - ...(params.cwd ? { cwd: params.cwd } : {}), - ...(params.clientArtifactPaths - ? { clientArtifactPaths: params.clientArtifactPaths } - : {}), - ...(params.sessionExplicit ? { sessionExplicit: true } : {}), - } - : undefined, - }, - sessionName: params.sessionName, - sessionStore: params.sessionStore, - logPath: params.logPath, - }); -} - -function setupRunnerRecordingMocks( - runnerCalls: RunnerCall[], - runCmdCalls: Array<{ cmd: string; args: string[] }>, -): void { - mockRunAppleRunnerCommand.mockImplementation(async (_device, command, options) => { - runnerCalls.push({ - command: command.command, - outPath: command.outPath, - fps: command.fps, - appBundleId: command.appBundleId, - logPath: options?.logPath, - traceLogPath: options?.traceLogPath, - }); - if (command.command === 'recordStart') { - return { recorderStartUptimeMs: 12_345, targetAppReadyUptimeMs: 15_678 }; - } - return {}; - }); - mockRunCmd.mockImplementation(async (cmd, args) => { - runCmdCalls.push({ cmd, args }); - return { stdout: '', stderr: '', exitCode: 0 }; - }); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used for runner-backed recording'); - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - // Restore default implementations - mockRunCmd.mockImplementation(async () => ({ stdout: '', stderr: '', exitCode: 0 })); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used in this test'); - }); - mockRunAppleRunnerCommand.mockImplementation(async () => ({})); - mockTrimRecordingStart.mockImplementation(async () => {}); - mockOverlayRecordingTouches.mockImplementation(async () => {}); - mockWaitForStableFile.mockImplementation(async () => {}); - mockIsPlayableVideo.mockImplementation(async () => true); -}); - -afterEach(() => { - vi.useRealTimers(); - for (const outPath of mockedIosRecordingOutputs.splice(0)) { - fs.rmSync(outPath, { force: true }); - } -}); - -test('record stop derives telemetry artifact local path from client outPath', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-remote-artifacts'; - const session = makeIosDeviceSession(sessionName, 'com.atebits.Tweetie2'); - sessionStore.set(sessionName, session); - - const runnerCalls: RunnerCall[] = []; - const runCmdCalls: Array<{ cmd: string; args: string[] }> = []; - setupRunnerRecordingMocks(runnerCalls, runCmdCalls); - const daemonOut = path.join(os.tmpdir(), `agent-device-recording-${Date.now()}-random.mp4`); - const clientOut = path.join(os.tmpdir(), `requested-recording-${Date.now()}.mp4`); - - await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', daemonOut], - clientArtifactPaths: { outPath: clientOut }, - }); - - const responseStop = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(responseStop?.ok).toBe(true); - expect((responseStop as any).data?.artifacts?.[1]?.field).toBe('telemetryPath'); - expect((responseStop as any).data?.artifacts?.[1]?.localPath).toBe( - deriveRecordingTelemetryPath(clientOut), - ); - expect((responseStop as any).data?.telemetryPath).toBe(deriveRecordingTelemetryPath(daemonOut)); - - await sessionStore.flushEvents(sessionName); - const summaries = sessionStore - .readEvents(sessionName) - .events.map((event) => event.summary) - .filter((summary): summary is string => summary !== undefined); - expect(summaries).toContain(`Started recording ${path.basename(clientOut)}`); - expect(summaries).toContain(`Stopped recording ${path.basename(clientOut)}`); - expect(JSON.stringify(summaries)).not.toContain(path.basename(daemonOut)); -}); - -test('record stop releases session created only for recording', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'record-only-session'; - const session = makeIosSimulatorRecordingSession(sessionName, { recordOnlySession: true }); - sessionStore.set(sessionName, session); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(true); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('record stop keeps normal app session open when stop validation fails', async () => { - vi.useFakeTimers(); - vi.setSystemTime(20_000); - const sessionStore = makeSessionStore(); - const sessionName = 'app-session-failed-stop'; - const outPath = path.join(os.tmpdir(), 'app-session-failed-stop.mp4'); - fs.writeFileSync(outPath, 'not playable'); - const session = makeIosSimulatorRecordingSession(sessionName, { - appBundleId: 'com.apple.Preferences', - outPath, - startedAt: Date.now() - 500, - }); - sessionStore.set(sessionName, session); - mockIsPlayableVideo.mockImplementation(async () => false); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(false); - expect(sessionStore.get(sessionName)).toBe(session); - expect(sessionStore.get(sessionName)?.recording).toBeUndefined(); -}); - -test('record start and stop web recording keep the requested artifact path stable', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'web-recording-stable-path'; - const session = makeWebSession(sessionName); - sessionStore.set(sessionName, session); - const outPath = path.join(os.tmpdir(), `${sessionName}.webm`); - const calls: string[] = []; - const provider = makeWebProvider({ - startRecording: async (path) => { - calls.push(`start:${path}`); - }, - stopRecording: async () => { - calls.push('stop'); - fs.writeFileSync(outPath, 'webm'); - }, - }); - - try { - await withWebProvider(provider, async () => { - const start = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', outPath], - }); - const stop = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(start?.ok).toBe(true); - expect((start as any).data?.outPath).toBe(outPath); - expect(stop?.ok).toBe(true); - expect((stop as any).data?.outPath).toBe(outPath); - expect((stop as any).data?.artifacts?.[0]?.path).toBe(outPath); - }); - expect(calls).toEqual([`start:${outPath}`, 'stop']); - } finally { - fs.rmSync(outPath, { force: true }); - } -}); - -test('record start web appends .webm to extensionless paths before delegating', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'web-recording-extensionless'; - const session = makeWebSession(sessionName); - sessionStore.set(sessionName, session); - const requestedPath = path.join(os.tmpdir(), sessionName); - const expectedPath = `${requestedPath}.webm`; - const calls: string[] = []; - const provider = makeWebProvider({ - startRecording: async (path) => { - calls.push(path); - fs.writeFileSync(path, 'webm'); - }, - }); - - try { - await withWebProvider(provider, async () => { - const start = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', requestedPath], - }); - - expect(start?.ok).toBe(true); - expect((start as any).data?.outPath).toBe(expectedPath); - }); - expect(calls).toEqual([expectedPath]); - } finally { - fs.rmSync(expectedPath, { force: true }); - } -}); - -test('record start web rejects non-WebM output paths before delegating', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'web-recording-mp4'; - const session = makeWebSession(sessionName); - sessionStore.set(sessionName, session); - const provider = makeWebProvider({ - startRecording: async () => { - throw new Error('should not delegate invalid web recording path'); - }, - }); - - await withWebProvider(provider, async () => { - const start = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', path.join(os.tmpdir(), `${sessionName}.mp4`)], - }); - - expect(start?.ok).toBe(false); - if (!start || start.ok) { - throw new Error(`expected web recording start failure, got ${JSON.stringify(start)}`); - } - expect(start.error.code).toBe('INVALID_ARGS'); - expect(start.error.message).toMatch(/\.webm output path/); - }); -}); - -test('record start web rejects native recording flags before delegating', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'web-recording-native-flags'; - const session = makeWebSession(sessionName); - sessionStore.set(sessionName, session); - const provider = makeWebProvider({ - startRecording: async () => { - throw new Error('should not delegate unsupported web recording flags'); - }, - }); - - await withWebProvider(provider, async () => { - const start = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', path.join(os.tmpdir(), `${sessionName}.webm`)], - flags: { fps: 0, quality: 'high', hideTouches: true }, - }); - - expect(start?.ok).toBe(false); - if (!start || start.ok) { - throw new Error(`expected web recording start failure, got ${JSON.stringify(start)}`); - } - expect(start.error.code).toBe('INVALID_ARGS'); - expect(start.error.message).toContain('--fps, --quality, --hide-touches'); - }); -}); - -test('record start rejects the removed max-size field from older remote clients', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'recording-legacy-max-size'; - sessionStore.set(sessionName, makeOpenedIosSimulatorSession(sessionName)); - - const start = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './legacy-max-size.mp4'], - rawFlags: { screenshotMaxSize: 720 }, - }); - - expect(start?.ok).toBe(false); - if (!start || start.ok) { - throw new Error(`expected recording start failure, got ${JSON.stringify(start)}`); - } - expect(start.error.code).toBe('INVALID_ARGS'); - expect(start.error.message).toBe( - 'record --max-size was removed; recordings capture at native resolution', - ); -}); - -test('record start web requires an existing browser session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'web-recording-no-open'; - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', path.join(os.tmpdir(), `${sessionName}.webm`)], - }); - - expect(response?.ok).toBe(false); - if (!response || response.ok) { - throw new Error(`expected web recording start failure, got ${JSON.stringify(response)}`); - } - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/requires an active app session/i); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('record stop closes record-only web sessions during cleanup', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'web-recording-record-only-cleanup'; - const session = makeWebSession(sessionName); - session.recordOnlySession = true; - const outPath = path.join(os.tmpdir(), `${sessionName}.webm`); - session.recording = { - platform: 'web', - outPath, - startedAt: Date.now(), - showTouches: false, - gestureEvents: [], - }; - sessionStore.set(sessionName, session); - const calls: string[] = []; - const provider = makeWebProvider({ - close: async () => { - calls.push('close'); - }, - stopRecording: async () => { - calls.push('stop'); - fs.writeFileSync(outPath, 'webm'); - }, - }); - - try { - await withWebProvider(provider, async () => { - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(true); - }); - expect(calls).toEqual(['stop', 'close']); - expect(sessionStore.get(sessionName)).toBeUndefined(); - } finally { - fs.rmSync(outPath, { force: true }); - } -}); - -test('record stop rejects web recording when agent-browser does not finalize the file', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'web-recording-missing-file'; - const session = makeWebSession(sessionName); - sessionStore.set(sessionName, session); - const outPath = path.join(os.tmpdir(), `${sessionName}.webm`); - const provider = makeWebProvider(); - - try { - await withWebProvider(provider, async () => { - const start = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', outPath], - }); - const stop = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(start?.ok).toBe(true); - expect(stop?.ok).toBe(false); - if (!stop || stop.ok) { - throw new Error(`expected web recording stop failure, got ${JSON.stringify(stop)}`); - } - expect(stop.error.message).toMatch(/not finalized into a WebM video/); - expect(fs.existsSync(outPath)).toBe(false); - }); - } finally { - fs.rmSync(outPath, { force: true }); - } -}); - -test('record start resolves relative output path from request cwd', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-cwd'; - const session = makeIosDeviceSession(sessionName, 'com.atebits.Tweetie2'); - sessionStore.set(sessionName, session); - - const runnerCalls: RunnerCall[] = []; - const runCmdCalls: Array<{ cmd: string; args: string[] }> = []; - setupRunnerRecordingMocks(runnerCalls, runCmdCalls); - const cwd = '/tmp/agent-device-cwd-test'; - const responseStart = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './device.mp4'], - cwd, - }); - - expect(responseStart?.ok).toBe(true); - expect(runnerCalls[0]?.outPath ?? '').toMatch(/^agent-device-recording-\d+\.mp4$/); - expect(runnerCalls[0]?.fps).toBeUndefined(); - const startedRecording = sessionStore.get(sessionName)?.recording; - expect(startedRecording?.platform).toBe('ios-device-runner'); - if (startedRecording?.platform === 'ios-device-runner') { - expect(startedRecording.outPath).toBe(path.join(cwd, 'device.mp4')); - expect(startedRecording.remotePath ?? '').toMatch(/^tmp\/agent-device-recording-\d+\.mp4$/); - } - - await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - cwd, - }); - expect(runCmdCalls.length).toBe(1); -}); - -test('record start rejects invalid fps value', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-invalid-fps'; - sessionStore.set(sessionName, makeIosDeviceSession(sessionName)); - - mockRunAppleRunnerCommand.mockImplementation(async () => { - throw new Error('runAppleRunnerCommand should not be used for invalid args'); - }); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used for invalid args'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './device.mp4'], - flags: { fps: 0 }, - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('INVALID_ARGS'); - expect((response as any).error?.message ?? '').toMatch( - /fps must be an integer between 1 and 120/, - ); -}); - -test('record start rejects invalid quality value', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-invalid-quality'; - sessionStore.set(sessionName, makeIosDeviceSession(sessionName)); - - mockRunAppleRunnerCommand.mockImplementation(async () => { - throw new Error('runAppleRunnerCommand should not be used for invalid args'); - }); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used for invalid args'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './device.mp4'], - flags: { quality: 'ultra' as never }, - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('INVALID_ARGS'); - expect((response as any).error?.message ?? '').toMatch(/quality must be one of: medium, high/); -}); - -test('record start on iOS device requires active app session context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-no-app'; - sessionStore.set(sessionName, makeIosDeviceSession(sessionName)); - - mockRunAppleRunnerCommand.mockImplementation(async () => { - throw new Error('runAppleRunnerCommand should not be used without active app context'); - }); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used for iOS devices'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './device.mp4'], - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('INVALID_ARGS'); - expect((response as any).error?.message ?? '').toMatch(/requires an active app session/i); -}); - -test('record start returns structured error when iOS runner start fails', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-start-fail'; - const session = makeIosDeviceSession(sessionName, 'com.atebits.Tweetie2'); - sessionStore.set(sessionName, session); - - mockRunAppleRunnerCommand.mockImplementation(async () => { - throw new Error('runner disconnected'); - }); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used for iOS devices'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './device.mp4'], - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('COMMAND_FAILED'); - expect((response as any).error?.message ?? '').toMatch( - /failed to start recording: runner disconnected/, - ); - expect(sessionStore.get(sessionName)?.recording).toBeUndefined(); -}); - -test('record start recovers from stale iOS runner recording state', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-runner-desync'; - const session = makeIosDeviceSession(sessionName, 'com.atebits.Tweetie2'); - sessionStore.set(sessionName, session); - - const commands: string[] = []; - let startAttempts = 0; - mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { - commands.push(command.command); - if (command.command === 'recordStart') { - startAttempts += 1; - if (startAttempts === 1) { - throw new Error('recording already in progress'); - } - } - return { recorderStartUptimeMs: 11_000, targetAppReadyUptimeMs: 12_000 }; - }); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used for iOS devices'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './device.mp4'], - }); - - expect(response?.ok).toBe(true); - expect(commands).toEqual(['recordStart', 'recordStop', 'recordStart']); - expect(sessionStore.get(sessionName)?.recording?.platform).toBe('ios-device-runner'); -}); - -test('record start does not stop recording owned by another session during desync recovery', async () => { - const sessionStore = makeSessionStore(); - const ownerSessionName = 'ios-device-owner'; - const ownerSession = makeIosDeviceSession(ownerSessionName, 'com.example.owner'); - ownerSession.recording = { - platform: 'ios-device-runner', - outPath: '/tmp/owner.mp4', - remotePath: 'tmp/owner.mp4', - startedAt: Date.now(), - showTouches: false, - gestureEvents: [], - }; - sessionStore.set(ownerSessionName, ownerSession); - - const sessionName = 'ios-device-requester'; - const requesterSession = makeIosDeviceSession(sessionName, 'com.example.requester'); - sessionStore.set(sessionName, requesterSession); - - const commands: string[] = []; - mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { - commands.push(command.command); - if (command.command === 'recordStart') { - throw new Error('recording already in progress'); - } - return {}; - }); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used for iOS devices'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './device.mp4'], - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('COMMAND_FAILED'); - expect((response as any).error?.message ?? '').toMatch( - /already in progress in session 'ios-device-owner'/, - ); - expect(commands).toEqual(['recordStart']); - expect(sessionStore.get(ownerSessionName)?.recording?.platform).toBe('ios-device-runner'); -}); - -test('record stop reports iOS runner stop failure after copying and clears recording state', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-stop-fail'; - sessionStore.set(sessionName, { - ...makeIosDeviceSession(sessionName), - recording: { - platform: 'ios-device-runner', - outPath: '/tmp/device.mp4', - remotePath: 'tmp/device.mp4', - startedAt: Date.now(), - showTouches: false, - gestureEvents: [], - }, - }); - - const runCmdCalls: Array<{ cmd: string; args: string[] }> = []; - mockRunCmd.mockImplementation(async (cmd, args) => { - runCmdCalls.push({ cmd, args }); - return { stdout: '', stderr: '', exitCode: 0 }; - }); - mockRunAppleRunnerCommand.mockImplementation(async () => { - throw new Error('runner disconnected'); - }); - mockRunCmdBackground.mockImplementation(() => { - throw new Error('runCmdBackground should not be used for iOS devices'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('COMMAND_FAILED'); - expect((response as any).error?.message).toMatch(/runner reported recordStop did not succeed/); - expect(runCmdCalls.length).toBe(1); - expect(sessionStore.get(sessionName)?.recording).toBeUndefined(); -}); - -test('record stop trims iOS device recordings from target app readiness before overlays', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-device-trim'; - sessionStore.set(sessionName, { - ...makeIosDeviceSession(sessionName, 'com.atebits.Tweetie2'), - recording: { - platform: 'ios-device-runner', - outPath: '/tmp/device.mp4', - remotePath: 'tmp/device.mp4', - startedAt: Date.now(), - runnerStartedAtUptimeMs: 10_000, - targetAppReadyUptimeMs: 13_250, - showTouches: true, - gestureEvents: [{ kind: 'tap', tMs: 3_600, x: 50, y: 80 }], - }, - }); - - const lifecycleCalls: string[] = []; - mockTrimRecordingStart.mockImplementation(async ({ videoPath, trimStartMs }) => { - lifecycleCalls.push(`trim:${videoPath}:${trimStartMs}`); - }); - mockOverlayRecordingTouches.mockImplementation(async ({ videoPath, telemetryPath }) => { - lifecycleCalls.push(`overlay:${videoPath}:${telemetryPath}`); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(true); - const expectedLifecycleCalls = ['trim:/tmp/device.mp4:3250']; - if (!overlaySupportWarning) { - expectedLifecycleCalls.push( - `overlay:/tmp/device.mp4:${deriveRecordingTelemetryPath('/tmp/device.mp4')}`, - ); - } - expect(lifecycleCalls).toEqual(expectedLifecycleCalls); - expect((response as any).data?.overlayWarning).toBe(overlaySupportWarning); -}); - -test('record stop leaves a short visual tail after iOS simulator gestures', async () => { - vi.useFakeTimers(); - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-visual-tail'; - const kill = vi.fn(); - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.recording = { - platform: 'ios', - outPath: '/tmp/visual-tail.mp4', - startedAt: Date.now(), - showTouches: true, - gestureEvents: [{ kind: 'tap', tMs: 10, x: 20, y: 30 } as any], - child: { kill }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }; - sessionStore.set(sessionName, session); - - const responsePromise = runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - await vi.advanceTimersByTimeAsync(349); - expect(kill).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - const response = await responsePromise; - - expect(response?.ok).toBe(true); - expect(kill).toHaveBeenCalledWith('SIGINT'); -}); - -test('stopSessionRecordingForTeardown finalizes an active iOS simulator recording and clears session state', async () => { - const outPath = path.join(os.tmpdir(), `agent-device-teardown-${Date.now()}.mp4`); - fs.writeFileSync(outPath, 'recorded-bytes'); - const session = makeIosSimulatorRecordingSession('ios-sim-teardown', { outPath }); - const recording = session.recording; - const kill = recording?.platform === 'ios' ? recording.child.kill : undefined; - - await stopSessionRecordingForTeardown(session); - - // Teardown must SIGINT the recorder (which finalizes the mp4) rather than - // orphaning the simctl child, and must detach the recording from the session. - expect(kill).toHaveBeenCalledWith('SIGINT'); - expect(session.recording).toBeUndefined(); -}); - -test('stopSessionRecordingForTeardown is a no-op when the session has no active recording', async () => { - const session = makeIosSimulatorSession('ios-sim-teardown-no-recording'); - - await expect(stopSessionRecordingForTeardown(session)).resolves.toBeUndefined(); - - expect(session.recording).toBeUndefined(); -}); - -test('stopSessionRecordingForTeardown rethrows a typed stop failure for the cleanup-failure channel', async () => { - const session = makeIosSimulatorRecordingSession('ios-sim-teardown-stop-failure', { - startedAt: Date.now() - 5_000, - }); - const recording = session.recording; - if (recording?.platform === 'ios') { - recording.wait = Promise.resolve({ stdout: '', stderr: 'recorder crashed', exitCode: 1 }); - } - - await expect(stopSessionRecordingForTeardown(session)).rejects.toThrow( - /failed to stop recording/, - ); - - // The recording is still detached so a retry cannot double-stop the recorder. - expect(session.recording).toBeUndefined(); -}); - -test('record stop reports too-short iOS simulator recordings without leaving invalid output', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-too-short'; - const outPath = path.join(os.tmpdir(), `agent-device-too-short-${Date.now()}.mp4`); - fs.writeFileSync(outPath, 'not-a-video'); - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.recording = { - platform: 'ios', - outPath, - startedAt: Date.now(), - showTouches: false, - gestureEvents: [], - child: { kill: vi.fn() }, - wait: Promise.resolve({ stdout: '', stderr: 'failed to finalize', exitCode: 1 }), - }; - sessionStore.set(sessionName, session); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.message).toMatch(/wait at least 1000ms/i); - expect((response as any).error?.message).toMatch(/failed to finalize/i); - expect(fs.existsSync(outPath)).toBe(false); -}); - -test('record stop measures too-short iOS simulator recordings from stop request time', async () => { - vi.useFakeTimers(); - vi.setSystemTime(10_000); - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-too-short-delayed-finalize'; - const outPath = path.join(os.tmpdir(), `agent-device-too-short-delayed-${Date.now()}.mp4`); - fs.writeFileSync(outPath, 'not-a-video'); - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.recording = { - platform: 'ios', - outPath, - startedAt: Date.now() - 500, - showTouches: false, - gestureEvents: [], - child: { kill: vi.fn() }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }; - sessionStore.set(sessionName, session); - mockWaitForStableFile.mockImplementation(async () => { - vi.setSystemTime(11_300); - }); - mockIsPlayableVideo.mockImplementation(async () => false); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.message).toMatch(/Recording stopped after 500ms/i); - expect((response as any).error?.message).toMatch(/wait at least 1000ms/i); - expect(fs.existsSync(outPath)).toBe(false); -}); - -test('record stop measures too-short Android failures from stop request time', async () => { - vi.useFakeTimers(); - vi.setSystemTime(20_000); - const sessionStore = makeSessionStore(); - const sessionName = 'android-too-short-delayed-stop'; - const session = makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }); - session.recording = { - platform: 'android', - outPath: path.resolve('./android-too-short.mp4'), - startedAt: Date.now() - 500, - showTouches: true, - gestureEvents: [], - remotePath: '/sdcard/agent-device-recording-too-short.mp4', - remotePid: '4322', - chunks: [ - { - index: 1, - path: path.resolve('./android-too-short.mp4'), - remotePath: '/sdcard/agent-device-recording-too-short.mp4', - }, - ], - }; - sessionStore.set(sessionName, session); - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - if (command === '-s emulator-5554 shell ps -o pid= -p 4322') { - return { stdout: '4322\n', stderr: '', exitCode: 0 }; - } - if (command === '-s emulator-5554 shell kill -2 4322') { - vi.setSystemTime(21_300); - return { stdout: '', stderr: 'failed to stop', exitCode: 1 }; - } - if (command === '-s emulator-5554 shell kill -9 4322') { - return { stdout: '', stderr: 'failed to force stop', exitCode: 1 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.message).toMatch(/Recording stopped after 500ms/i); - expect((response as any).error?.message).toMatch(/wait at least 1000ms/i); - expect((response as any).error?.message).toMatch(/failed to stop/i); -}); - -test('record start on iOS simulator requires active app session context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-no-app'; - sessionStore.set(sessionName, makeIosSimulatorSession(sessionName)); - - mockRunCmdBackground.mockImplementation(() => { - throw new Error('simctl recordVideo should not start without active app context'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-no-app.mp4'], - flags: { hideTouches: true }, - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('INVALID_ARGS'); - expect((response as any).error?.message ?? '').toMatch(/requires an active app session/i); - expect(sessionStore.get(sessionName)?.recording).toBeUndefined(); -}); - -test('record start with explicit missing session does not fall back to record-only capture', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-explicit-missing'; - mockRunCmdBackground.mockImplementation(() => { - throw new Error('simctl recordVideo should not start for an explicit missing session'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-explicit-missing.mp4'], - flags: { hideTouches: true }, - sessionExplicit: true, - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('INVALID_ARGS'); - expect((response as any).error?.message ?? '').toMatch(/explicit session/i); - expect(mockResolveTargetDevice).not.toHaveBeenCalled(); - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('record start without a session defaults to app scope', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-default-app-scope'; - mockRunCmdBackground.mockImplementation(() => { - throw new Error('simctl recordVideo should not start without explicit whole-screen scope'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-default-app-scope.mp4'], - flags: { hideTouches: true }, - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('INVALID_ARGS'); - expect((response as any).error?.message ?? '').toMatch(/defaults to app scope/i); - expect(mockResolveTargetDevice).not.toHaveBeenCalled(); - expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); - expect(sessionStore.get(sessionName)).toBeUndefined(); -}); - -test('record start with explicit missing session can opt into device scope', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-explicit-device-scope'; - mockResolveTargetDevice.mockResolvedValueOnce({ - platform: 'apple', - id: 'ios-sim-1', - name: 'iPhone 16', - kind: 'simulator', - booted: true, - }); - mockIosSimulatorRecordingStart({ pid: 5153 }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-explicit-device-scope.mp4'], - flags: { hideTouches: true, recordingScope: 'device' }, - sessionExplicit: true, - }); - - expect(response?.ok).toBe(true); - expect((response as any).data?.recordingScope).toBe('device'); - expect((response as any).data?.recordOnlySession).toBe(true); - expect(sessionStore.get(sessionName)?.recordOnlySession).toBe(true); - expect(sessionStore.get(sessionName)?.recording?.platform).toBe('ios'); -}); - -test('record start on iOS simulator rejects Agent Device Runner as active app context', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-runner-app'; - const session = makeIosSimulatorSession(sessionName); - session.appBundleId = IOS_RUNNER_CONTAINER_BUNDLE_IDS[0]; - sessionStore.set(sessionName, session); - - mockRunCmdBackground.mockImplementation(() => { - throw new Error('simctl recordVideo should not start for the runner app'); - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-runner-app.mp4'], - flags: { hideTouches: true }, - }); - - expect(response?.ok).toBe(false); - expect((response as any).error?.code).toBe('INVALID_ARGS'); - expect((response as any).error?.message ?? '').toMatch(/Agent Device Runner/); - expect(sessionStore.get(sessionName)?.recording).toBeUndefined(); -}); - -test('record start on iOS simulator supports explicit device-scope capture without a session', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-record-only'; - mockResolveTargetDevice.mockResolvedValueOnce({ - platform: 'apple', - id: 'ios-sim-1', - name: 'iPhone 16', - kind: 'simulator', - booted: true, - }); - mockIosSimulatorRecordingStart({ pid: 5152 }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-record-only.mp4'], - flags: { hideTouches: true, recordingScope: 'device' }, - }); - - expect(response?.ok).toBe(true); - expect((response as any).data?.recordingBackend).toBe('simctl recordVideo'); - expect((response as any).data?.recordingScope).toBe('device'); - expect((response as any).data?.recordOnlySession).toBe(true); - expect((response as any).data?.activeSessionApp).toBeUndefined(); - expect(sessionStore.get(sessionName)?.recordOnlySession).toBe(true); - expect(sessionStore.get(sessionName)?.recording?.platform).toBe('ios'); -}); - -test('record start stores iOS simulator recorder pid for scoped cleanup', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-recorder-pid'; - sessionStore.set(sessionName, makeOpenedIosSimulatorSession(sessionName)); - mockIosSimulatorRecordingStart({ pid: 5151 }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-recorder-pid.mp4'], - flags: { hideTouches: true }, - }); - - expect(response?.ok).toBe(true); - expect((response as any).data?.recordingBackend).toBe('simctl recordVideo'); - expect((response as any).data?.recordOnlySession).toBe(false); - expect((response as any).data?.activeSessionApp).toEqual({ - bundleId: 'com.apple.Preferences', - name: 'Settings', - }); - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('ios'); - if (recording?.platform === 'ios') { - expect(recording.recorderPid).toBe(5151); - } -}); - -test('record stop prefers session-owned iOS recorder processes before path fallback', async () => { - vi.useFakeTimers(); - const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true); - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-owned-recorder'; - const kill = vi.fn(); - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.recording = { - platform: 'ios', - outPath: '/tmp/owned-recorder.mp4', - startedAt: Date.now(), - showTouches: true, - gestureEvents: [], - recorderPid: 1111, - child: { kill, pid: 1111 }, - wait: new Promise(() => {}), - }; - sessionStore.set(sessionName, session); - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'pgrep' && args[0] === '-P') { - expect(args).toEqual(['-P', '1111']); - return { stdout: '2222\n', stderr: '', exitCode: 0 }; - } - if (cmd === 'pgrep' && args[0] === '-f') { - throw new Error('path fallback should not run when owned recorder cleanup matches'); - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - try { - const responsePromise = runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - await vi.advanceTimersByTimeAsync(12_000); - const response = await responsePromise; - - expect(response?.ok).toBe(false); - expect((response as any).error?.message).toMatch(/did not exit/); - expect(kill.mock.calls.map((call) => call[0])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']); - expect(mockRunCmd.mock.calls.map((call) => call[1])).toEqual([ - ['-P', '1111'], - ['-P', '1111'], - ['-P', '1111'], - ]); - expect(processKill.mock.calls.map((call) => call[0])).toEqual([ - 1111, 2222, 1111, 2222, 1111, 2222, 1111, - ]); - expect(processKill.mock.calls.map((call) => call[1])).toEqual([ - 'SIGINT', - 'SIGINT', - 'SIGTERM', - 'SIGTERM', - 'SIGKILL', - 'SIGKILL', - 0, - ]); - } finally { - processKill.mockRestore(); - } -}); - -test('record stop falls back to path matching for stale iOS simulator recordVideo processes', async () => { - vi.useFakeTimers(); - const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true); - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-stale-recorder'; - const kill = vi.fn(); - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.recording = { - platform: 'ios', - outPath: '/tmp/stale-recorder.mp4', - startedAt: Date.now(), - showTouches: true, - gestureEvents: [], - child: { kill }, - wait: new Promise(() => {}), - }; - sessionStore.set(sessionName, session); - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'pgrep') { - expect(args).toEqual(['-f', 'simctl.*recordVideo.*/tmp/stale-recorder\\.mp4']); - return { stdout: '4242\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - try { - const responsePromise = runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - await vi.advanceTimersByTimeAsync(12_000); - const response = await responsePromise; - - expect(response?.ok).toBe(false); - expect((response as any).error?.message).toMatch(/did not exit/); - expect(kill.mock.calls.map((call) => call[0])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']); - expect(processKill.mock.calls.map((call) => call[1])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']); - } finally { - processKill.mockRestore(); - } -}); - -test('record stop keeps iOS simulator video when overlay export fails', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-overlay-warning'; - sessionStore.set(sessionName, makeOpenedIosSimulatorSession(sessionName)); - - mockIosSimulatorRecordingStart(); - mockOverlayRecordingTouches.mockImplementation(async () => { - throw new Error('swift export failed'); - }); - - await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-warning.mp4'], - }); - sessionStore.get(sessionName)?.recording?.gestureEvents.push({ - kind: 'tap', - tMs: 120, - x: 90, - y: 180, - }); - - const responseStop = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(responseStop?.ok).toBe(true); - expect((responseStop as any).data?.overlayWarning).toBe( - overlaySupportWarning ?? 'failed to overlay recording touches: swift export failed', - ); -}); - -test('record stop skips touch overlay export when no gestures were recorded', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-no-gestures'; - sessionStore.set(sessionName, makeOpenedIosSimulatorSession(sessionName)); - - mockIosSimulatorRecordingStart(); - - await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-no-gestures.mp4'], - }); - - const responseStop = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(responseStop?.ok).toBe(true); - expect(mockOverlayRecordingTouches).not.toHaveBeenCalled(); - expect((responseStop as any).data?.overlayWarning).toBeUndefined(); -}); - -test('record start does not fail when iOS simulator runner warm-up fails', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-warm-failure'; - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.appBundleId = 'com.apple.Preferences'; - sessionStore.set(sessionName, session); - - let started = false; - mockIosSimulatorRecordingStart({ onStart: () => (started = true) }); - const runnerCalls: RunnerCall[] = []; - mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { - runnerCalls.push({ command: command.command }); - if (command.command === 'snapshot') { - throw new Error('runner warm-up unavailable'); - } - return { currentUptimeMs: 30_000 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim.mp4'], - }); - - expect(response?.ok).toBe(true); - expect(started).toBe(true); - // Warm-up failure falls back to the standalone uptime command for the anchor. - expect(runnerCalls.map((call) => call.command)).toEqual(['snapshot', 'uptime']); - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('ios'); - if (recording?.platform === 'ios') { - expect(recording.gestureClockOriginUptimeMs).toBe(30_000); - } -}); - -test('record start anchors gesture clock from simulator warm-up and skips standalone uptime', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-warm-anchor'; - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.appBundleId = 'com.apple.Preferences'; - sessionStore.set(sessionName, session); - - mockIosSimulatorRecordingStart(); - const runnerCalls: RunnerCall[] = []; - mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { - runnerCalls.push({ command: command.command }); - if (command.command === 'snapshot') { - return { currentUptimeMs: 20_000 }; - } - return {}; - }); - - const beforeMs = Date.now(); - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-warm-anchor.mp4'], - }); - const afterMs = Date.now(); - - expect(response?.ok).toBe(true); - expect(runnerCalls.map((call) => call.command)).toEqual(['snapshot']); - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('ios'); - if (recording?.platform === 'ios') { - expect(recording.gestureClockOriginUptimeMs).toBe(20_000); - expect(recording.gestureClockOriginAtMs).toBeGreaterThanOrEqual(beforeMs); - expect(recording.gestureClockOriginAtMs).toBeLessThanOrEqual(afterMs); - } -}); - -test('record start falls back to standalone uptime when warm response lacks currentUptimeMs', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-warm-missing'; - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.appBundleId = 'com.apple.Preferences'; - sessionStore.set(sessionName, session); - - mockIosSimulatorRecordingStart(); - const runnerCalls: RunnerCall[] = []; - mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { - runnerCalls.push({ command: command.command }); - if (command.command === 'uptime') { - return { currentUptimeMs: 30_000 }; - } - return {}; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-warm-missing.mp4'], - }); - - expect(response?.ok).toBe(true); - expect(runnerCalls.map((call) => call.command)).toEqual(['snapshot', 'uptime']); - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('ios'); - if (recording?.platform === 'ios') { - expect(recording.gestureClockOriginUptimeMs).toBe(30_000); - } -}); - -test('record start rejects non-finite or non-positive warm anchors', async () => { - for (const badValue of [Number.NaN, -1]) { - const sessionStore = makeSessionStore(); - const sessionName = `ios-sim-warm-bad-${badValue}`; - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.appBundleId = 'com.apple.Preferences'; - sessionStore.set(sessionName, session); - - mockIosSimulatorRecordingStart(); - const runnerCalls: RunnerCall[] = []; - mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { - runnerCalls.push({ command: command.command }); - if (command.command === 'snapshot') { - return { currentUptimeMs: badValue }; - } - return { currentUptimeMs: 30_000 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', `./sim-warm-bad-${badValue}.mp4`], - }); - - expect(response?.ok).toBe(true); - expect(runnerCalls.map((call) => call.command)).toEqual(['snapshot', 'uptime']); - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('ios'); - if (recording?.platform === 'ios') { - expect(recording.gestureClockOriginUptimeMs).toBe(30_000); - } - } -}); - -test('record start degrades to wall-clock when warm anchor missing and uptime fails', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-anchor-degraded'; - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.appBundleId = 'com.apple.Preferences'; - sessionStore.set(sessionName, session); - - mockIosSimulatorRecordingStart(); - mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { - if (command.command === 'uptime') { - throw new Error('uptime unavailable'); - } - return {}; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-anchor-degraded.mp4'], - }); - - expect(response?.ok).toBe(true); - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('ios'); - if (recording?.platform === 'ios') { - expect(recording.gestureClockOriginAtMs).toBeUndefined(); - expect(recording.gestureClockOriginUptimeMs).toBeUndefined(); - } -}); - -test('record start skips iOS simulator runner warm-up when touch overlays are hidden', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim-hide-touches'; - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'Simulator', - kind: 'simulator', - booted: true, - }); - session.appBundleId = 'com.apple.Preferences'; - sessionStore.set(sessionName, session); - - mockIosSimulatorRecordingStart(); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './sim-hide-touches.mp4'], - flags: { hideTouches: true }, - }); - - expect(response?.ok).toBe(true); - expect(mockRunAppleRunnerCommand).not.toHaveBeenCalled(); - expect(sessionStore.get(sessionName)?.recording?.showTouches).toBe(false); -}); - -test('record start/stop overlays Android gestures by default on devices', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-overlay'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }), - ); - - const adbCalls: Array = []; - mockRunCmd.mockImplementation(async (_cmd, args) => { - adbCalls.push(args); - if ( - /^-s emulator-5554 shell screenrecord --bit-rate 8000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - args.join(' '), - ) - ) { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test( - args.join(' '), - ) - ) { - return { stdout: '1024\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './android.mp4'], - }); - const startedRecording = sessionStore.get(sessionName)?.recording; - expect(startedRecording?.platform).toBe('android'); - startedRecording?.gestureEvents.push({ kind: 'tap', tMs: 120, x: 90, y: 180 }); - - const overlayCalls: Array<{ videoPath: string; telemetryPath: string }> = []; - mockRunCmd.mockImplementation(async (_cmd, args) => { - adbCalls.push(args); - if (args.join(' ') === '-s emulator-5554 shell ps -o pid= -p 4321') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test( - args.join(' '), - ) - ) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - mockOverlayRecordingTouches.mockImplementation(async ({ videoPath, telemetryPath }) => { - overlayCalls.push({ videoPath, telemetryPath }); - }); - - const responseStop = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(adbCalls.some((args) => args.join(' ') === '-s emulator-5554 shell kill -2 4321')).toBe( - true, - ); - expect(responseStop?.ok).toBe(true); - if (!responseStop?.ok) { - throw new Error('expected successful Android record stop response'); - } - if (overlaySupportWarning) { - expect(overlayCalls).toEqual([]); - expect(responseStop.data?.overlayWarning).toBe(overlaySupportWarning); - } else { - expect(overlayCalls).toEqual([ - { - videoPath: path.resolve('./android.mp4'), - telemetryPath: deriveRecordingTelemetryPath(path.resolve('./android.mp4')), - }, - ]); - expect(responseStop.data?.overlayWarning).toBeUndefined(); - } -}); - -test('record stop keeps Android video when overlay export fails', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-overlay-warning'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }), - ); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - if ( - /^-s emulator-5554 shell screenrecord --bit-rate 8000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ) - ) { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '1024\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './android-warning.mp4'], - }); - - const startedRecording = sessionStore.get(sessionName)?.recording; - startedRecording?.gestureEvents.push({ kind: 'tap', tMs: 120, x: 90, y: 180 }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - if (command === '-s emulator-5554 shell ps -o pid= -p 4321') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - mockOverlayRecordingTouches.mockImplementation(async () => { - throw new Error('android overlay export failed'); - }); - - const responseStop = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(responseStop?.ok).toBe(true); - expect((responseStop as any).data?.overlayWarning).toBe( - overlaySupportWarning ?? 'failed to overlay recording touches: android overlay export failed', - ); -}); - -test('record stop force-kills Android screenrecord when SIGINT fails but process is still running', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-force-stop'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }), - ); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - if ( - /^-s emulator-5554 shell screenrecord --bit-rate 8000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ) - ) { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '1024\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './android.mp4'], - }); - - const adbCalls: string[] = []; - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - adbCalls.push(command); - if (command === '-s emulator-5554 shell kill -2 4321') { - return { stdout: '', stderr: 'operation not permitted', exitCode: 1 }; - } - if (command === '-s emulator-5554 shell ps -o pid= -p 4321') { - return { - stdout: adbCalls.includes('-s emulator-5554 shell kill -9 4321') ? '' : '4321\n', - stderr: '', - exitCode: 0, - }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(true); - expect(adbCalls.includes('-s emulator-5554 shell kill -2 4321')).toBe(true); - expect(adbCalls.includes('-s emulator-5554 shell kill -9 4321')).toBe(true); - expect( - adbCalls.some((command) => - /^-s emulator-5554 shell rm -f \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command), - ), - ).toBe(true); -}); - -test('record stop warns when Android screenrecord hit the 180s platform limit', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-screenrecord-limit'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }), - ); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - if ( - /^-s emulator-5554 shell screenrecord --bit-rate 8000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ) - ) { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '1024\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './android-limit.mp4'], - }); - - const recording = sessionStore.get(sessionName)?.recording; - if (recording) { - recording.startedAt = Date.now() - 181_000; - } - - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - if (command === '-s emulator-5554 shell ps -o pid= -p 4321') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - if (command === '-s emulator-5554 shell kill -2 4321') { - return { stdout: '', stderr: 'No such process', exitCode: 1 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(true); - expect((response as any).data?.warning).toMatch(/180s platform limit/); -}); - -test('record stop returns multiple Android recording chunks', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-screenrecord-chunks'; - const session = makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }); - session.recording = { - platform: 'android', - outPath: path.resolve('./android-long.mp4'), - startedAt: Date.now() - 172_000, - showTouches: true, - gestureEvents: [{ kind: 'tap', tMs: 120, x: 90, y: 180 }], - remotePath: '/sdcard/agent-device-recording-2.mp4', - remotePid: '4322', - warning: - 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.', - chunks: [ - { - index: 1, - path: path.resolve('./android-long.mp4'), - remotePath: '/sdcard/agent-device-recording-1.mp4', - }, - { - index: 2, - path: path.resolve('./android-long.part-002.mp4'), - remotePath: '/sdcard/agent-device-recording-2.mp4', - }, - ], - }; - sessionStore.set(sessionName, session); - - const adbCommands: string[] = []; - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - adbCommands.push(command); - if (command === '-s emulator-5554 shell ps -o pid= -p 4322') { - return adbCommands.includes('-s emulator-5554 shell kill -2 4322') - ? { stdout: '', stderr: '', exitCode: 1 } - : { stdout: '4322\n', stderr: '', exitCode: 0 }; - } - if (command === '-s emulator-5554 shell kill -2 4322') { - return { stdout: '', stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(true); - if (response?.ok !== true) { - throw new Error('expected successful Android record stop response'); - } - expect(response.data?.warning).toMatch(/split into multiple MP4 chunks/); - expect(response.data?.overlayWarning).toMatch(/skipped for chunked Android recordings/); - expect(response.data?.chunks).toEqual([ - expect.objectContaining({ index: 1, path: path.resolve('./android-long.mp4') }), - expect.objectContaining({ index: 2, path: path.resolve('./android-long.part-002.mp4') }), - ]); - expect(response.data?.artifacts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ field: 'outPath', path: path.resolve('./android-long.mp4') }), - expect.objectContaining({ - field: 'chunkPath', - path: path.resolve('./android-long.part-002.mp4'), - }), - ]), - ); - expect(adbCommands).toEqual( - expect.arrayContaining([ - '-s emulator-5554 pull /sdcard/agent-device-recording-1.mp4 ' + - path.resolve('./android-long.mp4'), - '-s emulator-5554 pull /sdcard/agent-device-recording-2.mp4 ' + - path.resolve('./android-long.part-002.mp4'), - ]), - ); -}); - -test('Android recording rotation retries sequentially when concurrent screenrecord start fails', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); - const sessionStore = makeSessionStore(); - const sessionName = 'android-screenrecord-sequential-rotation'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }), - ); - - const adbCommands: string[] = []; - let startAttempt = 0; - let firstFailedStartIndex = -1; - let oldPidStopped = false; - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - adbCommands.push(command); - if (isAndroidScreenrecordStartCommand(command)) { - startAttempt += 1; - if (startAttempt === 1) return { stdout: '4321\n', stderr: '', exitCode: 0 }; - if (startAttempt <= 3) { - if (firstFailedStartIndex === -1) firstFailedStartIndex = adbCommands.length - 1; - return { stdout: '', stderr: 'encoder busy', exitCode: 1 }; - } - return { stdout: '4322\n', stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4$/.test( - command, - ) - ) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - if (command === '-s emulator-5554 shell ps -o pid= -p 4321') { - return oldPidStopped - ? { stdout: '', stderr: '', exitCode: 1 } - : { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (command === '-s emulator-5554 shell kill -2 4321') { - oldPidStopped = true; - return { stdout: '', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './android-sequential-rotation.mp4'], - }); - expect(response?.ok).toBe(true); - - await vi.advanceTimersByTimeAsync(170_000); - - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('android'); - if (recording?.platform !== 'android') { - throw new Error('expected Android recording'); - } - expect(recording.remotePid).toBe('4322'); - expect(recording.chunks).toHaveLength(2); - const stopOldChunk = adbCommands.findIndex( - (command) => command === '-s emulator-5554 shell kill -2 4321', - ); - const sequentialStart = adbCommands - .slice(stopOldChunk + 1) - .findIndex((command) => isAndroidScreenrecordStartCommand(command)); - expect(firstFailedStartIndex).toBeGreaterThan(-1); - expect(stopOldChunk).toBeGreaterThan(firstFailedStartIndex); - expect(sequentialStart).toBeGreaterThan(-1); -}); - -test('Android recording rotation discards next chunk when manifest commit fails', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); - const sessionStore = makeSessionStore(); - const sessionName = 'android-screenrecord-rotation-manifest-failure'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }), - ); - - const adbCommands: string[] = []; - let startAttempt = 0; - let manifestWriteCount = 0; - let nextPidStopped = false; - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - adbCommands.push(command); - if (command.includes('agent-device-recording-active.json.tmp')) { - manifestWriteCount += 1; - return manifestWriteCount === 4 - ? { stdout: '', stderr: 'manifest write failed', exitCode: 1 } - : { stdout: '', stderr: '', exitCode: 0 }; - } - if (isAndroidScreenrecordStartCommand(command)) { - startAttempt += 1; - return { stdout: `${4320 + startAttempt}\n`, stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - if (command === '-s emulator-5554 shell ps -o pid= -p 4322') { - return nextPidStopped - ? { stdout: '', stderr: '', exitCode: 1 } - : { stdout: '4322\n', stderr: '', exitCode: 0 }; - } - if (command === '-s emulator-5554 shell kill -2 4322') { - nextPidStopped = true; - return { stdout: '', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './android-rotation-manifest-failure.mp4'], - }); - expect(response?.ok).toBe(true); - - await vi.advanceTimersByTimeAsync(170_000); - - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('android'); - if (recording?.platform !== 'android') { - throw new Error('expected Android recording'); - } - expect(recording.remotePid).toBe('4321'); - expect(recording.chunks).toHaveLength(1); - expect(recording.rotationFailedReason).toMatch( - /failed to write Android recording recovery manifest/, - ); - expect(adbCommands).toContain('-s emulator-5554 shell kill -2 4322'); - expect( - adbCommands.some((command) => - /^-s emulator-5554 shell rm -f \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command), - ), - ).toBe(true); -}); - -test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-invalidated-recording'; - const session = makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }); - session.recording = { - platform: 'ios', - outPath: path.resolve('./invalidated.mp4'), - startedAt: Date.now() - 1_000, - showTouches: true, - gestureEvents: [], - invalidatedReason: 'iOS runner session exited during recording', - child: { kill: () => {} } as any, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }; - sessionStore.set(sessionName, session); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['stop'], - }); - - expect(response?.ok).toBe(true); - if (response?.ok === true) { - expect(response.data?.outPath).toBe(path.resolve('./invalidated.mp4')); - expect(response.data?.overlayWarning).toBe( - 'overlay unavailable: iOS runner session exited during recording', - ); - } - expect(sessionStore.get(sessionName)?.recording).toBeUndefined(); -}); - -test('record start accepts Android screenrecord before the remote file begins growing', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-running-without-file-growth'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }), - ); - - let psChecks = 0; - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - if ( - /^-s emulator-5554 shell screenrecord --bit-rate 8000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ) - ) { - return { stdout: '5555\n', stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) - ) { - return { stdout: '0\n', stderr: '', exitCode: 0 }; - } - if (command === '-s emulator-5554 shell ps -o pid= -p 5555') { - psChecks += 1; - return { stdout: '5555\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './android.mp4'], - }); - - expect(response?.ok).toBe(true); - expect(psChecks >= 2).toBe(true); -}); - -test('record start falls back to /data/local/tmp when /sdcard is unavailable on Android', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-fallback-path'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'device', - booted: true, - }), - ); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - const command = args.join(' '); - if ( - /^-s emulator-5554 shell screenrecord --bit-rate 8000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ) - ) { - return { stdout: 'permission denied\n', stderr: '', exitCode: 1 }; - } - if ( - /^-s emulator-5554 shell screenrecord --bit-rate 8000000 \/data\/local\/tmp\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ) - ) { - return { stdout: '7777\n', stderr: '', exitCode: 0 }; - } - if ( - /^-s emulator-5554 shell stat -c %s \/data\/local\/tmp\/agent-device-recording-\d+\.mp4$/.test( - command, - ) - ) { - return { stdout: '1024\n', stderr: '', exitCode: 0 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await runRecordCommand({ - sessionStore, - sessionName, - positionals: ['start', './android.mp4'], - }); - - expect(response?.ok).toBe(true); - const recording = sessionStore.get(sessionName)?.recording; - expect(recording?.platform).toBe('android'); - expect(recording?.platform === 'android' ? recording.remotePath : '').toMatch( - /^\/data\/local\/tmp\/agent-device-recording-\d+\.mp4$/, - ); -}); diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index fdf8403340..b0465fe115 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -73,6 +73,7 @@ test('capabilities reports supported commands for the selected session device', expect(runtime.uses).toEqual([ { required: [], preferred: ['appLogInspect'] }, { required: [], preferred: ['networkDump'] }, + { required: [], preferred: ['screenRecordingStart'] }, ]); }); @@ -120,6 +121,7 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim expect(runtime.uses).toEqual([ { required: [], preferred: ['appLogInspect'] }, { required: [], preferred: ['networkDump'] }, + { required: [], preferred: ['screenRecordingStart'] }, ]); }); @@ -244,6 +246,9 @@ function createAdmissionBinding( appLogReattach: unavailable, appLogCleanup: unavailable, networkDump: options.networkAvailable ? { available: true } : unavailable, + screenRecordingStart: unavailable, + screenRecordingReattach: unavailable, + screenRecordingCleanup: unavailable, }, }, operations: { diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 51939c4f7e..b261f9de78 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -72,6 +72,12 @@ import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtim import { acquireAdvisoryDeviceClaim } from '../../device-claims.ts'; import { inspectDeviceClaims } from '../../device-claim-inspection.ts'; import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts'; +import { + localRuntimeOwner, + type ScreenRecordingLiveHandle, +} from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { screenRecordingResourceStore } from '../../screen-recording-resource-store.ts'; const mockShutdownSimulator = vi.mocked(shutdownSimulator); const mockRunCmd = vi.mocked(runCmd); @@ -100,37 +106,104 @@ function makeSession(name: string, device: SessionState['device']): SessionState } function makeIosSimulatorRecordingSession( + sessionStore: SessionStore, name: string, - options: { recorderExitCode?: number } = {}, + options: { + recorderExitCode?: number; + cleanupConfirmed?: boolean; + device?: SessionState['device']; + } = {}, ): SessionState { - const session = makeSession(name, { - platform: 'apple', - id: 'sim-udid-recording', - name: 'iPhone 15', - kind: 'simulator', - booted: true, - }); + const session = makeSession( + name, + options.device ?? { + platform: 'apple', + id: 'sim-udid-recording', + name: 'iPhone 15', + kind: 'simulator', + booted: true, + }, + ); session.appBundleId = 'com.example.app'; - session.recording = { - platform: 'ios', - outPath: path.join(os.tmpdir(), `${name}.mp4`), - startedAt: Date.now() - 5_000, - showTouches: false, - gestureEvents: [], - child: { kill: vi.fn(), pid: 4242 }, - wait: Promise.resolve({ - stdout: '', - stderr: options.recorderExitCode ? 'recorder crashed' : '', - exitCode: options.recorderExitCode ?? 0, + const outPath = path.join(os.tmpdir(), `${name}.mp4`); + const finish = vi.fn(async () => + options.recorderExitCode + ? ({ + status: 'cleanup-pending', + reason: 'transport-failed', + message: 'failed to stop recording', + } as const) + : ({ + status: 'completed', + result: { + backend: 'simctl recordVideo', + outPath, + startedAt: Date.now() - 5_000, + completedAt: Date.now(), + scope: 'app', + showTouches: false, + recordOnlySession: false, + }, + } as const), + ); + const forceCleanup = vi.fn(async () => + options.cleanupConfirmed === false + ? ({ + status: 'cleanup-pending', + reason: 'transport-failed', + message: 'failed to force cleanup recording', + } as const) + : ({ status: 'cleaned' } as const), + ); + const handle: ScreenRecordingLiveHandle = { + inspect: () => ({ + backend: 'simctl recordVideo', + outPath, + startedAt: Date.now() - 5_000, + scope: 'app', + showTouches: false, + recordOnlySession: false, + gestureEvents: [], }), + appendGestureEvents: () => {}, + setTouchReferenceFrame: () => {}, + setRunnerSessionId: () => {}, + invalidate: () => {}, + finish, + forceCleanup, + [Symbol.asyncDispose]: async () => {}, + }; + const envelope = createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: name, + device: { id: session.device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, + owner: localRuntimeOwner('apple'), + fence: { token: `${name}-fence`, generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: { recordingId: name } }, + metadata: { phase: 'active' }, + }); + session.screenRecording = { + handle, + envelope, }; + screenRecordingResourceStore.write( + screenRecordingResourceStore.resolvePath(sessionStore.resolveSessionDir(name)), + envelope, + ); return session; } -function recordingKillMock(session: SessionState): ReturnType { - const recording = session.recording; - if (recording?.platform !== 'ios') throw new Error('expected an iOS simulator recording'); - return recording.child.kill as ReturnType; +function recordingFinishMock(session: SessionState): ReturnType { + const recording = session.screenRecording; + if (!recording) throw new Error('expected an active screen recording'); + return recording.handle.finish as ReturnType; +} + +function recordingCleanupMock(session: SessionState): ReturnType { + const recording = session.screenRecording; + if (!recording) throw new Error('expected an active screen recording'); + return recording.handle.forceCleanup as ReturnType; } beforeEach(() => { @@ -429,7 +502,8 @@ test('daemon session teardown stops active Apple xctrace perf capture', async () }, } as unknown as SessionState; - await teardownSessionResources({ appLog: 'already-settled', session, sessionName }); + const sessionStore = makeSessionStore(); + await teardownSessionResources({ appLog: 'already-settled', session, sessionName, sessionStore }); expect(mockCleanupAppleXctracePerfCapture).toHaveBeenCalledWith(activeCapture); expect(session.applePerf?.active).toBeUndefined(); @@ -438,8 +512,8 @@ test('daemon session teardown stops active Apple xctrace perf capture', async () test('close finalizes an active iOS simulator recording before deleting the session', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-active-recording-close-session'; - const session = makeIosSimulatorRecordingSession(sessionName); - const kill = recordingKillMock(session); + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); + const finish = recordingFinishMock(session); sessionStore.set(sessionName, session); const response = await handleSessionCommands({ @@ -459,19 +533,24 @@ test('close finalizes an active iOS simulator recording before deleting the sess expect(response?.ok).toBe(true); // The recorder was signaled (SIGINT finalizes the simctl mp4), the recording // was detached, and the session was deleted — no orphaned recordVideo child. - expect(kill).toHaveBeenCalledWith('SIGINT'); - expect(session.recording).toBeUndefined(); + expect(finish).toHaveBeenCalledOnce(); expect(sessionStore.get(sessionName)).toBeUndefined(); // An active recording at close time still defeats iOS runner retention even // though the recording is finalized (and cleared) before the retention step. expect(mockStopIosRunnerSession).toHaveBeenCalledWith(session.device.id); + expect(finish.mock.invocationCallOrder[0]).toBeLessThan( + mockStopIosRunnerSession.mock.invocationCallOrder[0]!, + ); }); test('close surfaces a recording finalization failure through the cleanup-failure channel', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-recording-close-failure-session'; - const session = makeIosSimulatorRecordingSession(sessionName, { recorderExitCode: 1 }); - const kill = recordingKillMock(session); + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { + recorderExitCode: 1, + }); + const finish = recordingFinishMock(session); + const forceCleanup = recordingCleanupMock(session); sessionStore.set(sessionName, session); await expect( @@ -491,33 +570,97 @@ test('close surfaces a recording finalization failure through the cleanup-failur ).rejects.toThrow(/recording: .*failed to stop recording/); // Cleanup failure is reported, later cleanup still ran, session still deleted. - expect(kill).toHaveBeenCalledWith('SIGINT'); + expect(finish).toHaveBeenCalledOnce(); + expect(forceCleanup).toHaveBeenCalledOnce(); expect(mockStopIosRunnerSession).toHaveBeenCalledWith(session.device.id); expect(sessionStore.get(sessionName)).toBeUndefined(); }); test('daemon session teardown finalizes an active iOS simulator recording', async () => { const sessionName = 'ios-active-recording-teardown-session'; - const session = makeIosSimulatorRecordingSession(sessionName); - const kill = recordingKillMock(session); + const sessionStore = makeSessionStore(); + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); + const finish = recordingFinishMock(session); + sessionStore.set(sessionName, session); - await teardownSessionResources({ appLog: 'already-settled', session, sessionName }); + await teardownSessionResources({ + appLog: 'already-settled', + session, + sessionName, + sessionStore, + }); + await teardownSessionResources({ + appLog: 'already-settled', + session, + sessionName, + sessionStore, + }); - expect(kill).toHaveBeenCalledWith('SIGINT'); - expect(session.recording).toBeUndefined(); + expect(finish).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)?.screenRecording).toBeUndefined(); + expect(finish.mock.invocationCallOrder[0]).toBeLessThan( + mockStopIosRunnerSession.mock.invocationCallOrder[0]!, + ); }); test('daemon session teardown surfaces a recording finalization failure', async () => { const sessionName = 'ios-recording-teardown-failure-session'; - const session = makeIosSimulatorRecordingSession(sessionName, { recorderExitCode: 1 }); - const kill = recordingKillMock(session); + const sessionStore = makeSessionStore(); + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { + recorderExitCode: 1, + }); + const finish = recordingFinishMock(session); + const forceCleanup = recordingCleanupMock(session); + sessionStore.set(sessionName, session); + + await expect( + teardownSessionResources({ + appLog: 'already-settled', + session, + sessionName, + sessionStore, + }), + ).rejects.toThrow(/recording: .*failed to stop recording/); + + expect(finish).toHaveBeenCalledOnce(); + expect(forceCleanup).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)?.screenRecording).toBeUndefined(); +}); + +test('daemon session teardown retains recording evidence when finish and forced cleanup both fail', async () => { + const sessionName = 'ios-recording-teardown-cleanup-failure-session'; + const sessionStore = makeSessionStore(); + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { + recorderExitCode: 1, + cleanupConfirmed: false, + }); + const finish = recordingFinishMock(session); + const forceCleanup = recordingCleanupMock(session); + sessionStore.set(sessionName, session); await expect( - teardownSessionResources({ appLog: 'already-settled', session, sessionName }), + teardownSessionResources({ + appLog: 'already-settled', + session, + sessionName, + sessionStore, + }), ).rejects.toThrow(/recording: .*failed to stop recording/); - expect(kill).toHaveBeenCalledWith('SIGINT'); - expect(session.recording).toBeUndefined(); + expect(finish).toHaveBeenCalledOnce(); + expect(forceCleanup).toHaveBeenCalledOnce(); + expect(sessionStore.get(sessionName)?.screenRecording).toBeDefined(); + expect( + screenRecordingResourceStore.read( + screenRecordingResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)), + ), + ).toMatchObject({ + status: 'decoded', + envelope: { + lifecycle: 'open', + metadata: { phase: 'cleanup-pending', cleanupPendingReason: 'transport-failed' }, + }, + }); }); test('close stops active Android native perf capture before deleting session', async () => { @@ -750,7 +893,8 @@ test('daemon session teardown stops active Android native perf capture', async ( }, } as unknown as SessionState; - await teardownSessionResources({ appLog: 'already-settled', session, sessionName }); + const sessionStore = makeSessionStore(); + await teardownSessionResources({ appLog: 'already-settled', session, sessionName, sessionStore }); expect(mockCleanupAndroidNativePerfSession).toHaveBeenCalledWith(session.device, activeCapture); expect(session.nativePerf?.android).toBeUndefined(); @@ -769,7 +913,8 @@ test('daemon session teardown stops Android snapshot helper session', async () = appBundleId: 'com.example.app', } as SessionState; - await teardownSessionResources({ appLog: 'already-settled', session, sessionName }); + const sessionStore = makeSessionStore(); + await teardownSessionResources({ appLog: 'already-settled', session, sessionName, sessionStore }); expect(mockStopAndroidSnapshotHelperSessionForDevice).toHaveBeenCalledWith(session.device); }); @@ -957,7 +1102,12 @@ test('daemon session teardown attempts every resource after an earlier cleanup r ); await expect( - teardownSessionResources({ appLog: 'already-settled', session, sessionName }), + teardownSessionResources({ + appLog: 'already-settled', + session, + sessionName, + sessionStore: makeSessionStore(), + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: expect.objectContaining({ @@ -1032,18 +1182,15 @@ test('close still runs later cleanup and deletes the session after an earlier cl test('targeted close preserves the platform-close AppError and still runs later cleanup', async () => { const sessionStore = makeSessionStore(); const sessionName = 'targeted-close-error-session'; - const session = { - ...makeSession(sessionName, { + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { + device: { platform: 'apple', id: 'sim-udid-close-error', name: 'iPhone 15', kind: 'simulator', booted: true, - }), - // Recording defeats runner retention so the apple_runner cleanup runs after - // the failed platform close, proving subsequent cleanup is still attempted. - recording: { outPath: '/tmp/recording.mp4' }, - } as unknown as SessionState; + }, + }); sessionStore.set(sessionName, session); const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { @@ -1111,13 +1258,8 @@ test('a failed platform close retains the device claim and reports it', async () if (!acquired.ownership) { throw new Error('expected the test session to acquire a device claim'); } - const session = { - ...makeSession(sessionName, device), - // Recording defeats runner retention so this mirrors the platform-close-error test above - // rather than exercising a different code path. - recording: { outPath: '/tmp/recording.mp4' }, - deviceClaim: acquired.ownership, - } as unknown as SessionState; + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { device }); + session.deviceClaim = acquired.ownership; sessionStore.set(sessionName, session); const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { @@ -1424,8 +1566,8 @@ test('close --save-script on a never-armed session is rejected before teardown, // guard runs *before* `stopBestEffortSessionResources` — not just that the response rejects. // Without it, moving the guard after teardown would still pass: there would be nothing for // teardown to observably touch. - const session = makeIosSimulatorRecordingSession(sessionName); - const kill = recordingKillMock(session); + const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); + const finish = recordingFinishMock(session); sessionStore.set(sessionName, session); await expect( @@ -1456,8 +1598,8 @@ test('close --save-script on a never-armed session is rejected before teardown, // No teardown hook ran: the recording is still live (recorder never signaled) and the runner // was never told to stop. This is the assertion that goes red if the guard moves after // `stopBestEffortSessionResources` — see the counterfactual in the PR description. - expect(kill).not.toHaveBeenCalled(); - expect(session.recording).toBeDefined(); + expect(finish).not.toHaveBeenCalled(); + expect(session.screenRecording).toBeDefined(); expect(mockStopIosRunnerSession).not.toHaveBeenCalled(); // A plain close (no --save-script) still closes the same session cleanly afterward, and now @@ -1476,8 +1618,7 @@ test('close --save-script on a never-armed session is rejected before teardown, invoke: noopInvoke, }); expect(plainClose?.ok).toBe(true); - expect(kill).toHaveBeenCalledWith('SIGINT'); - expect(session.recording).toBeUndefined(); + expect(finish).toHaveBeenCalledOnce(); expect(mockStopIosRunnerSession).toHaveBeenCalledWith(session.device.id); expect(sessionStore.get(sessionName)).toBeUndefined(); }); diff --git a/src/daemon/handlers/__tests__/session-logs.test.ts b/src/daemon/handlers/__tests__/session-logs.test.ts index 8580037d93..cd0d7ce971 100644 --- a/src/daemon/handlers/__tests__/session-logs.test.ts +++ b/src/daemon/handlers/__tests__/session-logs.test.ts @@ -359,7 +359,7 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { }); return createAppLogStartResult(handle, envelope); }); - const operations: PlatformRuntimeOperations = { + const operations: DeviceBinding['operations'] = { appLogInspect: inspect, appLogDoctor: doctor, appLogStart: start, @@ -410,6 +410,9 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { appLogReattach: { available: true }, appLogCleanup: { available: true }, networkDump: { available: true }, + screenRecordingStart: unavailableRecording, + screenRecordingReattach: unavailableRecording, + screenRecordingCleanup: unavailableRecording, }, }, operations, @@ -435,3 +438,8 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { forceCleanup, }; } + +const unavailableRecording = Object.freeze({ + available: false as const, + reason: 'owner-capability-missing' as const, +}); diff --git a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts index 697d49ca78..108d4c8fce 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts @@ -1,4 +1,4 @@ -import { test, expect, vi } from 'vitest'; +import { test, expect } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { @@ -10,6 +10,7 @@ import { assertInvalidArgsMessage, } from './session-test-harness.ts'; import { handleSessionCommands } from '../session.ts'; +import { makeTestScreenRecordingResource } from '../../../__tests__/test-utils/screen-recording-live-handle.ts'; test('open --relaunch rejects URL targets', async () => { const sessionStore = makeSessionStore(); @@ -206,6 +207,7 @@ test('open on in-use device returns DEVICE_IN_USE before readiness checks', asyn mockResolveTargetDevice.mockResolvedValue({ platform: 'apple', + appleOs: 'ios', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -241,25 +243,25 @@ test('open on device owned by recording session returns recording recovery hint' const sessionStore = makeSessionStore(); const recordingSession = makeSession('default', { platform: 'apple', + appleOs: 'ios', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', booted: true, }); recordingSession.recordOnlySession = true; - recordingSession.recording = { - platform: 'ios', - child: { kill: vi.fn(), pid: 123 }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + recordingSession.screenRecording = makeTestScreenRecordingResource(recordingSession, { + backend: 'simctl recordVideo', outPath: '/tmp/recording.mp4', startedAt: Date.now(), showTouches: false, - gestureEvents: [], - }; + recordOnlySession: true, + }); sessionStore.set('default', recordingSession); mockResolveTargetDevice.mockResolvedValue({ platform: 'apple', + appleOs: 'ios', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index 729c979d2d..c3c5417040 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -10,21 +10,32 @@ import { buildNestedReplayFlags, handleSessionReplayCommands } from '../session- import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; import { replayCommandFamily } from '../../../commands/replay/index.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; - -const recordTraceMocks = vi.hoisted(() => ({ +import { + unavailableBindDevice, + unavailableBindExactDevice, +} from '../../__tests__/test-device-runtime-gateway.ts'; +import { createScreenRecordingAdmissionLedger } from '../../screen-recording-admission-ledger.ts'; +import type { RecordRuntimeHandlerParams } from '../record-runtime.ts'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { + localRuntimeOwner, + type ScreenRecordingLiveHandle, +} from '@agent-device/contracts/platform'; + +const recordRuntimeMocks = vi.hoisted(() => ({ handleRecordCommand: vi.fn(), })); -vi.mock('../record-trace-recording.ts', () => ({ - handleRecordCommand: recordTraceMocks.handleRecordCommand, +vi.mock('../record-runtime.ts', () => ({ + handleRecordCommand: recordRuntimeMocks.handleRecordCommand, })); beforeEach(() => { vi.useRealTimers(); - recordTraceMocks.handleRecordCommand.mockReset(); + recordRuntimeMocks.handleRecordCommand.mockReset(); }); -type RecordCommandCall = [{ req: DaemonRequest; sessionName: string }]; +type RecordCommandCall = [RecordRuntimeHandlerParams]; type RecordVideoFixture = { root: string; @@ -37,6 +48,8 @@ type RecordVideoFixture = { type MockRecordingState = { recordingPath: string; events: string[]; + finish: ScreenRecordingLiveHandle['finish']; + liveSlotCleared: boolean; }; function createRecordVideoFixture(): RecordVideoFixture { @@ -53,7 +66,7 @@ function createRecordVideoFixture(): RecordVideoFixture { } function installMockRecordingHandler(sessionStore: SessionStore, state: MockRecordingState): void { - recordTraceMocks.handleRecordCommand.mockImplementation( + recordRuntimeMocks.handleRecordCommand.mockImplementation( async (params: { req: DaemonRequest }): Promise => await handleMockRecordCommand({ req: params.req, @@ -85,31 +98,56 @@ function startMockRecording(params: { state.recordingPath = req.positionals?.[1] ?? ''; const session = sessionStore.get(req.session); if (session) { - session.recording = { - platform: 'ios', - outPath: state.recordingPath, - startedAt: Date.now(), - showTouches: true, - gestureEvents: [], - child: { kill: () => true, pid: 123 }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - } as NonNullable; + const outPath = state.recordingPath; + const handle: ScreenRecordingLiveHandle = { + inspect: () => ({ + backend: 'test', + outPath, + startedAt: Date.now(), + scope: 'app', + showTouches: false, + recordOnlySession: false, + gestureEvents: [], + }), + appendGestureEvents: () => {}, + setTouchReferenceFrame: () => {}, + setRunnerSessionId: () => {}, + invalidate: () => {}, + finish: state.finish, + forceCleanup: async () => ({ status: 'cleaned' }), + [Symbol.asyncDispose]: async () => {}, + }; + session.screenRecording = { + handle, + envelope: createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: session.name, + device: { id: session.device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, + owner: localRuntimeOwner('apple'), + fence: { token: `${session.name}-fence`, generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: { recordingId: session.name } }, + metadata: { phase: 'active' }, + }), + }; sessionStore.set(req.session, session); } return { ok: true, data: { recording: 'started', outPath: state.recordingPath } }; } -function stopMockRecording(params: { +async function stopMockRecording(params: { req: DaemonRequest; sessionStore: SessionStore; state: MockRecordingState; -}): DaemonResponse { +}): Promise { const { req, sessionStore, state } = params; state.events.push('record:stop'); const session = sessionStore.get(req.session); if (session) { - session.recording = undefined; + await session.screenRecording?.handle.finish(); + session.screenRecording = undefined; sessionStore.set(req.session, session); + state.liveSlotCleared = sessionStore.get(req.session)?.screenRecording === undefined; } fs.writeFileSync(state.recordingPath, 'video'); return { @@ -132,22 +170,44 @@ function stopMockRecording(params: { function expectRecordVideoCalls(params: { generatedSession: string; artifactsDir: string | undefined; + admissionLedger: RecordRuntimeHandlerParams['admissionLedger']; + requestScope: RecordRuntimeHandlerParams['requestScope']; + throwIfCanceled: RecordRuntimeHandlerParams['throwIfCanceled']; }): void { - const { generatedSession, artifactsDir } = params; - const recordCalls = recordTraceMocks.handleRecordCommand.mock.calls as RecordCommandCall[]; - assert.equal(recordCalls.length, 2); - - const startCall = recordCalls[0]?.[0]; - const stopCall = recordCalls[1]?.[0]; - assert.equal(startCall?.sessionName, generatedSession); - assert.equal(startCall?.req.session, generatedSession); - assert.deepEqual(startCall?.req.positionals, [ + const { artifactsDir } = params; + const [startCall, stopCall] = requireRecordVideoCalls(); + expectRecordRuntimeCall(startCall, params); + assert.deepEqual(startCall.req.positionals, [ 'start', path.join(artifactsDir ?? '', 'attempt-1', 'recording.mp4'), ]); - assert.equal(stopCall?.sessionName, generatedSession); - assert.equal(stopCall?.req.session, generatedSession); - assert.deepEqual(stopCall?.req.positionals, ['stop']); + expectRecordRuntimeCall(stopCall, params); + assert.deepEqual(stopCall.req.positionals, ['stop']); +} + +function requireRecordVideoCalls(): [RecordRuntimeHandlerParams, RecordRuntimeHandlerParams] { + const calls = recordRuntimeMocks.handleRecordCommand.mock.calls as RecordCommandCall[]; + assert.equal(calls.length, 2); + const startCall = calls[0]?.[0]; + const stopCall = calls[1]?.[0]; + if (!startCall || !stopCall) throw new Error('Expected record start and stop calls'); + return [startCall, stopCall]; +} + +function expectRecordRuntimeCall( + call: RecordRuntimeHandlerParams, + expected: Pick< + Parameters[0], + 'generatedSession' | 'admissionLedger' | 'requestScope' | 'throwIfCanceled' + >, +): void { + assert.equal(call.sessionName, expected.generatedSession); + assert.equal(call.req.session, expected.generatedSession); + assert.strictEqual(call.bindDevice, unavailableBindDevice); + assert.strictEqual(call.bindExactDevice, unavailableBindExactDevice); + assert.strictEqual(call.admissionLedger, expected.admissionLedger); + assert.strictEqual(call.requestScope, expected.requestScope); + assert.strictEqual(call.throwIfCanceled, expected.throwIfCanceled); } test('buildNestedReplayFlags returns parent flags untouched when neither override is set', () => { @@ -211,10 +271,41 @@ test('buildNestedReplayFlags strips test-only recordVideo before replay actions assert.deepEqual(result, { platform: 'ios' }); }); -test('test normalizes false replay-only booleans while recording each replay attempt', async () => { +test('test finalizes replay video exactly once when cancellation arrives after start', async () => { vi.useFakeTimers({ now: 1_000 }); const { root, replayPath, sessionStore, nestedRequests, events } = createRecordVideoFixture(); - installMockRecordingHandler(sessionStore, { recordingPath: '', events }); + const finish = vi.fn(async () => ({ + status: 'completed' as const, + result: { + backend: 'test', + outPath: path.join(root, 'capture.mp4'), + startedAt: 1, + completedAt: 2, + scope: 'app' as const, + showTouches: false, + recordOnlySession: false, + }, + })); + const recordingState: MockRecordingState = { + recordingPath: '', + events, + finish, + liveSlotCleared: false, + }; + installMockRecordingHandler(sessionStore, recordingState); + const screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger(); + const requestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }; + const cancellation = new Error('request canceled after recording start'); + const throwIfCanceled = vi + .fn<() => void>() + .mockImplementationOnce(() => {}) + .mockImplementation(() => { + throw cancellation; + }); const responsePromise = handleSessionReplayCommands({ req: { @@ -235,6 +326,12 @@ test('test normalizes false replay-only booleans while recording each replay att logPath: path.join(root, 'daemon.log'), sessionStore, leaseRegistry: new LeaseRegistry(), + bindDevice: unavailableBindDevice, + bindExactDevice: unavailableBindExactDevice, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock: async () => {}, + throwIfCanceled, invoke: async (nestedReq) => { nestedRequests.push(nestedReq); if (nestedReq.command === 'open') { @@ -260,7 +357,16 @@ test('test normalizes false replay-only booleans while recording each replay att const testResult = suite.tests?.[0] ?? {}; const generatedSession = testResult.session; if (typeof generatedSession !== 'string') throw new Error('Expected generated test session'); - expectRecordVideoCalls({ generatedSession, artifactsDir: testResult.artifactsDir }); + expectRecordVideoCalls({ + generatedSession, + artifactsDir: testResult.artifactsDir, + admissionLedger: screenRecordingAdmissionLedger, + requestScope, + throwIfCanceled, + }); + assert.equal(throwIfCanceled.mock.calls.length, 1); + assert.equal(finish.mock.calls.length, 1); + assert.equal(recordingState.liveSlotCleared, true); assert.deepEqual(events, ['record:start', 'open:dispatch', 'record:stop']); const timingPath = path.join(testResult.artifactsDir ?? '', 'attempt-1', 'replay-timing.ndjson'); const timingEvents = fs diff --git a/src/daemon/handlers/__tests__/trace-runtime.test.ts b/src/daemon/handlers/__tests__/trace-runtime.test.ts new file mode 100644 index 0000000000..a2437ceb46 --- /dev/null +++ b/src/daemon/handlers/__tests__/trace-runtime.test.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { SessionStore } from '../../session-store.ts'; +import type { DaemonRequest, SessionState } from '../../types.ts'; +import { handleTraceCommand } from '../trace-runtime.ts'; + +function fixture() { + const root = mkdtempForTestSync('agent-device-trace-runtime-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const session: SessionState = { + name: 'trace-session', + device: { + platform: 'android', + id: 'emulator-5554', + name: 'Android', + kind: 'emulator', + booted: true, + }, + createdAt: Date.now(), + actions: [], + }; + sessionStore.set(session.name, session); + return { root, session, sessionStore }; +} + +function request(positionals: string[], clientOutPath?: string): DaemonRequest { + return { + token: 'token', + session: 'trace-session', + command: 'trace', + positionals, + flags: {}, + ...(clientOutPath ? { meta: { clientArtifactPaths: { outPath: clientOutPath } } } : {}), + }; +} + +test('starts and stops one trace through the session-owned trace slot', () => { + const { session, sessionStore } = fixture(); + const start = handleTraceCommand({ + req: request(['start']), + sessionName: session.name, + sessionStore, + }); + const startedPath = session.trace?.outPath; + expect(startedPath).toMatch(/trace-session-.*\.trace\.log$/); + expect(start).toMatchObject({ ok: true, data: { trace: 'started', outPath: startedPath } }); + + const stop = handleTraceCommand({ + req: request(['stop']), + sessionName: session.name, + sessionStore, + }); + expect(stop).toMatchObject({ ok: true, data: { trace: 'stopped' } }); + expect(session.trace).toBeUndefined(); +}); + +test('relocates trace output and projects the client artifact path', () => { + const { root, session, sessionStore } = fixture(); + const original = path.join(root, 'original.trace'); + const relocated = path.join(root, 'relocated.trace'); + const client = '/client/trace.json'; + handleTraceCommand({ + req: request(['start', original]), + sessionName: session.name, + sessionStore, + }); + + const stop = handleTraceCommand({ + req: request(['stop', relocated], client), + sessionName: session.name, + sessionStore, + }); + expect(fs.existsSync(original)).toBe(false); + expect(fs.existsSync(relocated)).toBe(true); + expect(stop).toMatchObject({ + ok: true, + data: { + outPath: relocated, + artifacts: [{ path: relocated, localPath: client, fileName: 'trace.json' }], + }, + }); +}); + +test('rejects invalid actions, missing sessions, and incoherent lifecycle transitions', () => { + const { session, sessionStore } = fixture(); + expect( + handleTraceCommand({ req: request(['pause']), sessionName: session.name, sessionStore }), + ).toMatchObject({ ok: false, error: { code: 'INVALID_ARGS' } }); + expect( + handleTraceCommand({ req: request(['start']), sessionName: 'missing', sessionStore }), + ).toMatchObject({ ok: false, error: { code: 'SESSION_NOT_FOUND' } }); + expect( + handleTraceCommand({ req: request(['stop']), sessionName: session.name, sessionStore }), + ).toMatchObject({ ok: false, error: { message: 'no active trace' } }); + + handleTraceCommand({ req: request(['start']), sessionName: session.name, sessionStore }); + expect( + handleTraceCommand({ req: request(['start']), sessionName: session.name, sessionStore }), + ).toMatchObject({ ok: false, error: { message: 'trace already in progress' } }); +}); diff --git a/src/daemon/handlers/interaction-touch-reference-frame.ts b/src/daemon/handlers/interaction-touch-reference-frame.ts index 9feaceeda1..b00bb2014c 100644 --- a/src/daemon/handlers/interaction-touch-reference-frame.ts +++ b/src/daemon/handlers/interaction-touch-reference-frame.ts @@ -17,11 +17,13 @@ async function resolveDirectTouchReferenceFrame(params: { captureSnapshotForSession: CaptureSnapshotForSession; }): Promise { const { session, flags, sessionStore, contextFromFlags, captureSnapshotForSession } = params; - if (!session.recording) { + const recording = session.screenRecording?.handle; + if (!recording) { return undefined; } - if (session.recording.touchReferenceFrame) { - return session.recording.touchReferenceFrame; + const currentFrame = recording.inspect().touchReferenceFrame; + if (currentFrame) { + return currentFrame; } if (session.device.platform === 'android') { @@ -30,31 +32,21 @@ async function resolveDirectTouchReferenceFrame(params: { referenceWidth: size.width, referenceHeight: size.height, }; - if (session.recording) { - session.recording.touchReferenceFrame = referenceFrame; - } + recording.setTouchReferenceFrame(referenceFrame); return referenceFrame; } const snapshotFrame = getSnapshotReferenceFrame(session.snapshot); if (snapshotFrame) { - if (session.recording) { - session.recording.touchReferenceFrame = snapshotFrame; - } + recording.setTouchReferenceFrame(snapshotFrame); return snapshotFrame; } - if (!session.recording) { - return undefined; - } - const snapshot = await captureSnapshotForSession(session, flags, sessionStore, contextFromFlags, { interactiveOnly: true, }); const referenceFrame = getSnapshotReferenceFrame(snapshot); - if (referenceFrame && session.recording) { - session.recording.touchReferenceFrame = referenceFrame; - } + if (referenceFrame) recording.setTouchReferenceFrame(referenceFrame); return referenceFrame; } diff --git a/src/daemon/handlers/interaction.ts b/src/daemon/handlers/interaction.ts index 58050e21db..be335fef53 100644 --- a/src/daemon/handlers/interaction.ts +++ b/src/daemon/handlers/interaction.ts @@ -72,7 +72,7 @@ async function dispatchTypeViaRuntime( async function recoverAndroidRecordingDialogForType( session: SessionState, ): Promise { - if (session.device.platform === 'android' && session.recording) { + if (session.device.platform === 'android' && session.screenRecording) { const androidRecoveryResult = await recoverAndroidBlockingSystemDialog({ session }); if (androidRecoveryResult.status === 'failed') { return errorResponse('COMMAND_FAILED', 'Android system dialog blocked the recording session'); diff --git a/src/daemon/handlers/record-runtime-request.ts b/src/daemon/handlers/record-runtime-request.ts new file mode 100644 index 0000000000..e940d47f26 --- /dev/null +++ b/src/daemon/handlers/record-runtime-request.ts @@ -0,0 +1,68 @@ +import { + RECORDING_EXPORT_QUALITIES, + recordingQualityInputToExportQuality, + type RecordingExportQuality, + type RecordingScope, +} from '@agent-device/contracts/recording'; +import { retiredScreenshotMaxSizeFlagError } from '@agent-device/contracts/capture'; +import { AppError } from '@agent-device/kernel/errors'; +import type { DaemonRequest } from '../types.ts'; +import { hasExplicitSessionFlag } from '../session-routing.ts'; + +const IOS_DEVICE_RECORD_MIN_FPS = 1; +const IOS_DEVICE_RECORD_MAX_FPS = 120; + +export type PreparedRecordingRequest = Readonly<{ + scope: RecordingScope; + fps?: number; + exportQuality?: RecordingExportQuality; + showTouches: boolean; + hideTouchesRequested: boolean; +}>; + +export function prepareRecordingRequest(req: DaemonRequest): PreparedRecordingRequest { + const removedFlag = retiredScreenshotMaxSizeFlagError('record', req.flags); + if (removedFlag) throw new AppError('INVALID_ARGS', removedFlag); + const fps = req.flags?.fps; + if ( + fps !== undefined && + (!Number.isInteger(fps) || fps < IOS_DEVICE_RECORD_MIN_FPS || fps > IOS_DEVICE_RECORD_MAX_FPS) + ) { + throw new AppError( + 'INVALID_ARGS', + `fps must be an integer between ${IOS_DEVICE_RECORD_MIN_FPS} and ${IOS_DEVICE_RECORD_MAX_FPS}`, + ); + } + const exportQuality = recordingQualityInputToExportQuality(req.flags?.quality); + if (req.flags?.quality !== undefined && exportQuality === undefined) { + throw new AppError( + 'INVALID_ARGS', + `quality must be one of: ${RECORDING_EXPORT_QUALITIES.join(', ')} (legacy numeric values 5-10 are accepted)`, + ); + } + return { + scope: readRecordingScope(req.flags?.recordingScope), + ...(fps === undefined ? {} : { fps }), + ...(exportQuality === undefined ? {} : { exportQuality }), + showTouches: req.flags?.hideTouches !== true, + hideTouchesRequested: req.flags?.hideTouches === true, + }; +} + +export function readRecordingScope(value: unknown): RecordingScope { + if (value === undefined) return 'app'; + if (value === 'app' || value === 'device' || value === 'system') return value; + throw new AppError('INVALID_ARGS', 'record scope must be app, device, or system'); +} + +export function missingAppSessionResponse(req: DaemonRequest) { + return { + ok: false as const, + error: { + code: 'INVALID_ARGS', + message: hasExplicitSessionFlag(req) + ? 'record start with app scope and an explicit session requires an active app session; run open first, or use --scope device to record the full screen' + : 'record start defaults to app scope and requires an active app session; run open first, or use --scope device to record the full screen', + }, + }; +} diff --git a/src/daemon/handlers/record-runtime-response.ts b/src/daemon/handlers/record-runtime-response.ts new file mode 100644 index 0000000000..2b8b682866 --- /dev/null +++ b/src/daemon/handlers/record-runtime-response.ts @@ -0,0 +1,111 @@ +import path from 'node:path'; +import type { RecordingCommandResult } from '@agent-device/contracts/recording'; +import type { + ScreenRecordingCompletion, + ScreenRecordingLiveSnapshot, + RuntimeOperationUnavailability, +} from '@agent-device/contracts/platform'; +import type { DaemonArtifact, DaemonResponse } from '../types.ts'; +import { deriveRecordingTelemetryPath } from '../recording-telemetry.ts'; + +export function buildRecordingStartResponse( + snapshot: ScreenRecordingLiveSnapshot, + sessionStateDir: string, + requestedOutPath: string, +): DaemonResponse { + return { + ok: true, + data: { + recording: 'started', + outPath: snapshot.clientOutPath ?? requestedOutPath, + sessionStateDir, + recordingBackend: snapshot.backend, + recordingScope: snapshot.scope, + recordOnlySession: snapshot.recordOnlySession, + activeSessionApp: snapshot.activeSessionApp, + showTouches: snapshot.showTouches, + } satisfies RecordingCommandResult, + }; +} + +export function buildRecordingStartedAction(snapshot: ScreenRecordingLiveSnapshot) { + return { + action: 'start', + ...(snapshot.clientOutPath ? { requestedFileName: path.basename(snapshot.clientOutPath) } : {}), + showTouches: snapshot.showTouches, + }; +} + +export function buildRecordingUnsupportedResponse( + fact: RuntimeOperationUnavailability, +): DaemonResponse { + return { + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'record is not supported on this device', + hint: + fact.hint ?? + 'Select an Apple, Android, physical HarmonyOS, or web target that supports screen recording.', + details: { reason: fact.reason }, + }, + }; +} + +export function buildRecordingStopResponse(completion: ScreenRecordingCompletion): DaemonResponse { + const artifacts: DaemonArtifact[] = [recordingArtifact(completion)]; + if (completion.chunks && completion.chunks.length > 1) { + artifacts.push( + ...completion.chunks.slice(1).map((chunk) => ({ + field: 'chunkPath', + artifactType: 'screen-recording-chunk' as const, + path: chunk.path, + localPath: chunk.clientOutPath, + fileName: path.basename(chunk.clientOutPath ?? chunk.path), + })), + ); + } + if (completion.telemetryPath) { + const clientTelemetryPath = completion.clientOutPath + ? deriveRecordingTelemetryPath(completion.clientOutPath) + : undefined; + artifacts.push({ + field: 'telemetryPath', + artifactType: 'screen-recording-telemetry', + path: completion.telemetryPath, + localPath: clientTelemetryPath, + fileName: path.basename(completion.telemetryPath), + }); + } + return { + ok: true, + data: { + recording: 'stopped', + outPath: completion.outPath, + telemetryPath: completion.telemetryPath, + artifacts, + recordingBackend: completion.backend, + recordingScope: completion.scope, + recordOnlySession: completion.recordOnlySession, + activeSessionApp: completion.activeSessionApp, + durationMs: Math.max(0, completion.completedAt - completion.startedAt), + showTouches: completion.showTouches, + warning: completion.warning, + overlayWarning: completion.overlayWarning, + chunks: completion.chunks?.map((chunk) => ({ + index: chunk.index, + path: chunk.clientOutPath ?? chunk.path, + })), + } satisfies RecordingCommandResult, + }; +} + +function recordingArtifact(completion: ScreenRecordingCompletion): DaemonArtifact { + return { + field: 'outPath', + artifactType: 'screen-recording', + path: completion.outPath, + localPath: completion.clientOutPath, + fileName: path.basename(completion.clientOutPath ?? completion.outPath), + }; +} diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts new file mode 100644 index 0000000000..6b55cd5c1a --- /dev/null +++ b/src/daemon/handlers/record-runtime.ts @@ -0,0 +1,296 @@ +import path from 'node:path'; +import { + resolveScreenRecordingRuntimePlan, + screenRecordingAdmissionUse, + screenRecordingStartUse, + screenRecordingRecoveryUse, + type DurableResourceEnvelope, + type PlatformRequestScope, + type ScreenRecordingStartInput, +} from '@agent-device/contracts/platform'; +import { isWholeScreenRecordingScope } from '@agent-device/contracts/recording'; +import { deviceIdentity, sameDeviceIdentity } from '@agent-device/kernel/device'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { resolveTargetDevice } from '../../core/dispatch.ts'; +import { ensureDeviceReady } from '../device-ready.ts'; +import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import { + adoptStartedScreenRecording, + finishLiveScreenRecording, + finishRecoveredScreenRecording, + screenRecordingDurableResource, +} from '../screen-recording-session-resource.ts'; +import { resolveImplicitSessionScope } from '../session-routing.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { recordSessionAction } from './handler-utils.ts'; +import { + missingAppSessionResponse, + prepareRecordingRequest, + readRecordingScope, +} from './record-runtime-request.ts'; +import { resolveRecordingOutputPaths } from '../../recording/output-path.ts'; +import { + buildRecordingStartResponse, + buildRecordingStartedAction, + buildRecordingStopResponse, + buildRecordingUnsupportedResponse, +} from './record-runtime-response.ts'; + +export type RecordRuntimeHandlerParams = Readonly<{ + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; + admissionLedger: ScreenRecordingAdmissionLedger; + requestScope: PlatformRequestScope; + retainDeviceExecutionLock(deviceId: string): Promise; + throwIfCanceled(): void; +}>; + +export async function handleRecordCommand( + params: RecordRuntimeHandlerParams, +): Promise { + try { + return await handleRecordCommandUnsafe(params); + } catch (error) { + return { ok: false, error: normalizeError(error) }; + } +} + +async function handleRecordCommandUnsafe( + params: RecordRuntimeHandlerParams, +): Promise { + const { req, sessionName, sessionStore } = params; + const existingSession = sessionStore.get(sessionName); + const { plan, scope } = resolveRecordPlan(req, existingSession); + if (plan.kind === 'start' && !isWholeScreenRecordingScope(scope) && !existingSession) { + return missingAppSessionResponse(req); + } + const session = await resolveRecordingSession(params, existingSession); + if (plan.kind === 'start') { + return await startRecording(params, session, prepareRecordingRequest(req), plan.use); + } + return await stopRecording(params, session, plan.kind); +} + +function resolveRecordPlan(req: DaemonRequest, session: SessionState | undefined) { + const scope = readRecordingScope(req.flags?.recordingScope); + return { + scope, + plan: resolveScreenRecordingRuntimePlan({ + action: req.positionals?.[0], + scope, + hasLiveHandle: session?.screenRecording !== undefined, + }), + }; +} + +async function resolveRecordingSession( + params: RecordRuntimeHandlerParams, + existing: SessionState | undefined, +): Promise { + const device = existing?.device ?? (await resolveTargetDevice(params.req.flags ?? {})); + await params.retainDeviceExecutionLock(device.id); + if (existing) return existing; + await ensureDeviceReady(device); + return createRecordOnlySession(params, device); +} + +async function startRecording( + params: RecordRuntimeHandlerParams, + session: SessionState, + prepared: ReturnType, + use: typeof screenRecordingStartUse, +): Promise { + if (session.screenRecording) { + return { ok: false, error: { code: 'INVALID_ARGS', message: 'recording already in progress' } }; + } + const admission = await params.bindDevice(session.device, screenRecordingAdmissionUse); + const startFact = admission.facts.screenRecordingStart; + if (!startFact.available) return buildRecordingUnsupportedResponse(startFact); + const runtime = await params.bindDevice(session.device, use); + const { fence, outputPaths } = prepareRecordingStart(params, session); + const started = await runtime.operations.screenRecordingStart( + screenRecordingStartInput(params, session, prepared, fence, outputPaths.outputPath), + ); + await adoptStartedScreenRecording({ + admissionLedger: params.admissionLedger, + session, + sessionName: params.sessionName, + sessionStore: params.sessionStore, + device: session.device, + owner: runtime.owner, + fence, + ...started, + throwIfCanceled: params.throwIfCanceled, + }); + const adopted = params.sessionStore.get(params.sessionName)?.screenRecording; + if (!adopted) throw new TypeError('Screen recording adoption did not publish a live handle'); + const snapshot = adopted.handle.inspect(); + recordSessionAction( + params.sessionStore, + session, + params.req, + params.req.command, + buildRecordingStartedAction(snapshot), + ); + return buildRecordingStartResponse( + snapshot, + params.sessionStore.ensureSessionDir(params.sessionName), + outputPaths.requestedPath, + ); +} + +function prepareRecordingStart(params: RecordRuntimeHandlerParams, session: SessionState) { + const resourcePath = screenRecordingDurableResource.store.resolvePath( + params.sessionStore.resolveSessionDir(params.sessionName), + ); + return { + fence: screenRecordingDurableResource.createNextFence({ + admissionLedger: params.admissionLedger, + resourcePath, + device: session.device, + }), + outputPaths: resolveRecordingOutputPaths({ + requestedPath: params.req.positionals?.[1], + platform: session.device.platform, + cwd: params.req.meta?.cwd, + }), + }; +} + +function screenRecordingStartInput( + params: RecordRuntimeHandlerParams, + session: SessionState, + prepared: ReturnType, + fence: ScreenRecordingStartInput['fence'], + outputPath: string, +): ScreenRecordingStartInput { + return { + sessionId: params.sessionName, + outputPath, + clientOutputPath: params.req.meta?.clientArtifactPaths?.outPath, + scope: prepared.scope, + showTouches: prepared.showTouches, + hideTouchesRequested: prepared.hideTouchesRequested, + recordOnlySession: session.recordOnlySession === true, + activeSessionApp: recordingAppIdentity(session), + exportQuality: prepared.exportQuality, + fps: prepared.fps, + fence, + }; +} + +function recordingAppIdentity( + session: SessionState, +): ScreenRecordingStartInput['activeSessionApp'] { + if (!session.appBundleId) return undefined; + return { bundleId: session.appBundleId, ...(session.appName ? { name: session.appName } : {}) }; +} + +async function stopRecording( + params: RecordRuntimeHandlerParams, + session: SessionState, + kind: 'stop-live' | 'stop-recovery', +): Promise { + let completion; + try { + completion = + kind === 'stop-live' + ? await finishLiveScreenRecording({ + session, + sessionName: params.sessionName, + sessionStore: params.sessionStore, + }) + : await finishRecovered(params, session); + } catch (error) { + deleteTerminalRecordOnlySession(params, session); + throw error; + } + const response = buildRecordingStopResponse(completion); + recordSessionAction(params.sessionStore, session, params.req, params.req.command, { + action: 'stop', + outPath: completion.outPath, + ...(completion.clientOutPath + ? { requestedFileName: path.basename(completion.clientOutPath) } + : {}), + showTouches: completion.showTouches, + }); + if (session.recordOnlySession) params.sessionStore.delete(params.sessionName); + return response; +} + +function deleteTerminalRecordOnlySession( + params: Pick, + session: SessionState, +): void { + if (!session.recordOnlySession) return; + const resourcePath = screenRecordingDurableResource.store.resolvePath( + params.sessionStore.resolveSessionDir(params.sessionName), + ); + const record = screenRecordingDurableResource.store.read(resourcePath); + if (record.status === 'decoded' && record.envelope.lifecycle === 'completed') { + params.sessionStore.delete(params.sessionName); + } +} + +async function finishRecovered(params: RecordRuntimeHandlerParams, session: SessionState) { + const resourcePath = screenRecordingDurableResource.store.resolvePath( + params.sessionStore.resolveSessionDir(params.sessionName), + ); + const record = screenRecordingDurableResource.store.read(resourcePath); + if (record.status !== 'decoded' || record.envelope.lifecycle !== 'open') { + throw new AppError('INVALID_ARGS', 'no active recording'); + } + if (record.envelope.sessionId !== params.sessionName) { + throw new AppError( + 'COMMAND_FAILED', + 'Screen recording recovery record does not belong to the requested session', + { reason: 'runtime-contract-invalid' }, + ); + } + if (!sameDeviceIdentity(record.envelope.device, deviceIdentity(session.device))) { + throw new AppError( + 'COMMAND_FAILED', + 'Screen recording recovery device does not match the selected device', + { reason: 'runtime-contract-invalid' }, + ); + } + return await finishRecoveredScreenRecording({ + resourcePath, + scope: params.requestScope, + acquireControl: async (envelope, recoveryScope) => { + const runtime = await params.bindExactDevice( + session.device, + envelope.owner, + envelope.fence, + screenRecordingRecoveryUse, + recoveryScope, + ); + return { + reattach: async (candidate: DurableResourceEnvelope<'screen-recording'>) => + await runtime.operations.screenRecordingReattach({ envelope: candidate }), + cleanup: async (candidate: DurableResourceEnvelope<'screen-recording'>) => + await runtime.operations.screenRecordingCleanup({ envelope: candidate }), + [Symbol.asyncDispose]: async () => {}, + }; + }, + }); +} + +function createRecordOnlySession( + params: Pick, + device: SessionState['device'], +): SessionState { + return { + name: params.sessionName, + sessionScope: resolveImplicitSessionScope(params.req), + device, + createdAt: Date.now(), + recordOnlySession: true, + actions: [], + }; +} diff --git a/src/daemon/handlers/record-trace-android-chunks.ts b/src/daemon/handlers/record-trace-android-chunks.ts deleted file mode 100644 index 05d2696408..0000000000 --- a/src/daemon/handlers/record-trace-android-chunks.ts +++ /dev/null @@ -1,307 +0,0 @@ -import path from 'node:path'; -import type { SessionState } from '../types.ts'; -import type { RecordTraceDeps } from './record-trace-types.ts'; -import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; -import { persistRecordingTelemetry } from '../recording-telemetry.ts'; - -const ANDROID_SCREENRECORD_TIME_LIMIT_MS = 180_000; -const ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS = 2_000; -const ANDROID_SCREENRECORD_CHUNK_MS = 170_000; - -type AndroidRecording = Extract, { platform: 'android' }>; - -type AndroidScreenrecordChunk = { - remotePath: string; - remotePid: string; - startedAt: number; -}; - -export function deriveAndroidChunkOutPath(outPath: string, chunkIndex: number): string { - if (chunkIndex === 1) { - return outPath; - } - const parsed = path.parse(outPath); - const extension = parsed.ext || '.mp4'; - return path.join( - parsed.dir, - `${parsed.name}.part-${String(chunkIndex).padStart(3, '0')}${extension}`, - ); -} - -export function ensureAndroidRecordingChunks( - recording: AndroidRecording, -): NonNullable { - recording.chunks ??= [ - { - index: 1, - path: recording.outPath, - remotePath: recording.remotePath, - }, - ]; - return recording.chunks; -} - -export function resolveAndroidScreenrecordLimitWarning( - recording: AndroidRecording, -): string | undefined { - const elapsedMs = Date.now() - recording.startedAt; - if (elapsedMs < ANDROID_SCREENRECORD_TIME_LIMIT_MS - ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS) { - return undefined; - } - return 'Android adb screenrecord stopped before record stop, likely after reaching the 180s platform limit. The MP4 may be truncated; final interactions after the limit are not in the video.'; -} - -export function scheduleAndroidRecordingRotation(params: { - recording: AndroidRecording; - startNextChunk: ( - preferredRemoteDir: string, - nextIndex: number, - ) => Promise; - finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; - cleanupStartedChunk?: (chunk: AndroidScreenrecordChunk) => Promise; - persistRecordingState?: (recording: AndroidRecording) => Promise; -}): void { - const { - recording, - startNextChunk, - finishCurrentChunk, - cleanupStartedChunk, - persistRecordingState, - } = params; - const timer = setTimeout(() => { - recording.rotationPromise = rotateAndroidRecordingChunk({ - recording, - startNextChunk, - finishCurrentChunk, - cleanupStartedChunk, - persistRecordingState, - }) - .catch((error: unknown) => { - recording.rotationFailedReason = error instanceof Error ? error.message : String(error); - }) - .finally(() => { - recording.rotationPromise = undefined; - if (!recording.stopping && !recording.rotationFailedReason) { - scheduleAndroidRecordingRotation({ - recording, - startNextChunk, - finishCurrentChunk, - cleanupStartedChunk, - persistRecordingState, - }); - } - }); - }, ANDROID_SCREENRECORD_CHUNK_MS); - timer.unref?.(); - recording.rotationTimer = timer; -} - -async function rotateAndroidRecordingChunk(params: { - recording: AndroidRecording; - startNextChunk: ( - preferredRemoteDir: string, - nextIndex: number, - ) => Promise; - finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; - cleanupStartedChunk?: (chunk: AndroidScreenrecordChunk) => Promise; - persistRecordingState?: (recording: AndroidRecording) => Promise; -}): Promise { - const { - recording, - startNextChunk, - finishCurrentChunk, - cleanupStartedChunk, - persistRecordingState, - } = params; - if (recording.stopping) return; - - const chunks = ensureAndroidRecordingChunks(recording); - const nextIndex = chunks.length + 1; - const previousChunk = { - remotePath: recording.remotePath, - remotePid: recording.remotePid, - startedAt: recording.remoteStartedAt ?? recording.startedAt, - }; - const started = await startNextAndroidRecordingChunkWithFallback({ - recording, - nextIndex, - previousChunk, - startNextChunk, - finishCurrentChunk, - }); - if (!started) return; - const { nextChunk, previousChunkFinished } = started; - const previousState = applyNextAndroidRecordingChunk({ - recording, - nextChunk, - }); - await commitNextAndroidRecordingChunk({ - recording, - chunks, - nextChunk, - nextIndex, - previousState, - finishCurrentChunk, - cleanupStartedChunk, - persistRecordingState, - }); - if (previousChunkFinished) { - return; - } - await finishAndroidRecordingChunkOrThrow(finishCurrentChunk, previousChunk); -} - -async function startNextAndroidRecordingChunkWithFallback(params: { - recording: AndroidRecording; - nextIndex: number; - previousChunk: AndroidScreenrecordChunk; - startNextChunk: ( - preferredRemoteDir: string, - nextIndex: number, - ) => Promise; - finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; -}): Promise<{ nextChunk: AndroidScreenrecordChunk; previousChunkFinished: boolean } | undefined> { - const { recording, nextIndex, previousChunk, startNextChunk, finishCurrentChunk } = params; - const preferredRemoteDir = path.posix.dirname(recording.remotePath); - try { - return { - nextChunk: await startNextChunk(preferredRemoteDir, nextIndex), - previousChunkFinished: false, - }; - } catch (concurrentStartError) { - const stopError = await finishCurrentChunk(previousChunk); - if (stopError) { - throw new Error(stopError); - } - if (recording.stopping) return undefined; - try { - return { - nextChunk: await startNextChunk(preferredRemoteDir, nextIndex), - previousChunkFinished: true, - }; - } catch (sequentialStartError) { - throw sequentialStartError instanceof Error ? sequentialStartError : concurrentStartError; - } - } -} - -function applyNextAndroidRecordingChunk(params: { - recording: AndroidRecording; - nextChunk: AndroidScreenrecordChunk; -}): Pick { - const { recording, nextChunk } = params; - const previousState = { - remotePath: recording.remotePath, - remotePid: recording.remotePid, - remoteStartedAt: recording.remoteStartedAt, - }; - recording.remotePath = nextChunk.remotePath; - recording.remotePid = nextChunk.remotePid; - recording.remoteStartedAt = nextChunk.startedAt; - return previousState; -} - -async function commitNextAndroidRecordingChunk(params: { - recording: AndroidRecording; - chunks: NonNullable; - nextChunk: AndroidScreenrecordChunk; - nextIndex: number; - previousState: Pick; - finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; - cleanupStartedChunk?: (chunk: AndroidScreenrecordChunk) => Promise; - persistRecordingState?: (recording: AndroidRecording) => Promise; -}): Promise { - const { - recording, - chunks, - nextChunk, - nextIndex, - previousState, - finishCurrentChunk, - cleanupStartedChunk, - persistRecordingState, - } = params; - chunks.push({ - index: nextIndex, - path: deriveAndroidChunkOutPath(recording.outPath, nextIndex), - remotePath: nextChunk.remotePath, - }); - recording.warning ??= - 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.'; - try { - await persistRecordingState?.(recording); - } catch (error) { - rollbackNextAndroidRecordingChunk({ recording, chunks, previousState }); - const cleanupError = await discardNextAndroidRecordingChunk({ - nextChunk, - finishCurrentChunk, - cleanupStartedChunk, - }); - if (cleanupError) throw cleanupError; - throw error; - } -} - -function rollbackNextAndroidRecordingChunk(params: { - recording: AndroidRecording; - chunks: NonNullable; - previousState: Pick; -}): void { - const { recording, chunks, previousState } = params; - chunks.pop(); - recording.remotePath = previousState.remotePath; - recording.remotePid = previousState.remotePid; - recording.remoteStartedAt = previousState.remoteStartedAt; -} - -async function finishAndroidRecordingChunkOrThrow( - finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise, - chunk: AndroidScreenrecordChunk, -): Promise { - const stopError = await finishCurrentChunk(chunk); - if (stopError) { - throw new Error(stopError); - } -} - -async function discardNextAndroidRecordingChunk(params: { - nextChunk: AndroidScreenrecordChunk; - finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; - cleanupStartedChunk?: (chunk: AndroidScreenrecordChunk) => Promise; -}): Promise { - const { nextChunk, finishCurrentChunk, cleanupStartedChunk } = params; - let discardError: unknown; - try { - await finishAndroidRecordingChunkOrThrow(finishCurrentChunk, nextChunk); - } catch (error) { - discardError = error; - } - try { - await cleanupStartedChunk?.(nextChunk); - } catch (error) { - discardError ??= error; - } - return discardError; -} - -export async function finalizeAndroidRecordingOutput(params: { - recording: AndroidRecording; - deps: RecordTraceDeps; -}): Promise { - const { recording, deps } = params; - const chunks = ensureAndroidRecordingChunks(recording); - if (chunks.length <= 1) { - await finalizeRecordingOverlay({ - recording, - deps, - targetLabel: 'Android recording', - }); - return; - } - - persistRecordingTelemetry({ recording }); - if (recording.showTouches && recording.gestureEvents.length > 0) { - recording.overlayWarning ??= - 'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry'; - } -} diff --git a/src/daemon/handlers/record-trace-android-copy.ts b/src/daemon/handlers/record-trace-android-copy.ts deleted file mode 100644 index d7e124f82b..0000000000 --- a/src/daemon/handlers/record-trace-android-copy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import fs from 'node:fs'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { sleep } from '../../utils/timeouts.ts'; -import { androidDeviceForSerial } from '../../platforms/android/adb.ts'; -import { pullAndroidAdbFile } from '../../platforms/android/adb-executor.ts'; -import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; -import type { SessionState } from '../types.ts'; -import type { RecordTraceDeps } from './record-trace-types.ts'; - -// After `kill -2`, screenrecord needs 1-3s under load to finalize the MP4, and it does so by -// patching a front-reserved moov in place — the remote file size never changes, so the only way -// to observe finalization is to re-pull and validate. The escalating delays must outlast that -// finalization window with margin. -const ANDROID_LOCAL_VIDEO_RETRY_DELAYS_MS = [750, 1_500, 3_000]; - -type AndroidRecording = Extract, { platform: 'android' }>; - -export async function copyAndroidRecordingChunksWithValidation(params: { - deps: RecordTraceDeps; - deviceId: string; - chunks: NonNullable; -}): Promise { - for (const chunk of params.chunks) { - const copyError = await copyAndroidRecordingWithValidation({ - deps: params.deps, - deviceId: params.deviceId, - remotePath: chunk.remotePath, - outPath: chunk.path, - }); - if (copyError) { - return `failed to copy recording chunk ${chunk.index}: ${copyError}`; - } - } - return undefined; -} - -async function copyAndroidRecordingWithValidation(params: { - deps: RecordTraceDeps; - deviceId: string; - remotePath: string; - outPath: string; -}): Promise { - const { deps, deviceId, remotePath, outPath } = params; - let lastCopyError: string | undefined; - - for (let attempt = 0; attempt <= ANDROID_LOCAL_VIDEO_RETRY_DELAYS_MS.length; attempt += 1) { - const retryDelayMs = ANDROID_LOCAL_VIDEO_RETRY_DELAYS_MS[attempt - 1]; - if (retryDelayMs !== undefined) { - await sleep(retryDelayMs); - } - removeLocalRecordingCandidate(outPath); - - const device = androidDeviceForSerial(deviceId); - const pullResult = await pullAndroidAdbFile(remotePath, outPath, { - allowFailure: true, - device, - }); - if (pullResult.exitCode !== 0) { - lastCopyError = formatRecordTraceExecFailure(pullResult, 'adb pull'); - continue; - } - - const playable = await deps.isPlayableVideo(outPath); - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_pull_validation', - data: { - deviceId, - remotePath, - outPath, - attempt: attempt + 1, - fileSize: readFileSize(outPath), - playable, - }, - }); - if (playable) { - return undefined; - } - - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_android_invalid_video_retry', - data: { - deviceId, - remotePath, - outPath, - attempt: attempt + 1, - }, - }); - } - - if (lastCopyError) { - return `failed to copy recording from device: ${lastCopyError}`; - } - removeLocalRecordingCandidate(outPath); - return 'failed to copy recording from device: pulled file is not a playable MP4'; -} - -function readFileSize(filePath: string): number { - try { - return fs.statSync(filePath).size; - } catch { - return 0; - } -} - -function removeLocalRecordingCandidate(filePath: string): void { - try { - fs.rmSync(filePath, { force: true }); - } catch { - // Ignore local cleanup issues and let the caller report the validation failure. - } -} diff --git a/src/daemon/handlers/record-trace-android-liveness.ts b/src/daemon/handlers/record-trace-android-liveness.ts deleted file mode 100644 index d201dc4e5c..0000000000 --- a/src/daemon/handlers/record-trace-android-liveness.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts'; -import type { - AndroidAdbExecutorOptions, - AndroidAdbExecutorResult, -} from '../../platforms/android/adb-executor.ts'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { - parseRecoverableAndroidScreenrecord, - type AndroidRecordingRecoveryMetadata, -} from './record-trace-android-recovery-manifest.ts'; - -const ANDROID_LIVENESS_PROBE_TIMEOUT_MS = 5_000; -const ANDROID_LIVENESS_STAT_MIN_SIZE_BYTES = 1; - -type AndroidScreenrecordLiveness = 'live' | 'stale' | 'uncertain' | 'finished'; -export type AndroidScreenrecordProbe = AndroidRecordingRecoveryMetadata | 'uncertain' | undefined; - -async function runAndroidLivenessAdb( - deviceId: string, - args: string[], - options?: AndroidAdbExecutorOptions, -): Promise { - return await runAndroidAdb(androidDeviceForSerial(deviceId), args, options); -} - -export async function checkRecoverableAndroidScreenrecord( - deviceId: string, - metadata: AndroidRecordingRecoveryMetadata, -): Promise { - const result = await runAndroidLivenessAdb( - deviceId, - ['shell', 'ps', '-o', 'pid=,args=', '-p', metadata.remotePid], - { - allowFailure: true, - timeoutMs: ANDROID_LIVENESS_PROBE_TIMEOUT_MS, - }, - ); - if (result.exitCode !== 0) { - // toybox `ps -p ` exits non-zero with no output at all — the normal signature - // of an exited process, not an adb failure (transport failures leave stderr and exec-layer - // timeouts throw before this branch). Corroborate with the full process list so a healthy - // device recovers the finished recording while a broken transport stays uncertain. - if (result.stdout.trim().length === 0 && result.stderr.trim().length === 0) { - return await resolveExitedAndroidScreenrecord(deviceId, metadata); - } - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_recovery_metadata_probe_uncertain', - data: { - deviceId, - remotePid: metadata.remotePid, - remotePath: metadata.remotePath, - exitCode: result.exitCode, - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - }, - }); - return 'uncertain'; - } - const lines = result.stdout.split(/\r?\n/); - const pidLine = lines - .map((line) => line.trim()) - .find((line) => line.startsWith(metadata.remotePid)); - const matched = lines - .map(parseRecoverableAndroidScreenrecord) - .some( - (candidate) => - candidate?.remotePid === metadata.remotePid && candidate.remotePath === metadata.remotePath, - ); - if (matched) { - return 'live'; - } - if (pidLine?.includes('screenrecord')) return 'uncertain'; - if (pidLine) return 'stale'; - return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; -} - -async function resolveExitedAndroidScreenrecord( - deviceId: string, - metadata: AndroidRecordingRecoveryMetadata, -): Promise { - const listed = await findLiveAndroidScreenrecordByPath(deviceId, metadata.remotePath); - if (listed === 'uncertain') { - return 'uncertain'; - } - if (listed) { - return listed.remotePid === metadata.remotePid ? 'live' : 'uncertain'; - } - return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; -} - -export async function findLiveAndroidScreenrecordByPath( - deviceId: string, - remotePath: string, -): Promise { - const result = await runAndroidLivenessAdb(deviceId, ['shell', 'ps', '-A', '-o', 'pid=,args='], { - allowFailure: true, - timeoutMs: ANDROID_LIVENESS_PROBE_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_recovery_ps_failed', - data: { - deviceId, - remotePath, - exitCode: result.exitCode, - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - }, - }); - return 'uncertain'; - } - - return result.stdout - .split(/\r?\n/) - .map(parseRecoverableAndroidScreenrecord) - .find((match): match is NonNullable => match?.remotePath === remotePath); -} - -export async function androidRemoteFileExists( - deviceId: string, - remotePath: string, -): Promise { - const result = await runAndroidLivenessAdb(deviceId, ['shell', 'stat', '-c', '%s', remotePath], { - allowFailure: true, - timeoutMs: ANDROID_LIVENESS_PROBE_TIMEOUT_MS, - }); - const size = result.exitCode === 0 ? Number(result.stdout.trim()) : NaN; - return Number.isFinite(size) && size >= ANDROID_LIVENESS_STAT_MIN_SIZE_BYTES; -} diff --git a/src/daemon/handlers/record-trace-android-recovery-manifest.ts b/src/daemon/handlers/record-trace-android-recovery-manifest.ts deleted file mode 100644 index 7e0de268ae..0000000000 --- a/src/daemon/handlers/record-trace-android-recovery-manifest.ts +++ /dev/null @@ -1,310 +0,0 @@ -import path from 'node:path'; -import type { RecordingChunk, SessionState } from '../types.ts'; - -const ANDROID_RECOVERY_METADATA_FILE = 'agent-device-recording-active.json'; -const ANDROID_RECOVERY_METADATA_DIRS = ['/sdcard', '/data/local/tmp'] as const; -const ANDROID_RECOVERY_MANIFEST_VERSION = 1; - -type AndroidRecording = Extract, { platform: 'android' }>; - -export type AndroidRecordingRecoveryMetadata = { - remotePath: string; - remotePid: string; - startedAt: number; -}; - -type AndroidRecordingRecoveryPending = { - remotePath: string; -}; - -export type AndroidRecordingRecoveryManifest = { - version: 1; - sessionName: string; - sessionScope?: SessionState['sessionScope']; - recordingId: string; - deviceId: string; - startedAt: number; - showTouches: boolean; - current?: AndroidRecordingRecoveryMetadata; - pending?: AndroidRecordingRecoveryPending; - chunks: AndroidRecordingRecoveryChunk[]; -}; - -export type AndroidRecordingRecoveryChunk = Pick; - -type AndroidRecordingRecoveryManifestRequired = Pick< - AndroidRecordingRecoveryManifest, - 'version' | 'sessionName' | 'recordingId' | 'deviceId' | 'startedAt' | 'showTouches' ->; - -export function parseRecoverableAndroidScreenrecord( - line: string, -): AndroidRecordingRecoveryMetadata | undefined { - const match = line - .trim() - .match( - /^(\d+)\s+.*\bscreenrecord\b.*(\/(?:sdcard|data\/local\/tmp)\/agent-device-recording-(\d+)\.mp4)(?:\s|$)/, - ); - if (!match) { - return undefined; - } - const [, remotePid, remotePath, timestamp] = match; - if (!remotePid || !remotePath) { - return undefined; - } - const startedAt = Number(timestamp); - return { - remotePid, - remotePath, - startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(), - }; -} - -export function parseAndroidRecoveryManifest( - value: string, -): - | { kind: 'manifest'; manifest: AndroidRecordingRecoveryManifest } - | { kind: 'delete' } - | { kind: 'blocked'; reason: string } { - const metadata = parseJsonObject(value); - if (!metadata) return { kind: 'delete' }; - const required = readAndroidRecoveryManifestRequired(metadata); - if (!required) return { kind: 'blocked', reason: 'unsupported_or_malformed_manifest' }; - const parsedCurrent = parseAndroidRecoveryMetadata(metadata.current); - const parsedPending = parseAndroidRecoveryPending(metadata.pending); - const chunks = parseAndroidRecordingChunks(metadata.chunks); - if (!chunks) return { kind: 'blocked', reason: 'invalid_recording_chunks' }; - if (!parsedCurrent && !parsedPending) { - return { kind: 'blocked', reason: 'invalid_recording_state' }; - } - return { - kind: 'manifest', - manifest: { - ...required, - sessionScope: parseSessionScope(metadata.sessionScope), - current: parsedCurrent, - pending: parsedPending, - chunks, - }, - }; -} - -export function androidRecoveryMetadataPathForRemotePath(remotePath: string): string { - return `${path.posix.dirname(remotePath)}/${ANDROID_RECOVERY_METADATA_FILE}`; -} - -export function androidRecoveryMetadataPaths(): string[] { - return ANDROID_RECOVERY_METADATA_DIRS.map((dir) => `${dir}/${ANDROID_RECOVERY_METADATA_FILE}`); -} - -export function buildAndroidRecoveryPendingManifest(params: { - deviceId: string; - sessionName: string; - sessionScope?: SessionState['sessionScope']; - recordingId: string; - startedAt: number; - showTouches: boolean; - remotePath: string; -}): AndroidRecordingRecoveryManifest { - const { deviceId, sessionName, sessionScope, recordingId, startedAt, showTouches, remotePath } = - params; - return { - version: ANDROID_RECOVERY_MANIFEST_VERSION, - sessionName, - sessionScope, - recordingId, - deviceId, - startedAt, - showTouches, - pending: { remotePath }, - chunks: [{ index: 1, remotePath }], - }; -} - -export function buildAndroidRecoveryManifest(params: { - deviceId: string; - sessionName: string; - sessionScope?: SessionState['sessionScope']; - recording: AndroidRecording; -}): AndroidRecordingRecoveryManifest { - const { deviceId, sessionName, sessionScope, recording } = params; - return { - version: ANDROID_RECOVERY_MANIFEST_VERSION, - sessionName, - sessionScope, - recordingId: - recording.recordingId ?? - `android-${recording.remotePid}-${recording.remoteStartedAt ?? recording.startedAt}`, - deviceId, - startedAt: recording.startedAt, - showTouches: recording.showTouches, - current: { - remotePath: recording.remotePath, - remotePid: recording.remotePid, - startedAt: recording.remoteStartedAt ?? recording.startedAt, - }, - chunks: toManifestChunks(recording), - }; -} - -export function buildAndroidRecoveryRotatingManifest(params: { - deviceId: string; - sessionName: string; - sessionScope?: SessionState['sessionScope']; - recording: AndroidRecording; - nextRemotePath: string; - nextIndex: number; -}): AndroidRecordingRecoveryManifest { - const { deviceId, sessionName, sessionScope, recording, nextRemotePath, nextIndex } = params; - return { - ...buildAndroidRecoveryManifest({ deviceId, sessionName, sessionScope, recording }), - pending: { remotePath: nextRemotePath }, - chunks: toManifestChunks(recording, { index: nextIndex, remotePath: nextRemotePath }), - }; -} - -function toManifestChunks( - recording: AndroidRecording, - extraChunk?: AndroidRecordingRecoveryChunk, -): AndroidRecordingRecoveryChunk[] { - return [ - ...(recording.chunks ?? [{ index: 1, remotePath: recording.remotePath }]), - ...(extraChunk ? [extraChunk] : []), - ].map((chunk) => ({ - index: chunk.index, - remotePath: chunk.remotePath, - })); -} - -function parseJsonObject(value: string): Record | undefined { - try { - const parsed: unknown = JSON.parse(value); - return isRecord(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readAndroidRecoveryManifestRequired( - metadata: Record, -): AndroidRecordingRecoveryManifestRequired | undefined { - if (metadata.version !== ANDROID_RECOVERY_MANIFEST_VERSION) return undefined; - const strings = readAndroidRecoveryManifestStrings(metadata); - if (!strings) return undefined; - const startedAt = readOptionalNumber(metadata.startedAt); - if (startedAt === undefined) return undefined; - const showTouches = readOptionalBoolean(metadata.showTouches); - if (showTouches === undefined) return undefined; - return { - version: ANDROID_RECOVERY_MANIFEST_VERSION, - ...strings, - startedAt, - showTouches, - }; -} - -function readAndroidRecoveryManifestStrings( - metadata: Record, -): - | Pick - | undefined { - const sessionName = readOptionalString(metadata.sessionName); - const recordingId = readOptionalString(metadata.recordingId); - const deviceId = readOptionalString(metadata.deviceId); - if (!sessionName || !recordingId || !deviceId) return undefined; - return { sessionName, recordingId, deviceId }; -} - -function parseAndroidRecoveryMetadata( - value: unknown, -): AndroidRecordingRecoveryMetadata | undefined { - if (!value || typeof value !== 'object') { - return undefined; - } - const metadata = value as Partial; - if ( - typeof metadata.remotePid !== 'string' || - !/^\d+$/.test(metadata.remotePid) || - typeof metadata.remotePath !== 'string' || - !isAndroidAgentRecordingPath(metadata.remotePath) - ) { - return undefined; - } - return { - remotePid: metadata.remotePid, - remotePath: metadata.remotePath, - startedAt: - typeof metadata.startedAt === 'number' && Number.isFinite(metadata.startedAt) - ? metadata.startedAt - : Date.now(), - }; -} - -function parseAndroidRecoveryPending(value: unknown): AndroidRecordingRecoveryPending | undefined { - if (!value || typeof value !== 'object') { - return undefined; - } - const metadata = value as Partial; - if ( - typeof metadata.remotePath !== 'string' || - !isAndroidAgentRecordingPath(metadata.remotePath) - ) { - return undefined; - } - return { remotePath: metadata.remotePath }; -} - -function parseAndroidRecordingChunks(value: unknown): AndroidRecordingRecoveryChunk[] | undefined { - if (!Array.isArray(value)) return undefined; - const chunks = value - .map(parseAndroidRecordingChunk) - .filter((chunk): chunk is AndroidRecordingRecoveryChunk => chunk !== undefined); - return chunks.length > 0 && chunks.length === value.length ? chunks : undefined; -} - -function parseAndroidRecordingChunk(value: unknown): AndroidRecordingRecoveryChunk | undefined { - if (!value || typeof value !== 'object') { - return undefined; - } - const chunk = value as Partial; - if ( - typeof chunk.index !== 'number' || - !Number.isInteger(chunk.index) || - chunk.index < 1 || - typeof chunk.remotePath !== 'string' || - !isAndroidAgentRecordingPath(chunk.remotePath) - ) { - return undefined; - } - return { - index: chunk.index, - remotePath: chunk.remotePath, - }; -} - -function parseSessionScope(value: unknown): SessionState['sessionScope'] | undefined { - if (!value || typeof value !== 'object') return undefined; - const scope = value as Partial>; - if (scope.kind !== 'cwd' || typeof scope.id !== 'string') return undefined; - return { kind: 'cwd', id: scope.id }; -} - -function readOptionalString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function readOptionalNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -function readOptionalBoolean(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined; -} - -function isAndroidAgentRecordingPath(remotePath: string): boolean { - return /^\/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4$/.test(remotePath); -} diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts deleted file mode 100644 index ef1db85eaf..0000000000 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts'; -import type { - AndroidAdbExecutorOptions, - AndroidAdbExecutorResult, -} from '../../platforms/android/adb-executor.ts'; -import { shellQuote } from '../../utils/shell-quote.ts'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import type { DaemonResponse, SessionState } from '../types.ts'; -import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; -import { errorResponse } from './response.ts'; -import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; -import { - androidRemoteFileExists, - checkRecoverableAndroidScreenrecord, - findLiveAndroidScreenrecordByPath, - type AndroidScreenrecordProbe, -} from './record-trace-android-liveness.ts'; -import { - androidRecoveryMetadataPathForRemotePath, - androidRecoveryMetadataPaths, - buildAndroidRecoveryManifest, - buildAndroidRecoveryPendingManifest, - buildAndroidRecoveryRotatingManifest, - parseAndroidRecoveryManifest, - type AndroidRecordingRecoveryChunk, - type AndroidRecordingRecoveryManifest, - type AndroidRecordingRecoveryMetadata, -} from './record-trace-android-recovery-manifest.ts'; - -const ANDROID_RECOVERY_WARNING = - 'Recovered Android recording after daemon restart from durable device manifest.'; -const ANDROID_RECOVERY_OVERLAY_WARNING = - 'touch overlay burn-in is unavailable after daemon restart because gesture telemetry is stored in daemon memory'; -const ANDROID_RECOVERY_FINISHED_WARNING = - 'Recovered Android recording after daemon restart from durable device manifest; the screenrecord process was no longer running, so the MP4 may be truncated.'; -const ANDROID_RECOVERY_ROTATION_WARNING = - 'Recovered Android recording from an interrupted chunk rotation; returning chunks known to be safely owned by the durable manifest.'; -const ANDROID_RECOVERY_PROBE_TIMEOUT_MS = 5_000; - -type AndroidDevice = SessionState['device']; -type AndroidRecording = Extract, { platform: 'android' }>; -type AndroidRecordingBase = Pick< - AndroidRecording, - | 'outPath' - | 'clientOutPath' - | 'telemetryPath' - | 'startedAt' - | 'exportQuality' - | 'showTouches' - | 'gestureEvents' ->; - -type AndroidRecordingRecoveryCandidate = Omit< - AndroidRecordingRecoveryManifest, - 'current' | 'chunks' -> & { - current: AndroidRecordingRecoveryMetadata; - chunks: AndroidRecordingRecoveryChunk[]; - recoveryWarning?: string; -}; -type AndroidRecoveryResolution = - | { kind: 'live'; manifest: AndroidRecordingRecoveryCandidate } - | { kind: 'stale' } - | { kind: 'uncertain' }; -type AndroidRecoveryManifestScan = { - live: AndroidRecordingRecoveryCandidate[]; - uncertain: AndroidRecordingRecoveryManifest[]; - blocked: AndroidRecoveryBlockedManifest[]; -}; - -type AndroidRecoveryBlockedManifest = { - metadataPath: string; - reason: string; -}; - -type AndroidActiveRecordingSummary = { - sessionName: string; - sessionScope?: SessionState['sessionScope']; - recordingId: string; - remotePid?: string; - remotePath?: string; -}; - -type AndroidOwnedManifestSelection = - | { - kind: 'selected'; - manifest: T; - activeRecordings: AndroidActiveRecordingSummary[]; - } - | { kind: 'owner-mismatch'; activeRecordings: AndroidActiveRecordingSummary[] } - | { kind: 'ambiguous'; activeRecordings: AndroidActiveRecordingSummary[] }; - -async function runAndroidRecoveryAdb( - deviceId: string, - args: string[], - options?: AndroidAdbExecutorOptions, -): Promise { - return await runAndroidAdb(androidDeviceForSerial(deviceId), args, options); -} - -async function readAndroidRecoveryMetadata(deviceId: string): Promise { - const scan: AndroidRecoveryManifestScan = { live: [], uncertain: [], blocked: [] }; - for (const metadataPath of androidRecoveryMetadataPaths()) { - const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'cat', metadataPath], { - allowFailure: true, - timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - continue; - } - const parsed = parseAndroidRecoveryManifest(result.stdout); - if (parsed.kind === 'delete') { - await cleanupAndroidRecoveryMetadataPath({ - deviceId, - metadataPath, - phase: 'record_stop_android_recovery_metadata_invalid_cleanup_failed', - }); - continue; - } - if (parsed.kind === 'blocked') { - scan.blocked.push({ metadataPath, reason: parsed.reason }); - continue; - } - const metadata = parsed.manifest; - if (metadata.deviceId !== deviceId) { - scan.blocked.push({ metadataPath, reason: 'device_mismatch' }); - continue; - } - const recovery = await resolveAndroidRecoveryCandidate(deviceId, metadata); - if (recovery.kind === 'live') { - scan.live.push(recovery.manifest); - continue; - } - if (recovery.kind === 'uncertain') { - scan.uncertain.push(metadata); - continue; - } - await cleanupAndroidRecoveryMetadataPath({ - deviceId, - metadataPath, - phase: 'record_stop_android_recovery_metadata_stale_cleanup_failed', - }); - } - return scan; -} - -async function resolveAndroidRecoveryCandidate( - deviceId: string, - manifest: AndroidRecordingRecoveryManifest, -): Promise { - if (manifest.pending) { - return await resolvePendingAndroidRecoveryCandidate(deviceId, manifest, manifest.pending); - } - return await resolveCurrentAndroidRecoveryCandidate(deviceId, manifest); -} - -async function resolvePendingAndroidRecoveryCandidate( - deviceId: string, - manifest: AndroidRecordingRecoveryManifest, - pendingMetadata: { remotePath: string }, -): Promise { - const pending = await findLiveAndroidScreenrecordByPath(deviceId, pendingMetadata.remotePath); - const adoptedPending = resolveLivePendingScreenrecord(manifest, pending); - if (adoptedPending) return adoptedPending; - if (!manifest.current) { - return await resolvePendingOnlyAndroidRecoveryCandidate( - deviceId, - manifest, - pendingMetadata.remotePath, - pending, - ); - } - return await resolveInterruptedRotationCurrent(deviceId, manifest, manifest.current, pending); -} - -async function resolveInterruptedRotationCurrent( - deviceId: string, - manifest: AndroidRecordingRecoveryManifest, - current: AndroidRecordingRecoveryMetadata, - pending: AndroidScreenrecordProbe, -): Promise { - const liveness = await checkRecoverableAndroidScreenrecord(deviceId, current); - if (liveness === 'uncertain' || pending === 'uncertain') return { kind: 'uncertain' }; - if (liveness === 'stale') return { kind: 'stale' }; - return liveAndroidRecoveryCandidate({ - manifest, - current, - chunks: chunksThroughRemotePath(manifest.chunks, current.remotePath), - recoveryWarning: - liveness === 'finished' - ? `${ANDROID_RECOVERY_ROTATION_WARNING} ${ANDROID_RECOVERY_FINISHED_WARNING}` - : ANDROID_RECOVERY_ROTATION_WARNING, - }); -} - -function resolveLivePendingScreenrecord( - manifest: AndroidRecordingRecoveryManifest, - pending: AndroidScreenrecordProbe, -): AndroidRecoveryResolution | undefined { - if (!pending || pending === 'uncertain') return undefined; - return liveAndroidRecoveryCandidate({ - manifest, - current: pending, - recoveryWarning: manifest.current - ? ANDROID_RECOVERY_ROTATION_WARNING - : ANDROID_RECOVERY_WARNING, - }); -} - -async function resolvePendingOnlyAndroidRecoveryCandidate( - deviceId: string, - manifest: AndroidRecordingRecoveryManifest, - pendingRemotePath: string, - pending: AndroidScreenrecordProbe, -): Promise { - if (pending === 'uncertain') { - return { kind: 'uncertain' }; - } - // The pending screenrecord process is gone. If it already produced an on-device file, - // recover it as a finished recording rather than discarding a completed capture — the - // same treatment resolveCurrentAndroidRecoveryCandidate gives a finished `current`. - if (await androidRemoteFileExists(deviceId, pendingRemotePath)) { - return liveAndroidRecoveryCandidate({ - manifest, - current: { - remotePath: pendingRemotePath, - // A pending chunk never recorded a pid — the manifest is written before the - // screenrecord process starts. The process is confirmed gone, so there is - // nothing to signal; the empty pid tells finishCurrentAndroidRecordingChunk to - // skip the stop signal. - remotePid: '', - startedAt: manifest.startedAt, - }, - recoveryWarning: ANDROID_RECOVERY_FINISHED_WARNING, - }); - } - return { kind: 'stale' }; -} - -async function resolveCurrentAndroidRecoveryCandidate( - deviceId: string, - manifest: AndroidRecordingRecoveryManifest, -): Promise { - if (!manifest.current) return { kind: 'stale' }; - const liveness = await checkRecoverableAndroidScreenrecord(deviceId, manifest.current); - if (liveness === 'live') { - return liveAndroidRecoveryCandidate({ manifest, current: manifest.current }); - } - if (liveness === 'finished') { - return liveAndroidRecoveryCandidate({ - manifest, - current: manifest.current, - recoveryWarning: ANDROID_RECOVERY_FINISHED_WARNING, - }); - } - return { kind: liveness }; -} - -function liveAndroidRecoveryCandidate(params: { - manifest: AndroidRecordingRecoveryManifest; - current: AndroidRecordingRecoveryMetadata; - chunks?: AndroidRecordingRecoveryChunk[]; - recoveryWarning?: string; -}): { kind: 'live'; manifest: AndroidRecordingRecoveryCandidate } { - const { manifest, current, chunks, recoveryWarning } = params; - return { - kind: 'live', - manifest: { - ...manifest, - current, - chunks: chunks ?? manifest.chunks, - ...(recoveryWarning ? { recoveryWarning } : {}), - }, - }; -} - -function chunksThroughRemotePath( - chunks: AndroidRecordingRecoveryChunk[], - remotePath: string, -): AndroidRecordingRecoveryChunk[] { - const index = chunks.findIndex((chunk) => chunk.remotePath === remotePath); - return index >= 0 ? chunks.slice(0, index + 1) : chunks; -} - -export async function writeAndroidRecoveryPendingMetadata(params: { - deviceId: string; - sessionName: string; - sessionScope?: SessionState['sessionScope']; - recordingId: string; - startedAt: number; - showTouches: boolean; - remotePath: string; -}): Promise { - const { deviceId, sessionName, sessionScope, recordingId, startedAt, showTouches, remotePath } = - params; - return await writeAndroidRecoveryManifest({ - deviceId, - manifest: buildAndroidRecoveryPendingManifest({ - deviceId, - sessionName, - sessionScope, - recordingId, - startedAt, - showTouches, - remotePath, - }), - phase: 'record_start_android_recovery_metadata_failed', - }); -} - -export async function writeAndroidRecoveryRotatingMetadata(params: { - deviceId: string; - sessionName: string; - sessionScope?: SessionState['sessionScope']; - recording: AndroidRecording; - nextRemotePath: string; - nextIndex: number; -}): Promise { - const { deviceId, sessionName, sessionScope, recording, nextRemotePath, nextIndex } = params; - return await writeAndroidRecoveryManifest({ - deviceId, - manifest: buildAndroidRecoveryRotatingManifest({ - deviceId, - sessionName, - sessionScope, - recording, - nextRemotePath, - nextIndex, - }), - phase: 'record_rotate_android_recovery_metadata_failed', - }); -} - -export async function writeAndroidRecoveryMetadata(params: { - deviceId: string; - sessionName: string; - sessionScope?: SessionState['sessionScope']; - recording: AndroidRecording; -}): Promise { - const { deviceId, sessionName, sessionScope, recording } = params; - return await writeAndroidRecoveryManifest({ - deviceId, - manifest: buildAndroidRecoveryManifest({ deviceId, sessionName, sessionScope, recording }), - phase: 'record_start_android_recovery_metadata_failed', - }); -} - -async function writeAndroidRecoveryManifest(params: { - deviceId: string; - manifest: AndroidRecordingRecoveryManifest; - phase: string; -}): Promise { - const { deviceId, manifest, phase } = params; - const currentPath = manifest.current?.remotePath ?? manifest.pending?.remotePath; - if (!currentPath) return 'failed to write Android recording recovery manifest: missing path'; - const metadataPath = androidRecoveryMetadataPathForRemotePath(currentPath); - const metadataTmpPath = `${metadataPath}.tmp`; - const payload = JSON.stringify(manifest); - const result = await runAndroidRecoveryAdb( - deviceId, - [ - 'shell', - `printf %s ${shellQuote(payload)} > ${shellQuote(metadataTmpPath)} && mv -f ${shellQuote(metadataTmpPath)} ${shellQuote(metadataPath)}`, - ], - { - allowFailure: true, - timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, - }, - ); - if (result.exitCode !== 0) { - emitAndroidRecoveryAdbFailure({ - phase, - deviceId, - metadataPath, - result, - }); - await cleanupAndroidRecoveryMetadataPath({ - deviceId, - metadataPath: metadataTmpPath, - phase: `${phase}_tmp_cleanup_failed`, - }); - return `failed to write Android recording recovery manifest: ${formatRecordTraceExecFailure(result, 'adb shell write recovery manifest')}`; - } - - for (const staleMetadataPath of androidRecoveryMetadataPaths()) { - if (staleMetadataPath !== metadataPath) { - await cleanupAndroidRecoveryMetadataPath({ - deviceId, - metadataPath: staleMetadataPath, - phase: 'record_start_android_recovery_metadata_stale_cleanup_failed', - }); - } - } - return undefined; -} - -export async function cleanupAndroidRecoveryMetadata(deviceId: string): Promise { - for (const metadataPath of androidRecoveryMetadataPaths()) { - await cleanupAndroidRecoveryMetadataPath({ - deviceId, - metadataPath, - phase: 'record_stop_android_recovery_metadata_cleanup_failed', - }); - } -} - -async function cleanupAndroidRecoveryMetadataPath(params: { - deviceId: string; - metadataPath: string; - phase: string; -}): Promise { - const { deviceId, metadataPath, phase } = params; - const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'rm', '-f', metadataPath], { - allowFailure: true, - timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - emitAndroidRecoveryAdbFailure({ - phase, - deviceId, - metadataPath, - result, - }); - } -} - -function emitAndroidRecoveryAdbFailure(params: { - phase: string; - deviceId: string; - metadataPath: string; - result: AndroidAdbExecutorResult; -}): void { - const { phase, deviceId, metadataPath, result } = params; - emitDiagnostic({ - level: 'warn', - phase, - data: { - deviceId, - metadataPath, - exitCode: result.exitCode, - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - }, - }); -} - -export async function recoverMissingAndroidRecording(params: { - sessionName: string; - activeSession: SessionState; - device: AndroidDevice; - recordingBase: AndroidRecordingBase; -}): Promise { - const { sessionName, activeSession, device, recordingBase } = params; - const manifests = await readAndroidRecoveryMetadata(device.id); - if (manifests.live.length > 0) { - return recoverAndroidRecordingFromManifest({ - sessionName, - activeSession, - device, - recordingBase, - manifests: manifests.live, - }); - } - if (manifests.uncertain.length > 0) { - return blockAndroidManifestRecoveryForUncertainManifest({ - sessionName, - activeSession, - manifests: manifests.uncertain, - }); - } - if (manifests.blocked.length > 0) { - return blockAndroidManifestRecoveryForBlockedManifest(manifests.blocked); - } - - return null; -} - -function blockAndroidManifestRecoveryForUncertainManifest(params: { - sessionName: string; - activeSession: SessionState; - manifests: AndroidRecordingRecoveryManifest[]; -}): DaemonResponse { - const { sessionName, activeSession, manifests } = params; - const selection = selectOwnedAndroidRecoveryManifest({ sessionName, activeSession, manifests }); - const details = { - activeRecordings: selection.activeRecordings, - recoveryBlocked: 'manifest_liveness_uncertain', - hint: 'Retry record stop after the device responds. Android recording recovery requires a verified durable manifest.', - }; - if (selection.kind === 'owner-mismatch') { - return errorResponse('INVALID_ARGS', formatAndroidRecordingOwnerMismatch(manifests), details); - } - if (selection.kind === 'ambiguous') { - return errorResponse( - 'INVALID_ARGS', - 'multiple active Android recording manifests could not be verified; cannot safely recover missing recording state', - details, - ); - } - return errorResponse( - 'INVALID_ARGS', - 'active Android recording manifest could not be verified; retry record stop after the device responds', - details, - ); -} - -function blockAndroidManifestRecoveryForBlockedManifest( - manifests: AndroidRecoveryBlockedManifest[], -): DaemonResponse { - return errorResponse('INVALID_ARGS', 'active Android recording manifest could not be validated', { - recoveryBlocked: 'manifest_invalid_or_unsupported', - manifests, - hint: 'Retry with the same agent-device version that started the recording, or inspect and remove stale device recovery metadata after confirming no recording is active.', - }); -} - -function recoverAndroidRecordingFromManifest(params: { - sessionName: string; - activeSession: SessionState; - device: AndroidDevice; - recordingBase: AndroidRecordingBase; - manifests: AndroidRecordingRecoveryCandidate[]; -}): DaemonResponse | AndroidRecording { - const { sessionName, activeSession, device, recordingBase, manifests } = params; - const selected = selectAndroidRecoveryManifest({ sessionName, activeSession, manifests }); - if ('ok' in selected) return selected; - emitAndroidRecoveryDiagnostic(device, selected); - return buildAndroidRecordingFromManifest(selected, recordingBase); -} - -function selectAndroidRecoveryManifest(params: { - sessionName: string; - activeSession: SessionState; - manifests: AndroidRecordingRecoveryCandidate[]; -}): DaemonResponse | AndroidRecordingRecoveryCandidate { - const { sessionName, activeSession, manifests } = params; - const selection = selectOwnedAndroidRecoveryManifest({ sessionName, activeSession, manifests }); - if (selection.kind === 'selected') return selection.manifest; - if (selection.kind === 'owner-mismatch') { - return errorResponse('INVALID_ARGS', formatAndroidRecordingOwnerMismatch(manifests), { - activeRecordings: selection.activeRecordings, - }); - } - return errorResponse( - 'INVALID_ARGS', - 'multiple active Android recording manifests exist; cannot safely recover missing recording state', - { activeRecordings: selection.activeRecordings }, - ); -} - -function selectOwnedAndroidRecoveryManifest(params: { - sessionName: string; - activeSession: SessionState; - manifests: T[]; -}): AndroidOwnedManifestSelection { - const { sessionName, activeSession, manifests } = params; - const matches = manifests.filter((manifest) => - androidRecoveryManifestMatchesSession(manifest, sessionName, activeSession), - ); - const activeRecordings = summarizeAndroidActiveRecordings(manifests); - if (matches.length === 0) { - return { kind: 'owner-mismatch', activeRecordings }; - } - if (matches.length > 1 || manifests.length > 1) { - return { kind: 'ambiguous', activeRecordings }; - } - return { kind: 'selected', manifest: matches[0]!, activeRecordings }; -} - -function summarizeAndroidActiveRecordings( - manifests: AndroidRecordingRecoveryManifest[], -): AndroidActiveRecordingSummary[] { - return manifests.map((manifest) => ({ - sessionName: manifest.sessionName, - sessionScope: manifest.sessionScope, - recordingId: manifest.recordingId, - remotePid: manifest.current?.remotePid, - remotePath: manifest.current?.remotePath ?? manifest.pending?.remotePath, - })); -} - -function emitAndroidRecoveryDiagnostic( - device: AndroidDevice, - manifest: AndroidRecordingRecoveryCandidate, -): void { - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_android_recovered_missing_state', - data: { - deviceId: device.id, - sessionName: manifest.sessionName, - recordingId: manifest.recordingId, - remotePath: manifest.current.remotePath, - remotePid: manifest.current.remotePid, - chunks: manifest.chunks.length, - }, - }); -} - -function buildAndroidRecordingFromManifest( - manifest: AndroidRecordingRecoveryCandidate, - recordingBase: AndroidRecordingBase, -): AndroidRecording { - const recoveryWarning = manifest.recoveryWarning ?? ANDROID_RECOVERY_WARNING; - return { - platform: 'android', - recordingId: manifest.recordingId, - remotePath: manifest.current.remotePath, - remotePid: manifest.current.remotePid, - remoteStartedAt: manifest.current.startedAt, - chunks: manifest.chunks.map((chunk) => ({ - index: chunk.index, - path: deriveAndroidChunkOutPath(recordingBase.outPath, chunk.index), - remotePath: chunk.remotePath, - })), - outPath: recordingBase.outPath, - clientOutPath: recordingBase.clientOutPath, - telemetryPath: recordingBase.telemetryPath, - startedAt: manifest.startedAt, - exportQuality: recordingBase.exportQuality, - showTouches: false, - gestureEvents: [], - warning: manifest.showTouches - ? `${recoveryWarning} ${ANDROID_RECOVERY_OVERLAY_WARNING}.` - : recoveryWarning, - overlayWarning: manifest.showTouches ? ANDROID_RECOVERY_OVERLAY_WARNING : undefined, - }; -} - -function androidRecoveryManifestMatchesSession( - manifest: AndroidRecordingRecoveryManifest, - sessionName: string, - activeSession: SessionState, -): boolean { - return ( - manifest.sessionName === sessionName && - sessionScopesEqual(manifest.sessionScope, activeSession.sessionScope) - ); -} - -function sessionScopesEqual( - left: SessionState['sessionScope'] | undefined, - right: SessionState['sessionScope'] | undefined, -): boolean { - if (!left && !right) return true; - if (!left || !right) return false; - return left.kind === right.kind && left.id === right.id; -} - -function formatAndroidRecordingOwnerMismatch( - manifests: AndroidRecordingRecoveryManifest[], -): string { - if (manifests.length === 1) { - const manifest = manifests[0]!; - if (manifest.sessionScope) { - return `active Android recording belongs to session "${manifest.sessionName}" in ${manifest.sessionScope.kind} scope; retry record stop from the original working directory without --session to recover it`; - } - return `active Android recording belongs to session "${manifest.sessionName}"; run record stop --session ${manifest.sessionName} to recover it`; - } - return 'active Android recordings belong to other sessions; cannot safely recover missing recording state'; -} diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts deleted file mode 100644 index 07f33a7d83..0000000000 --- a/src/daemon/handlers/record-trace-android.ts +++ /dev/null @@ -1,689 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { sleep } from '../../utils/timeouts.ts'; -import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts'; -import type { DaemonResponse, SessionState } from '../types.ts'; -import { buildRecordStopFailure, formatRecordTraceExecFailure } from '../record-trace-errors.ts'; -import type { RecordTraceDeps } from './record-trace-types.ts'; -import { errorResponse } from './response.ts'; -import type { - AndroidAdbExecutorOptions, - AndroidAdbExecutorResult, -} from '../../platforms/android/adb-executor.ts'; -import { - ensureAndroidRecordingChunks, - finalizeAndroidRecordingOutput, - resolveAndroidScreenrecordLimitWarning, - scheduleAndroidRecordingRotation, -} from './record-trace-android-chunks.ts'; -import { copyAndroidRecordingChunksWithValidation } from './record-trace-android-copy.ts'; -import { - DEFAULT_RECORDING_EXPORT_QUALITY, - type RecordingExportQuality, -} from '@agent-device/contracts/recording'; -import { - cleanupAndroidRecoveryMetadata, - writeAndroidRecoveryMetadata, - writeAndroidRecoveryPendingMetadata, - writeAndroidRecoveryRotatingMetadata, -} from './record-trace-android-recovery.ts'; - -const ANDROID_RECORDING_BIT_RATE: Record = { - medium: 8_000_000, - high: 20_000_000, -}; - -const ANDROID_REMOTE_FILE_POLL_MS = 250; -const ANDROID_REMOTE_FILE_ATTEMPTS = 20; -const ANDROID_REMOTE_FILE_STABLE_POLLS = 4; -const ANDROID_PROCESS_EXIT_POLL_MS = 250; -const ANDROID_PROCESS_EXIT_ATTEMPTS = 40; -const ANDROID_RECORDING_READY_ATTEMPTS = 8; -const ANDROID_RECORDING_READY_MIN_RUNNING_POLLS = 2; -const ANDROID_RECORDING_PROBE_TIMEOUT_MS = 5_000; - -type AndroidDevice = SessionState['device']; -type AndroidRecording = Extract, { platform: 'android' }>; -type AndroidRecordingBase = Pick< - AndroidRecording, - | 'outPath' - | 'clientOutPath' - | 'telemetryPath' - | 'startedAt' - | 'exportQuality' - | 'showTouches' - | 'gestureEvents' ->; -type AndroidRecordingChunkStart = { - remotePath: string; - remotePid: string; - startedAt: number; -}; -type AndroidRecordingChunkStartAttempt = - | { kind: 'started'; chunk: AndroidRecordingChunkStart } - | { kind: 'failed'; message: string }; - -type AndroidRecordingChunkStartHooks = { - prepareRemotePath?: (remotePath: string) => Promise; - cleanupPreparedRemotePath?: (remotePath: string) => Promise; -}; - -async function runAndroidRecordingAdb( - deviceId: string, - args: string[], - options?: AndroidAdbExecutorOptions, -): Promise { - return await runAndroidAdb(androidDeviceForSerial(deviceId), args, options); -} - -function parseAndroidRemotePid(stdout: string): string | undefined { - return stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => /^\d+$/.test(line)) - .at(-1); -} - -async function isAndroidProcessRunning(deviceId: string, pid: string): Promise { - const result = await runAndroidRecordingAdb(deviceId, ['shell', 'ps', '-o', 'pid=', '-p', pid], { - allowFailure: true, - timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - return false; - } - return result.stdout - .split(/\s+/) - .map((value) => value.trim()) - .includes(pid); -} - -async function waitForAndroidProcessExit(deviceId: string, pid: string): Promise { - for (let attempt = 0; attempt < ANDROID_PROCESS_EXIT_ATTEMPTS; attempt += 1) { - if (!(await isAndroidProcessRunning(deviceId, pid))) { - return true; - } - await sleep(ANDROID_PROCESS_EXIT_POLL_MS); - } - return !(await isAndroidProcessRunning(deviceId, pid)); -} - -async function waitForAndroidRemoteFileStability( - deviceId: string, - remotePath: string, -): Promise { - let previousSize: string | undefined; - let stableCount = 0; - - for (let attempt = 0; attempt < ANDROID_REMOTE_FILE_ATTEMPTS; attempt += 1) { - const statResult = await runAndroidRecordingAdb( - deviceId, - ['shell', 'stat', '-c', '%s', remotePath], - { allowFailure: true, timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS }, - ); - const currentSize = statResult.exitCode === 0 ? statResult.stdout.trim() : ''; - if (currentSize.length > 0 && currentSize === previousSize) { - stableCount += 1; - if (stableCount >= ANDROID_REMOTE_FILE_STABLE_POLLS) { - return; - } - } else { - stableCount = 0; - } - previousSize = currentSize; - await sleep(ANDROID_REMOTE_FILE_POLL_MS); - } -} - -async function waitForAndroidRecordingReady( - deviceId: string, - remotePath: string, - remotePid: string, -): Promise { - for (let attempt = 0; attempt < ANDROID_RECORDING_READY_ATTEMPTS; attempt += 1) { - const statResult = await runAndroidRecordingAdb( - deviceId, - ['shell', 'stat', '-c', '%s', remotePath], - { allowFailure: true, timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS }, - ); - const currentSize = statResult.exitCode === 0 ? Number(statResult.stdout.trim()) : NaN; - if (Number.isFinite(currentSize) && currentSize > 0) { - return true; - } - - if (!(await isAndroidProcessRunning(deviceId, remotePid))) { - return false; - } - - // Some Android builds keep the output file at zero bytes briefly after screenrecord starts. - // Once the process stays alive for a couple of polls, treat recording as ready and let stop - // validation handle final container/playability checks. - if (attempt + 1 >= ANDROID_RECORDING_READY_MIN_RUNNING_POLLS) { - return true; - } - - await sleep(ANDROID_REMOTE_FILE_POLL_MS); - } - - return false; -} - -function androidRemoteRecordingPaths(timestamp: number, preferredDir?: string): string[] { - const fileName = `agent-device-recording-${timestamp}.mp4`; - const dirs = ['/sdcard', '/data/local/tmp']; - const orderedDirs = - preferredDir && dirs.includes(preferredDir) - ? [preferredDir, ...dirs.filter((dir) => dir !== preferredDir)] - : dirs; - return orderedDirs.map((dir) => `${dir}/${fileName}`); -} - -function buildAndroidScreenrecordCommand( - remotePath: string, - quality: RecordingExportQuality, -): string { - const screenrecordArgs = ['screenrecord']; - screenrecordArgs.push('--bit-rate', String(ANDROID_RECORDING_BIT_RATE[quality])); - screenrecordArgs.push(remotePath); - return `${screenrecordArgs.join(' ')} >/dev/null 2>&1 & echo $!`; -} - -async function cleanupAndroidRemoteRecording(deviceId: string, remotePath: string): Promise { - await runAndroidRecordingAdb(deviceId, ['shell', 'rm', '-f', remotePath], { - allowFailure: true, - timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, - }); -} - -async function forceStopAndroidProcess(deviceId: string, pid: string): Promise { - const forceResult = await runAndroidRecordingAdb(deviceId, ['shell', 'kill', '-9', pid], { - allowFailure: true, - timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, - }); - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_android_force_signal', - data: { - deviceId, - remotePid: pid, - exitCode: forceResult.exitCode, - stdout: forceResult.stdout.trim(), - stderr: forceResult.stderr.trim(), - }, - }); - if (forceResult.exitCode !== 0 && (await isAndroidProcessRunning(deviceId, pid))) { - return false; - } - return await waitForAndroidProcessExit(deviceId, pid); -} - -async function startAndroidScreenrecordChunk(params: { - device: AndroidDevice; - quality: RecordingExportQuality; - preferredRemoteDir?: string; - hooks?: AndroidRecordingChunkStartHooks; -}): Promise { - const { device, quality, preferredRemoteDir, hooks } = params; - let lastStartError = - 'failed to start recording: Android screenrecord did not begin producing frames'; - - for (const remotePath of androidRemoteRecordingPaths(Date.now(), preferredRemoteDir)) { - const attempt = await tryStartAndroidScreenrecordAtPath({ - device, - quality, - remotePath, - hooks, - }); - if (attempt.kind === 'started') { - return attempt.chunk; - } - lastStartError = attempt.message; - } - - return { error: errorResponse('COMMAND_FAILED', lastStartError) }; -} - -async function tryStartAndroidScreenrecordAtPath(params: { - device: AndroidDevice; - quality: RecordingExportQuality; - remotePath: string; - hooks?: AndroidRecordingChunkStartHooks; -}): Promise { - const { device, quality, remotePath, hooks } = params; - const prepareError = await hooks?.prepareRemotePath?.(remotePath); - if (prepareError) { - return { kind: 'failed', message: prepareError }; - } - - const startResult = await runAndroidRecordingAdb( - device.id, - ['shell', buildAndroidScreenrecordCommand(remotePath, quality)], - { - allowFailure: true, - timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, - }, - ); - if (startResult.exitCode !== 0) { - await hooks?.cleanupPreparedRemotePath?.(remotePath); - return { - kind: 'failed', - message: `failed to start recording: ${formatRecordTraceExecFailure(startResult, 'adb shell screenrecord')}`, - }; - } - - const remotePid = parseAndroidRemotePid(startResult.stdout); - if (!remotePid) { - await hooks?.cleanupPreparedRemotePath?.(remotePath); - await cleanupAndroidRemoteRecording(device.id, remotePath); - return { - kind: 'failed', - message: 'failed to start recording: adb did not return a valid Android screenrecord pid', - }; - } - - emitDiagnostic({ - level: 'debug', - phase: 'record_start_android_started', - data: { - deviceId: device.id, - remotePath, - remotePid, - }, - }); - - if (await waitForAndroidRecordingReady(device.id, remotePath, remotePid)) { - return { - kind: 'started', - chunk: { - remotePath, - remotePid, - startedAt: Date.now(), - }, - }; - } - - await forceStopAndroidProcess(device.id, remotePid); - await hooks?.cleanupPreparedRemotePath?.(remotePath); - await cleanupAndroidRemoteRecording(device.id, remotePath); - return { - kind: 'failed', - message: 'failed to start recording: Android screenrecord did not begin producing frames', - }; -} - -export async function startAndroidRecording(params: { - sessionName: string; - activeSession: SessionState; - device: AndroidDevice; - recordingBase: AndroidRecordingBase; -}): Promise { - const { sessionName, activeSession, device, recordingBase } = params; - const quality = recordingBase.exportQuality ?? DEFAULT_RECORDING_EXPORT_QUALITY; - const recordingId = randomUUID(); - const chunk = await startAndroidScreenrecordChunk({ - device, - quality, - hooks: { - prepareRemotePath: async (remotePath) => - await writeAndroidRecoveryPendingMetadata({ - deviceId: device.id, - sessionName, - sessionScope: activeSession.sessionScope, - recordingId, - startedAt: recordingBase.startedAt, - showTouches: recordingBase.showTouches, - remotePath, - }), - cleanupPreparedRemotePath: async () => { - await cleanupAndroidRecoveryMetadata(device.id); - }, - }, - }); - if ('error' in chunk) { - return chunk.error; - } - - const recording = buildAndroidRecording({ recordingBase, chunk, recordingId }); - const metadataError = await writeAndroidRecoveryMetadata({ - deviceId: device.id, - sessionName, - sessionScope: activeSession.sessionScope, - recording, - }); - if (metadataError) { - await forceStopAndroidProcess(device.id, recording.remotePid); - await cleanupAndroidRemoteRecording(device.id, recording.remotePath); - await cleanupAndroidRecoveryMetadata(device.id); - return errorResponse('COMMAND_FAILED', `failed to start recording: ${metadataError}`); - } - scheduleAndroidRecordingChunks({ - activeSession, - sessionName, - device, - recording, - quality, - }); - return recording; -} - -function buildAndroidRecording(params: { - recordingBase: AndroidRecordingBase; - chunk: AndroidRecordingChunkStart; - recordingId: string; -}): AndroidRecording { - const { recordingBase, chunk, recordingId } = params; - return { - platform: 'android', - recordingId, - remotePath: chunk.remotePath, - remotePid: chunk.remotePid, - remoteStartedAt: chunk.startedAt, - chunks: [ - { - index: 1, - path: recordingBase.outPath, - remotePath: chunk.remotePath, - }, - ], - ...recordingBase, - startedAt: chunk.startedAt, - }; -} - -function scheduleAndroidRecordingChunks(params: { - activeSession: SessionState; - sessionName: string; - device: AndroidDevice; - recording: AndroidRecording; - quality: RecordingExportQuality; -}): void { - const { activeSession, sessionName, device, recording, quality } = params; - scheduleAndroidRecordingRotation({ - recording, - finishCurrentChunk: async (chunk) => - await finishCurrentAndroidRecordingChunk({ - device, - recording, - remotePath: chunk.remotePath, - remotePid: chunk.remotePid, - waitForRemoteFileStability: false, - }), - cleanupStartedChunk: async (chunk) => { - await cleanupAndroidRemoteRecording(device.id, chunk.remotePath); - }, - startNextChunk: async (preferredRemoteDir, nextIndex) => { - const nextChunk = await startAndroidScreenrecordChunk({ - device, - quality, - preferredRemoteDir, - hooks: { - prepareRemotePath: async (remotePath) => - await writeAndroidRecoveryRotatingMetadata({ - deviceId: device.id, - sessionName, - sessionScope: activeSession.sessionScope, - recording, - nextRemotePath: remotePath, - nextIndex, - }), - cleanupPreparedRemotePath: async () => { - await writeAndroidRecoveryMetadata({ - deviceId: device.id, - sessionName, - sessionScope: activeSession.sessionScope, - recording, - }); - }, - }, - }); - if ('error' in nextChunk) { - throw new Error( - nextChunk.error.ok - ? 'failed to start next Android recording chunk' - : nextChunk.error.error.message, - ); - } - return nextChunk; - }, - persistRecordingState: async (updatedRecording) => { - const metadataError = await writeAndroidRecoveryMetadata({ - deviceId: device.id, - sessionName, - sessionScope: activeSession.sessionScope, - recording: updatedRecording, - }); - if (metadataError) { - throw new Error(metadataError); - } - }, - }); -} - -async function finishCurrentAndroidRecordingChunk(params: { - device: AndroidDevice; - recording: AndroidRecording; - remotePath?: string; - remotePid?: string; - waitForRemoteFileStability?: boolean; -}): Promise { - const { - device, - recording, - remotePath = recording.remotePath, - remotePid = recording.remotePid, - waitForRemoteFileStability = true, - } = params; - if (!remotePid) { - // A recovered finished recording with no tracked process (a pending chunk whose - // screenrecord already exited): there is nothing to signal, and the on-device file - // is already complete. Skip the kill entirely — probing/signalling an empty pid is - // unsafe (`isAndroidProcessRunning('')` can report a false positive). - appendAndroidRecordingWarning(recording, resolveAndroidScreenrecordLimitWarning(recording)); - if (waitForRemoteFileStability) { - await waitForAndroidRemoteFileStability(device.id, remotePath); - } - return undefined; - } - const wasRunningBeforeStop = await isAndroidProcessRunning(device.id, remotePid); - if (!wasRunningBeforeStop) { - appendAndroidRecordingWarning(recording, resolveAndroidScreenrecordLimitWarning(recording)); - } - - const stopResult = await runAndroidRecordingAdb(device.id, ['shell', 'kill', '-2', remotePid], { - allowFailure: true, - timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, - }); - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_signal', - data: { - deviceId: device.id, - remotePath, - remotePid, - exitCode: stopResult.exitCode, - stdout: stopResult.stdout.trim(), - stderr: stopResult.stderr.trim(), - }, - }); - - if (stopResult.exitCode !== 0) { - return await recoverAndroidStopSignalFailure(device.id, remotePid, stopResult); - } - const exitError = await waitForAndroidStopExit(device.id, remotePid); - if (exitError) { - return exitError; - } - - if (waitForRemoteFileStability) { - await waitForAndroidRemoteFileStability(device.id, remotePath); - } - return undefined; -} - -async function recoverAndroidStopSignalFailure( - deviceId: string, - remotePid: string, - stopResult: AndroidAdbExecutorResult, -): Promise { - if (!(await isAndroidProcessRunning(deviceId, remotePid))) { - return undefined; - } - if (await forceStopAndroidProcess(deviceId, remotePid)) { - return undefined; - } - return `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'adb shell kill')}`; -} - -async function waitForAndroidStopExit( - deviceId: string, - remotePid: string, -): Promise { - if (await waitForAndroidProcessExit(deviceId, remotePid)) { - return undefined; - } - if (await forceStopAndroidProcess(deviceId, remotePid)) { - return undefined; - } - return `failed to stop recording: Android screenrecord pid ${remotePid} did not exit`; -} - -export async function stopAndroidRecording(params: { - deps: RecordTraceDeps; - device: AndroidDevice; - recording: AndroidRecording; - stopRequestedAt: number; -}): Promise { - const { deps, device, recording, stopRequestedAt } = params; - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_enter', - data: { - deviceId: device.id, - remotePath: recording.remotePath, - remotePid: recording.remotePid, - }, - }); - recording.stopping = true; - await finishPendingAndroidRecordingRotation(recording); - const stopError = await finishCurrentAndroidRecordingChunk({ device, recording }); - if (recording.rotationFailedReason && !stopError) { - recording.warning ??= `Android recording chunk rotation failed: ${recording.rotationFailedReason}`; - } - - const copyError = - stopError === undefined - ? await copyAndFinalizeAndroidRecording({ deps, device, recording }) - : undefined; - const cleanupError = await cleanupRemoteAndroidRecordingChunks({ - deviceId: device.id, - recording, - recordCleanupError: stopError === undefined, - }); - - if (copyError) { - return errorResponse( - 'COMMAND_FAILED', - formatAndroidStopFailure(copyError, recording, stopRequestedAt), - ); - } - - if (stopError) { - return errorResponse( - 'COMMAND_FAILED', - formatAndroidStopFailure(stopError, recording, stopRequestedAt), - ); - } - - if (cleanupError) { - return errorResponse('COMMAND_FAILED', cleanupError); - } - - return null; -} - -async function finishPendingAndroidRecordingRotation(recording: AndroidRecording): Promise { - if (recording.rotationTimer) { - clearTimeout(recording.rotationTimer); - recording.rotationTimer = undefined; - } - await recording.rotationPromise; -} - -async function copyAndFinalizeAndroidRecording(params: { - deps: RecordTraceDeps; - device: AndroidDevice; - recording: AndroidRecording; -}): Promise { - const { deps, device, recording } = params; - const copyError = await copyAndroidRecordingChunksWithValidation({ - deps, - deviceId: device.id, - chunks: ensureAndroidRecordingChunks(recording), - }); - if (copyError) { - return copyError; - } - - await finalizeAndroidRecordingOutput({ recording, deps }); - return undefined; -} - -async function cleanupRemoteAndroidRecordingChunks(params: { - deviceId: string; - recording: AndroidRecording; - recordCleanupError: boolean; -}): Promise { - const { deviceId, recording, recordCleanupError } = params; - let cleanupError: string | undefined; - for (const chunk of ensureAndroidRecordingChunks(recording)) { - const chunkCleanupError = await cleanupRemoteAndroidRecordingChunk(deviceId, chunk.remotePath); - if (chunkCleanupError && recordCleanupError) { - cleanupError = chunkCleanupError; - } - } - await cleanupAndroidRecoveryMetadata(deviceId); - return cleanupError; -} - -async function cleanupRemoteAndroidRecordingChunk( - deviceId: string, - remotePath: string, -): Promise { - const rmResult = await runAndroidRecordingAdb(deviceId, ['shell', 'rm', '-f', remotePath], { - allowFailure: true, - timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, - }); - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_cleanup', - data: { - deviceId, - remotePath, - exitCode: rmResult.exitCode, - stdout: rmResult.stdout.trim(), - stderr: rmResult.stderr.trim(), - }, - }); - if (rmResult.exitCode !== 0) { - return `failed to clean up remote recording: ${formatRecordTraceExecFailure(rmResult, 'adb shell rm')}`; - } - return undefined; -} - -function formatAndroidStopFailure( - error: string, - recording: AndroidRecording, - stopRequestedAt: number, -): string { - return buildRecordStopFailure(error, recording, stopRequestedAt).message; -} - -function appendAndroidRecordingWarning( - recording: AndroidRecording, - warning: string | undefined, -): void { - if (!warning || recording.warning?.includes(warning)) { - return; - } - recording.warning = recording.warning ? `${recording.warning} ${warning}` : warning; -} diff --git a/src/daemon/handlers/record-trace-finalize.ts b/src/daemon/handlers/record-trace-finalize.ts deleted file mode 100644 index b8a95fc896..0000000000 --- a/src/daemon/handlers/record-trace-finalize.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { persistRecordingTelemetry } from '../recording-telemetry.ts'; -import { getRecordingOverlaySupportWarning } from '../../recording/overlay.ts'; -import { formatRecordTraceError } from '../record-trace-errors.ts'; -import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; -import type { RecordTraceDeps } from './record-trace-types.ts'; - -type FinalizeRecordingOverlayParams = { - recording: { - outPath: string; - gestureEvents: import('../types.ts').RecordingGestureEvent[]; - telemetryPath?: string; - showTouches: boolean; - exportQuality?: import('@agent-device/contracts/recording').RecordingExportQuality; - overlayWarning?: string; - }; - deps: Pick; - trimStartMs?: number; - targetLabel: string; -}; - -export async function finalizeRecordingOverlay( - params: FinalizeRecordingOverlayParams, -): Promise { - const { recording, deps, trimStartMs, targetLabel } = params; - - const telemetryPath = persistRecordingTelemetry({ - recording, - trimStartMs, - }); - - if (!recording.showTouches) { - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_overlay_skipped', - data: { reason: 'hide_touches' }, - }); - return; - } - - if (recording.gestureEvents.length === 0) { - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_overlay_skipped', - data: { reason: 'no_gesture_events' }, - }); - return; - } - - const overlaySupportWarning = getRecordingOverlaySupportWarning(); - if (overlaySupportWarning) { - recording.overlayWarning ??= overlaySupportWarning; - return; - } - - try { - await withDiagnosticTimer( - 'record_stop_overlay_export', - () => - deps.overlayRecordingTouches({ - videoPath: recording.outPath, - telemetryPath, - exportQuality: recording.exportQuality, - targetLabel, - }), - { - targetLabel, - gestureEventCount: recording.gestureEvents.length, - }, - ); - } catch (error) { - recording.overlayWarning ??= `failed to overlay recording touches: ${formatRecordTraceError(error)}`; - } -} diff --git a/src/daemon/handlers/record-trace-harmony.ts b/src/daemon/handlers/record-trace-harmony.ts deleted file mode 100644 index 404e3e73b1..0000000000 --- a/src/daemon/handlers/record-trace-harmony.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { AppError } from '@agent-device/kernel/errors'; -import { runHarmonyHdc } from '../../platforms/harmonyos/hdc.ts'; -import { sleep } from '../../utils/timeouts.ts'; -import type { SessionState } from '../types.ts'; -import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; -import { errorResponse } from './response.ts'; - -const HARMONY_SCREEN_RECORDER_BUNDLE = 'com.huawei.hmos.screenrecorder'; -const HARMONY_SCREEN_RECORDER_ABILITY = 'com.huawei.hmos.screenrecorder.ServiceExtAbility'; - -type HarmonyRecording = Extract, { platform: 'harmonyos' }>; - -export function parseHarmonyMediaUri(output: string): string | undefined { - return output.match(/file:\/\/[^\s"']+/)?.[0]; -} - -export function parseHarmonyFileSize(output: string): number | undefined { - const fields = output.trim().split(/\s+/); - const size = Number(fields.at(-4)); - return Number.isSafeInteger(size) && size > 0 ? size : undefined; -} - -export async function startHarmonyRecording(params: { - device: SessionState['device']; - recordingBase: RecordingBase; -}): Promise> { - const { device, recordingBase } = params; - const fileName = `agent-device-recording-${randomUUID()}.mp4`; - const start = await runHarmonyHdc(device, [ - 'shell', - 'aa', - 'start', - '-b', - HARMONY_SCREEN_RECORDER_BUNDLE, - '-a', - HARMONY_SCREEN_RECORDER_ABILITY, - '--ps', - 'CustomizedFileName', - fileName, - ]); - if (!start.stdout.includes('start ability successfully')) { - return errorResponse('COMMAND_FAILED', 'failed to start HarmonyOS screen recording', { - output: start.stdout.trim(), - }); - } - return { - ...recordingBase, - platform: 'harmonyos', - fileName, - remotePath: `/data/local/tmp/${fileName}`, - }; -} - -export async function stopHarmonyRecording(params: { - deps: Pick; - device: SessionState['device']; - recording: HarmonyRecording; -}): Promise | null> { - const { deps, device, recording } = params; - let mediaUri: string | undefined; - let copied = false; - try { - const stop = await runHarmonyHdc(device, [ - 'shell', - 'aa', - 'start', - '-b', - HARMONY_SCREEN_RECORDER_BUNDLE, - '-a', - HARMONY_SCREEN_RECORDER_ABILITY, - ]); - if (!stop.stdout.includes('start ability successfully')) { - return harmonyRecordingFailure('failed to stop HarmonyOS screen recording', stop.stdout); - } - - const query = await runHarmonyHdc(device, [ - 'shell', - 'mediatool', - 'query', - recording.fileName, - '-u', - ]); - mediaUri = parseHarmonyMediaUri(query.stdout); - if (!mediaUri) { - return harmonyRecordingFailure( - `failed to find finalized HarmonyOS recording '${recording.fileName}'`, - query.stdout, - ); - } - - const mediaCopy = await copyHarmonyMediaToDevice({ - device, - mediaUri, - remotePath: recording.remotePath, - }); - if (!mediaCopy.ready) { - return errorResponse( - 'COMMAND_FAILED', - `failed to finalize HarmonyOS recording: ${recording.fileName} did not produce a non-empty media file`, - { lastStagingStatus: mediaCopy.lastStatus }, - ); - } - await runHarmonyHdc(device, ['file', 'recv', recording.remotePath, recording.outPath]); - await deps.waitForStableFile(recording.outPath); - if (!(await waitForHarmonyPlayableVideo(recording.outPath, deps))) { - return errorResponse( - 'COMMAND_FAILED', - `failed to stop HarmonyOS recording: ${recording.outPath} was not finalized into a playable MP4; the retrieved file is retained for inspection`, - ); - } - copied = true; - return null; - } catch (error) { - return { - ok: false, - error: { - code: 'COMMAND_FAILED', - message: `failed to finalize HarmonyOS recording: ${formatHarmonyRecordingError(error)}`, - ...(mediaUri - ? { - hint: `The recording may remain in the device media library as ${mediaUri}. Retrieve or delete it with mediatool.`, - } - : {}), - }, - }; - } finally { - await runHarmonyHdc(device, ['shell', 'rm', '-f', recording.remotePath], { - allowFailure: true, - }).catch(() => {}); - if (copied && mediaUri) { - await runHarmonyHdc(device, ['shell', 'mediatool', 'delete', mediaUri], { - allowFailure: true, - }).catch(() => {}); - } - } -} - -async function copyHarmonyMediaToDevice(params: { - device: SessionState['device']; - mediaUri: string; - remotePath: string; -}): Promise<{ ready: boolean; lastStatus: string }> { - const { device, mediaUri, remotePath } = params; - let lastStatus = 'staging file was not listed'; - // ScreenRecorder publishes the media row before cloud-media finalization finishes. - // API 24 hardware has taken several seconds to turn that row into readable bytes. - for (let attempt = 0; attempt < 40; attempt += 1) { - // mediatool leaves an initial zero-byte destination in place and does not - // overwrite it on a later retry, so clear the device-side staging path first. - await runHarmonyHdc(device, ['shell', 'rm', '-f', remotePath], { allowFailure: true }); - await runHarmonyHdc(device, ['shell', 'mediatool', 'recv', mediaUri, remotePath]); - const stat = await runHarmonyHdc(device, ['shell', 'ls', '-l', remotePath], { - allowFailure: true, - }); - lastStatus = stat.stdout.trim() || stat.stderr.trim() || `hdc exit ${stat.exitCode}`; - if (stat.exitCode === 0 && parseHarmonyFileSize(stat.stdout) !== undefined) { - return { ready: true, lastStatus }; - } - await sleep(250); - } - return { ready: false, lastStatus }; -} - -async function waitForHarmonyPlayableVideo( - outPath: string, - deps: Pick, -): Promise { - for (let attempt = 0; attempt < 12; attempt += 1) { - if (await deps.isPlayableVideo(outPath)) return true; - await sleep(150); - } - return false; -} - -function harmonyRecordingFailure( - message: string, - output: string, -): ReturnType { - return errorResponse('COMMAND_FAILED', `${message}: ${output.trim() || 'no diagnostic output'}`); -} - -function formatHarmonyRecordingError(error: unknown): string { - if (error instanceof AppError) return error.message; - return error instanceof Error ? error.message : String(error); -} diff --git a/src/daemon/handlers/record-trace-ios-simulator-recording.ts b/src/daemon/handlers/record-trace-ios-simulator-recording.ts deleted file mode 100644 index 274b8efeef..0000000000 --- a/src/daemon/handlers/record-trace-ios-simulator-recording.ts +++ /dev/null @@ -1,270 +0,0 @@ -import fs from 'node:fs'; -import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; -import { sleep } from '../../utils/timeouts.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { - buildRecordStopFailure, - formatRecordTraceError, - formatRecordTraceExecFailure, -} from '../record-trace-errors.ts'; -import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; -import { - getIosRunnerOptions, - normalizeAppBundleId, - warmIosSimulatorRunner, -} from './record-trace-ios.ts'; -import { - IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS, - stopIosSimulatorRecordingProcess, -} from './record-trace-ios-simulator.ts'; -import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; -import { errorResponse } from './response.ts'; - -const LOCAL_RECORDING_READY_POLL_MS = 250; -const LOCAL_RECORDING_LIVENESS_GRACE_MS = 50; -// CoreSimulator may delay creating the zero-byte recordVideo destination while -// a just-booted simulator finishes service startup. This is still much shorter -// than recording itself, but avoids reporting a false start under CI load. -const LOCAL_RECORDING_READY_TIMEOUT_MS = 15_000; -const IOS_SIMULATOR_VIDEO_READY_POLL_MS = 150; -const IOS_SIMULATOR_VIDEO_READY_ATTEMPTS = 12; - -type ActiveRecording = NonNullable; -type IosSimulatorRecording = Extract; -type LocalRecordingReadiness = - | { kind: 'ready'; readyAt: number } - | { kind: 'exited'; result: Awaited } - | { kind: 'failed'; error: unknown } - | { kind: 'timeout' }; - -export async function startIosSimulatorRecording(params: { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; - recordingBase: RecordingBase; - resolvedOut: string; -}): Promise { - const { req, activeSession, device, logPath, deps, recordingBase, resolvedOut } = params; - - // The warm-up carries the gesture-clock anchor on its snapshot response when the runner - // stamps it, letting us skip a standalone uptime command. The anchor is a pure clock pair - // (origin uptime + daemon receipt time), so capturing it before the recorder spawn/settle - // window is equivalent to capturing it after: recordingStartedAt stays readyAt below. - const warmAnchor = recordingBase.showTouches - ? await warmIosSimulatorRunner({ req, activeSession, device, logPath, deps }) - : undefined; - const { child, wait } = deps.startIosSimulatorRecording({ device, outPath: resolvedOut }); - const readiness = await waitForLocalRecordingReadiness(resolvedOut, wait); - if (readiness.kind !== 'ready') { - if (readiness.kind === 'timeout' || readiness.kind === 'failed') { - await stopIosSimulatorRecordingProcess({ - deps, - recording: { - platform: 'ios', - child, - wait, - ...recordingBase, - outPath: resolvedOut, - recorderPid: child.pid, - startedAt: Date.now(), - }, - }); - } - removeInvalidRecordingOutput(resolvedOut); - return errorResponse('COMMAND_FAILED', formatRecordingStartFailure(readiness)); - } - const readyAt = readiness.readyAt; - let gestureClockOriginAtMs: number | undefined; - let gestureClockOriginUptimeMs: number | undefined; - if (warmAnchor) { - gestureClockOriginAtMs = warmAnchor.gestureClockOriginAtMs; - gestureClockOriginUptimeMs = warmAnchor.gestureClockOriginUptimeMs; - } else if (recordingBase.showTouches) { - // Fallback for older runner builds (or a failed/unavailable warm anchor): issue a - // standalone uptime command and pair it at the request midpoint. - try { - const uptimeRequestStartedAtMs = Date.now(); - const uptimeResult = await deps.runAppleRunnerCommand( - device, - { - command: 'uptime', - appBundleId: normalizeAppBundleId(activeSession), - }, - getIosRunnerOptions(req, logPath, activeSession), - ); - const uptimeRequestFinishedAtMs = Date.now(); - gestureClockOriginAtMs = Math.round( - (uptimeRequestStartedAtMs + uptimeRequestFinishedAtMs) / 2, - ); - gestureClockOriginUptimeMs = - typeof uptimeResult.currentUptimeMs === 'number' ? uptimeResult.currentUptimeMs : undefined; - } catch { - // Best effort only; wall-clock fallback remains available. - } - } - return { - platform: 'ios', - child, - wait, - ...recordingBase, - recorderPid: child.pid, - startedAt: readyAt, - gestureClockOriginAtMs: - gestureClockOriginUptimeMs === undefined ? undefined : gestureClockOriginAtMs, - gestureClockOriginUptimeMs, - }; -} - -export async function stopIosSimulatorRecording(params: { - deps: RecordTraceDeps; - recording: IosSimulatorRecording; - stopRequestedAt: number; -}): Promise { - const { deps, recording, stopRequestedAt } = params; - - await withDiagnosticTimer('record_stop_tail_settle', () => deps.waitForRecordingTail(recording), { - platform: recording.platform, - gestureEventCount: recording.gestureEvents.length, - }); - const stopResult = await withDiagnosticTimer( - 'record_stop_ios_simulator_process', - () => stopIosSimulatorRecordingProcess({ deps, recording }), - { - outPath: recording.outPath, - }, - ); - if (!stopResult) { - return buildIosSimulatorRecordingStopFailure( - `failed to stop recording: simctl recordVideo did not exit after ${IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS}ms and forced cleanup`, - recording, - stopRequestedAt, - ); - } - if (stopResult.exitCode !== 0) { - return buildIosSimulatorRecordingStopFailure( - `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'simctl recordVideo')}`, - recording, - stopRequestedAt, - ); - } - - await withDiagnosticTimer( - 'record_stop_video_stable', - () => - deps.waitForStableFile(recording.outPath, { - pollMs: IOS_SIMULATOR_VIDEO_READY_POLL_MS, - attempts: IOS_SIMULATOR_VIDEO_READY_ATTEMPTS, - }), - { - outPath: recording.outPath, - }, - ); - const playable = await withDiagnosticTimer( - 'record_stop_video_playable_check', - () => deps.isPlayableVideo(recording.outPath), - { - outPath: recording.outPath, - }, - ); - if (!playable) { - return buildIosSimulatorRecordingStopFailure( - `failed to stop recording: ${recording.outPath} was not finalized into a playable video`, - recording, - stopRequestedAt, - ); - } - - await withDiagnosticTimer( - 'record_stop_finalize_overlay', - () => - finalizeRecordingOverlay({ - recording, - deps, - targetLabel: 'iOS recording', - }), - { - outPath: recording.outPath, - showTouches: recording.showTouches, - gestureEventCount: recording.gestureEvents.length, - }, - ); - - return null; -} - -async function waitForLocalRecordingReadiness( - outPath: string, - wait: IosSimulatorRecording['wait'], -): Promise { - let settledProcessExit: LocalRecordingReadiness | undefined; - const processExit: Promise = wait.then( - (result) => (settledProcessExit = { kind: 'exited', result }), - (error: unknown) => (settledProcessExit = { kind: 'failed', error }), - ); - // Give an already-settled recorder wait precedence over a destination the process touched - // immediately before exiting. - await Promise.resolve(); - const attempts = Math.ceil(LOCAL_RECORDING_READY_TIMEOUT_MS / LOCAL_RECORDING_READY_POLL_MS); - for (let attempt = 0; attempt <= attempts; attempt += 1) { - if (settledProcessExit) return settledProcessExit; - try { - fs.statSync(outPath); - const readyAt = Date.now(); - // `simctl recordVideo` creates a zero-byte destination when capture is ready and writes - // the finalized MP4 only after SIGINT. Existence, not size, is the readiness signal, but - // keep a short liveness window for an immediate post-create process exit to win. - const exit = await Promise.race([ - processExit, - sleep(LOCAL_RECORDING_LIVENESS_GRACE_MS).then(() => undefined), - ]); - if (exit) return exit; - return { kind: 'ready', readyAt }; - } catch { - // Wait for the recorder to create the output file. - } - - if (attempt === attempts) return { kind: 'timeout' }; - const exit = await Promise.race([ - processExit, - sleep(LOCAL_RECORDING_READY_POLL_MS).then(() => undefined), - ]); - if (exit) return exit; - } - - return { kind: 'timeout' }; -} - -function formatRecordingStartFailure( - readiness: Exclude, -): string { - if (readiness.kind === 'timeout') { - return `failed to start recording: simctl recordVideo did not create its output within ${LOCAL_RECORDING_READY_TIMEOUT_MS}ms`; - } - if (readiness.kind === 'failed') { - return `failed to start recording: ${formatRecordTraceError(readiness.error)}`; - } - return `failed to start recording: ${formatRecordTraceExecFailure( - readiness.result, - 'simctl recordVideo', - )}`; -} - -function buildIosSimulatorRecordingStopFailure( - message: string, - recording: IosSimulatorRecording, - stopRequestedAt: number, -): DaemonResponse { - const failure = buildRecordStopFailure(message, recording, stopRequestedAt); - removeInvalidRecordingOutput(recording.outPath); - return errorResponse('COMMAND_FAILED', failure.message); -} - -function removeInvalidRecordingOutput(outPath: string): void { - try { - fs.rmSync(outPath, { force: true }); - } catch { - // Best effort: the error response still reports the failed finalization. - } -} diff --git a/src/daemon/handlers/record-trace-ios-simulator.ts b/src/daemon/handlers/record-trace-ios-simulator.ts deleted file mode 100644 index c7df5cc213..0000000000 --- a/src/daemon/handlers/record-trace-ios-simulator.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { sleep } from '../../utils/timeouts.ts'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import type { ExecResult } from '../../utils/exec.ts'; -import { signalPidsBestEffort, uniquePositivePids } from '../../utils/host-process.ts'; -import { formatRecordTraceError } from '../record-trace-errors.ts'; -import type { SessionState } from '../types.ts'; -import type { RecordTraceDeps } from './record-trace-types.ts'; - -export const IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS = 5_000; - -const IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS = 2_000; - -/** - * Worst-case wall-clock time for {@link stopIosSimulatorRecordingProcess} to - * conclude: the direct child-handle SIGINT wait plus the three escalating - * PID-based retries (SIGINT, SIGTERM, SIGKILL). Any teardown path that bounds - * recording finalization with its own timeout (daemon shutdown's per-session - * teardown race) must budget at least this long, or a recorder stuck past the - * direct-handle wait is abandoned mid-escalation and the simctl child orphans - * with an unfinalized 0-byte mp4. - */ -export const IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS = - IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS + 3 * IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS; - -type IosSimulatorRecording = Extract, { platform: 'ios' }>; - -export async function stopIosSimulatorRecordingProcess(params: { - deps: RecordTraceDeps; - recording: IosSimulatorRecording; -}): Promise { - const { deps, recording } = params; - // First signal the direct ChildProcess handle. If it does not exit, retry through - // session-owned PID metadata so cleanup still works when the process tree outlives the handle. - recording.child.kill('SIGINT'); - let result = await waitForRecordingProcessExit( - recording.wait, - IOS_SIMULATOR_RECORDING_STOP_TIMEOUT_MS, - ); - if (result) return result; - - await signalIosSimulatorRecorderCleanup(deps, recording, 'SIGINT'); - result = await waitForRecordingProcessExit( - recording.wait, - IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, - ); - if (result) return result; - - recording.child.kill('SIGTERM'); - await signalIosSimulatorRecorderCleanup(deps, recording, 'SIGTERM'); - result = await waitForRecordingProcessExit( - recording.wait, - IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, - ); - if (result) return result; - - recording.child.kill('SIGKILL'); - await signalIosSimulatorRecorderCleanup(deps, recording, 'SIGKILL'); - result = await waitForRecordingProcessExit( - recording.wait, - IOS_SIMULATOR_RECORDING_FORCE_STOP_TIMEOUT_MS, - ); - if (result) return result; - if (recording.recorderPid !== undefined && !isProcessAlive(recording.recorderPid)) { - return { exitCode: 0, stderr: '', stdout: '' }; - } - return null; -} - -async function waitForRecordingProcessExit( - wait: Promise, - timeoutMs: number, -): Promise { - // A rejected monitor means we lost the ability to confirm process exit; it does not mean the - // recorder exited. Treat it like an unconfirmed timeout so the caller continues through the - // PID-backed SIGINT/SIGTERM/SIGKILL cleanup sequence. - return await Promise.race([ - wait.then( - (result) => result, - () => null, - ), - sleep(timeoutMs).then(() => null), - ]); -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function signalIosSimulatorRecorderCleanup( - deps: RecordTraceDeps, - recording: IosSimulatorRecording, - signal: NodeJS.Signals, -): Promise { - if (await signalSessionOwnedIosSimulatorRecorders(deps, recording, signal)) { - return; - } - await signalMatchingIosSimulatorRecorders(deps, recording.outPath, signal); -} - -async function signalMatchingIosSimulatorRecorders( - deps: RecordTraceDeps, - outPath: string, - signal: NodeJS.Signals, -): Promise { - const pattern = `simctl.*recordVideo.*${escapeProcessRegex(outPath)}`; - let result: ExecResult; - try { - result = await deps.runCmd('pgrep', ['-f', pattern], { allowFailure: true }); - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_ios_simulator_pgrep_failed', - data: { - outPath, - signal, - error: formatRecordTraceError(error), - }, - }); - return; - } - - const pids = uniquePositivePids(parseProcessIds(result.stdout), { excludePid: process.pid }); - const signaled = signalPidsBestEffort(pids, signal); - - emitDiagnostic({ - level: signaled > 0 ? 'warn' : 'debug', - phase: 'record_stop_ios_simulator_signal_recorders', - data: { - outPath, - signal, - matchedPidCount: pids.length, - signaled, - pgrepExitCode: result.exitCode, - }, - }); -} - -async function signalSessionOwnedIosSimulatorRecorders( - deps: RecordTraceDeps, - recording: IosSimulatorRecording, - signal: NodeJS.Signals, -): Promise { - const recorderPid = recording.recorderPid ?? recording.child.pid; - if (typeof recorderPid !== 'number' || !Number.isInteger(recorderPid) || recorderPid <= 0) { - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_ios_simulator_owned_recorder_unavailable', - data: { - outPath: recording.outPath, - signal, - reason: 'missing_recorder_pid', - }, - }); - return false; - } - - const childResult = await findChildProcessIds(deps, recorderPid, recording.outPath, signal); - const pids = uniquePositivePids([recorderPid, ...childResult.pids], { - excludePid: process.pid, - }); - const signaled = signalPidsBestEffort(pids, signal); - - emitDiagnostic({ - level: signaled > 0 ? 'warn' : 'debug', - phase: 'record_stop_ios_simulator_signal_owned_recorder', - data: { - outPath: recording.outPath, - signal, - recorderPid, - childPidCount: childResult.pids.length, - matchedPidCount: pids.length, - signaled, - pgrepExitCode: childResult.exitCode, - }, - }); - - return signaled > 0; -} - -async function findChildProcessIds( - deps: RecordTraceDeps, - parentPid: number, - outPath: string, - signal: NodeJS.Signals, -): Promise<{ pids: number[]; exitCode?: number }> { - let result: ExecResult; - try { - result = await deps.runCmd('pgrep', ['-P', String(parentPid)], { allowFailure: true }); - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_ios_simulator_owned_pgrep_failed', - data: { - outPath, - signal, - parentPid, - error: formatRecordTraceError(error), - }, - }); - return { pids: [] }; - } - - return { - pids: parseProcessIds(result.stdout), - exitCode: result.exitCode, - }; -} - -function parseProcessIds(stdout: string): number[] { - return stdout - .split(/\s+/) - .map((value) => Number(value)) - .filter((pid) => Number.isInteger(pid) && pid > 0); -} - -function escapeProcessRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/src/daemon/handlers/record-trace-ios.ts b/src/daemon/handlers/record-trace-ios.ts deleted file mode 100644 index 6c8f461979..0000000000 --- a/src/daemon/handlers/record-trace-ios.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { isIosFamily } from '@agent-device/kernel/device'; -import { SessionStore } from '../session-store.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { resolveIosPhysicalDeviceControl } from '../../platforms/apple/core/physical-device-control.ts'; -import { formatRecordTraceError } from '../record-trace-errors.ts'; -import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts'; -import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; -import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; -import { errorResponse } from './response.ts'; - -export function normalizeAppBundleId(session: SessionState): string | undefined { - const trimmed = session.appBundleId?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; -} - -function isRunnerRecordingAlreadyInProgressError(error: unknown): boolean { - return formatRecordTraceError(error).toLowerCase().includes('recording already in progress'); -} - -function findOtherActiveIosRunnerRecording( - sessionStore: SessionStore, - deviceId: string, - currentSessionName: string, -): SessionState | undefined { - return sessionStore - .toArray() - .find( - (session) => - session.name !== currentSessionName && - isIosFamily(session.device) && - session.device.kind === 'device' && - session.device.id === deviceId && - session.recording?.platform === 'ios-device-runner', - ); -} - -export function getIosRunnerOptions( - req: DaemonRequest, - logPath: string | undefined, - session: SessionState, -) { - return buildAppleRunnerRequestOptions({ - req, - logPath, - traceLogPath: session.trace?.outPath, - }); -} - -function resolveIosRecordingTrimStartMs( - recording: Extract, { platform: 'ios-device-runner' }>, -): number { - if ( - typeof recording.runnerStartedAtUptimeMs !== 'number' || - typeof recording.targetAppReadyUptimeMs !== 'number' - ) { - return 0; - } - return Math.max(0, recording.targetAppReadyUptimeMs - recording.runnerStartedAtUptimeMs); -} - -async function stopRunnerRecordingBestEffort(params: { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; -}): Promise { - const { req, activeSession, device, logPath, deps } = params; - const appBundleId = normalizeAppBundleId(activeSession); - - try { - await deps.runAppleRunnerCommand( - device, - { command: 'recordStop', appBundleId }, - getIosRunnerOptions(req, logPath, activeSession), - ); - return true; - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_runner_failed', - data: { - platform: device.platform, - kind: device.kind, - deviceId: device.id, - session: activeSession.name, - error: formatRecordTraceError(error), - }, - }); - return false; - } -} - -type RunnerGestureClockAnchor = { - gestureClockOriginAtMs: number; - gestureClockOriginUptimeMs: number; -}; - -export async function warmIosSimulatorRunner(params: { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; -}): Promise { - const { req, activeSession, device, logPath, deps } = params; - const appBundleId = normalizeAppBundleId(activeSession); - if (!appBundleId) return undefined; - - try { - const result = await deps.runAppleRunnerCommand( - device, - { - command: 'snapshot', - appBundleId, - interactiveOnly: true, - depth: 1, - }, - getIosRunnerOptions(req, logPath, activeSession), - ); - // Pair the runner-stamped uptime with daemon receipt time. The runner stamps - // currentUptimeMs just before sending the response, so receive time is the closest - // wall-clock pair; the request midpoint would be wrong because this warm request can - // include cold runner build/launch (10s+). - const receivedAtMs = Date.now(); - if ( - typeof result.currentUptimeMs === 'number' && - Number.isFinite(result.currentUptimeMs) && - result.currentUptimeMs > 0 - ) { - emitDiagnostic({ - level: 'debug', - phase: 'record_start_gesture_clock_anchor', - data: { source: 'warm_snapshot' }, - }); - return { - gestureClockOriginAtMs: receivedAtMs, - gestureClockOriginUptimeMs: result.currentUptimeMs, - }; - } - return undefined; - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'record_start_simulator_runner_warm_failed', - data: { - deviceId: device.id, - session: activeSession.name, - appBundleId, - error: formatRecordTraceError(error), - }, - }); - return undefined; - } -} - -export async function startIosDeviceRecording(params: { - req: DaemonRequest; - activeSession: SessionState; - sessionStore: SessionStore; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; - fpsFlag: number | undefined; - recordingBase: RecordingBase; - appBundleId: string; -}): Promise> { - const { - req, - activeSession, - sessionStore, - device, - logPath, - deps, - fpsFlag, - recordingBase, - appBundleId, - } = params; - const recordingFileName = `agent-device-recording-${Date.now()}.mp4`; - const remotePath = `tmp/${recordingFileName}`; - const runnerOptions = getIosRunnerOptions(req, logPath, activeSession); - let runnerStartedAtUptimeMs: number | undefined; - let targetAppReadyUptimeMs: number | undefined; - const startRunnerRecording = async () => - deps.runAppleRunnerCommand( - device, - { - command: 'recordStart', - outPath: recordingFileName, - fps: fpsFlag, - appBundleId, - }, - runnerOptions, - ); - - try { - const startResult = await startRunnerRecording(); - runnerStartedAtUptimeMs = - typeof startResult.recorderStartUptimeMs === 'number' - ? startResult.recorderStartUptimeMs - : undefined; - targetAppReadyUptimeMs = - typeof startResult.targetAppReadyUptimeMs === 'number' - ? startResult.targetAppReadyUptimeMs - : undefined; - } catch (error) { - if (!isRunnerRecordingAlreadyInProgressError(error)) { - return errorResponse( - 'COMMAND_FAILED', - `failed to start recording: ${formatRecordTraceError(error)}`, - ); - } - - emitDiagnostic({ - level: 'warn', - phase: 'record_start_runner_desynced', - data: { - platform: device.platform, - kind: device.kind, - deviceId: device.id, - session: activeSession.name, - error: formatRecordTraceError(error), - }, - }); - - const otherRecordingSession = findOtherActiveIosRunnerRecording( - sessionStore, - device.id, - activeSession.name, - ); - if (otherRecordingSession) { - return errorResponse( - 'COMMAND_FAILED', - `failed to start recording: recording already in progress in session '${otherRecordingSession.name}'`, - ); - } - - try { - await deps.runAppleRunnerCommand( - device, - { command: 'recordStop', appBundleId }, - runnerOptions, - ); - } catch { - // best effort: stop stale runner recording and retry start - } - - try { - const startResult = await startRunnerRecording(); - runnerStartedAtUptimeMs = - typeof startResult.recorderStartUptimeMs === 'number' - ? startResult.recorderStartUptimeMs - : undefined; - targetAppReadyUptimeMs = - typeof startResult.targetAppReadyUptimeMs === 'number' - ? startResult.targetAppReadyUptimeMs - : undefined; - } catch (retryError) { - return errorResponse( - 'COMMAND_FAILED', - `failed to start recording: ${formatRecordTraceError(retryError)}`, - ); - } - } - - return { - platform: 'ios-device-runner', - remotePath, - runnerStartedAtUptimeMs, - targetAppReadyUptimeMs, - ...recordingBase, - }; -} - -export async function startMacOsRecording(params: { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; - fpsFlag: number | undefined; - recordingBase: RecordingBase; - appBundleId: string; -}): Promise> { - const { req, activeSession, device, logPath, deps, fpsFlag, recordingBase, appBundleId } = params; - - try { - await deps.runAppleRunnerCommand( - device, - { - command: 'recordStart', - outPath: recordingBase.outPath, - fps: fpsFlag, - appBundleId, - }, - getIosRunnerOptions(req, logPath, activeSession), - ); - } catch (error) { - return errorResponse( - 'COMMAND_FAILED', - `failed to start recording: ${formatRecordTraceError(error)}`, - ); - } - - return { - platform: 'macos-runner', - ...recordingBase, - }; -} - -export async function stopIosDeviceRecording(params: { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; - recording: Extract, { platform: 'ios-device-runner' }>; -}): Promise { - const { req, activeSession, device, logPath, deps, recording } = params; - const runnerStopOk = await stopRunnerRecordingBestEffort({ - req, - activeSession, - device, - logPath, - deps, - }); - - try { - await resolveIosPhysicalDeviceControl(device).copyRunnerFile( - device, - recording.remotePath, - recording.outPath, - ); - } catch (error) { - return errorResponse( - 'COMMAND_FAILED', - `failed to copy recording from device: ${formatRecordTraceError(error)}`, - ); - } - - await deps.waitForStableFile(recording.outPath); - const playable = await deps.isPlayableVideo(recording.outPath); - if (!playable) { - return errorResponse( - 'COMMAND_FAILED', - `failed to stop recording: ${recording.outPath} was not finalized into a playable video`, - ); - } - if (!runnerStopOk) { - return errorResponse( - 'COMMAND_FAILED', - 'failed to stop recording: the iOS runner reported recordStop did not succeed', - ); - } - - const trimStartMs = resolveIosRecordingTrimStartMs(recording); - if (trimStartMs > 0) { - await deps.trimRecordingStart({ - videoPath: recording.outPath, - trimStartMs, - }); - } - - await finalizeRecordingOverlay({ - recording, - deps, - trimStartMs, - targetLabel: 'iOS recording', - }); - - return null; -} - -export async function stopMacOsRecording(params: { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; - recording: Extract, { platform: 'macos-runner' }>; -}): Promise { - const { req, activeSession, device, logPath, deps, recording } = params; - await stopRunnerRecordingBestEffort({ req, activeSession, device, logPath, deps }); - - await finalizeRecordingOverlay({ - recording, - deps, - targetLabel: 'macOS recording', - }); - - return null; -} diff --git a/src/daemon/handlers/record-trace-recording-backends.ts b/src/daemon/handlers/record-trace-recording-backends.ts deleted file mode 100644 index 71a8c37b08..0000000000 --- a/src/daemon/handlers/record-trace-recording-backends.ts +++ /dev/null @@ -1,436 +0,0 @@ -import type { RecordingBackendTag } from '@agent-device/contracts/recording'; -import fs from 'node:fs'; -import path from 'node:path'; -import { tryGetPlugin } from '../../core/platform-plugin-registry.ts'; -import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import type { SessionStore } from '../session-store.ts'; -import { - appendRecordingExtensionWhenMissing, - defaultRecordingPath, - WEB_RECORDING_EXTENSION, -} from '../../recording/output-path.ts'; -import { resolveWebProvider } from '../../platforms/web/provider.ts'; -import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/apple/core/runner/runner-client.ts'; -import { isWholeScreenRecordingScope } from '@agent-device/contracts/recording'; -import { errorResponse } from './response.ts'; -import { startAndroidRecording, stopAndroidRecording } from './record-trace-android.ts'; -import { startHarmonyRecording, stopHarmonyRecording } from './record-trace-harmony.ts'; -import { recoverMissingAndroidRecording } from './record-trace-android-recovery.ts'; -import { - normalizeAppBundleId, - startIosDeviceRecording, - startMacOsRecording, - stopIosDeviceRecording, - stopMacOsRecording, -} from './record-trace-ios.ts'; -import { - startIosSimulatorRecording, - stopIosSimulatorRecording, -} from './record-trace-ios-simulator-recording.ts'; -import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; - -// The plugin registry is consulted by `resolveRecordingBackendForDevice` below; -// register the builtin plugins on load so the lookup is populated (idempotent, -// mirrors src/daemon/app-log.ts and src/daemon/handlers/session-perf.ts). -registerBuiltinPlatformPlugins(); - -type ActiveRecording = NonNullable; -type RecordingPlatform = ActiveRecording['platform']; -type RecordingFor

= Extract; - -type RecordingOutputPathContext = { - req: DaemonRequest; -}; - -type RecordingStartContext = { - req: DaemonRequest; - sessionName: string; - activeSession: SessionState; - sessionStore: SessionStore; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; - fpsFlag: number | undefined; - recordingBase: RecordingBase; - resolvedOut: string; -}; - -type RecordingStopContext

= { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; - recording: RecordingFor

; - stopRequestedAt: number; -}; - -type MissingRecordingStopContext = Omit; - -// A backend is parameterized by the recording tag it owns, so its `stop` receives an -// already-narrowed recording — no `recording as Extract` casts. -// `start` stays wide because the device platform does not map 1:1 to a recording tag -// (e.g. an iOS device resolves to either the `ios` or `ios-device-runner` recording). -export type RecordingBackend

= { - recordingBackend: string; - validateStart?: (req: DaemonRequest) => DaemonResponse | null; - resolveOutputPath: (context: RecordingOutputPathContext) => string; - start: (context: RecordingStartContext) => Promise; - stop: (context: RecordingStopContext

) => Promise; - recoverMissingStop?: ( - context: MissingRecordingStopContext, - ) => Promise; - cleanupRecordOnlySession?: (session: SessionState) => Promise; -}; - -// Device-resolution view: a backend selected before any recording exists exposes only the -// start/output/cleanup surface; stop is dispatched per active recording's tag via -// stopActiveRecording, which is why omitting it keeps the per-tag backends assignable here. -type RecordingStartBackend = Omit; - -export function resolveRecordingBackendForDevice( - device: SessionState['device'], -): RecordingStartBackend { - // Routes the per-platform branch through the PlatformPlugin recording facet (issue - // #974): web/android/apple carry a `recording.resolveBackendTag`; linux (and any - // unregistered platform) fall through to `'unsupported'`, matching the former hand - // branch's default. The daemon owns the backend instances and maps the neutral tag - // back to them here. The recording-plugin routing parity test pins the equivalence. - const tag = tryGetPlugin(device.platform)?.recording?.resolveBackendTag(device) ?? 'unsupported'; - return RECORDING_BACKENDS_BY_TAG[tag]; -} - -export function stopActiveRecording(context: RecordingStopContext): Promise { - const { recording } = context; - switch (recording.platform) { - case 'android': - return androidRecordingBackend.stop({ ...context, recording }); - case 'harmonyos': - return harmonyRecordingBackend.stop({ ...context, recording }); - case 'ios': - return iosSimulatorRecordingBackend.stop({ ...context, recording }); - case 'ios-device-runner': - return iosDeviceRecordingBackend.stop({ ...context, recording }); - case 'macos-runner': - return macOsRecordingBackend.stop({ ...context, recording }); - case 'web': - return webRecordingBackend.stop({ ...context, recording }); - } - - const exhaustive: never = recording; - return exhaustive; -} - -function resolveNativeRecordingOutputPath({ req }: RecordingOutputPathContext): string { - const requestedPath = req.positionals?.[1]; - return requestedPath ?? defaultRecordingPath(undefined); -} - -function resolveWebRecordingOutputPath({ req }: RecordingOutputPathContext): string { - const requestedPath = req.positionals?.[1]; - return requestedPath === undefined - ? defaultRecordingPath('web') - : appendRecordingExtensionWhenMissing(requestedPath, WEB_RECORDING_EXTENSION); -} - -const webRecordingBackend: RecordingBackend<'web'> = { - recordingBackend: 'agent-browser recording', - validateStart: (req) => validateWebRecordingFlags(req), - resolveOutputPath: resolveWebRecordingOutputPath, - start: async ({ activeSession, recordingBase, resolvedOut }) => { - const startError = validateWebRecordingOutputPath(resolvedOut); - if (startError) { - return startError; - } - if (activeSession.recordOnlySession) { - return errorResponse( - 'INVALID_ARGS', - 'record on web requires an active browser session; run open --platform web first', - ); - } - const provider = resolveWebProvider(); - if (!provider.startRecording) { - return errorResponse('UNSUPPORTED_OPERATION', 'record is not supported by this web provider'); - } - await provider.startRecording(resolvedOut); - return { - ...recordingBase, - outPath: resolvedOut, - startedAt: Date.now(), - platform: 'web', - showTouches: false, - }; - }, - stop: async ({ recording }) => await stopWebRecording({ recording }), - cleanupRecordOnlySession: async () => { - try { - await resolveWebProvider().close(); - } catch { - // Best effort cleanup; deleting the daemon session still releases agent-device state. - } - }, -}; - -const iosDeviceRecordingBackend: RecordingBackend<'ios-device-runner'> = { - recordingBackend: 'runner AVAssetWriter', - resolveOutputPath: resolveNativeRecordingOutputPath, - start: async ({ - req, - activeSession, - sessionStore, - device, - logPath, - deps, - fpsFlag, - recordingBase, - }) => { - const appBundleId = normalizeAppBundleId(activeSession); - if (!appBundleId) { - return errorResponse( - 'INVALID_ARGS', - 'record on physical iOS devices requires an active app session; run open first', - ); - } - return await startIosDeviceRecording({ - req, - activeSession, - sessionStore, - device, - logPath, - deps, - fpsFlag, - recordingBase, - appBundleId, - }); - }, - stop: async ({ req, activeSession, device, logPath, deps, recording }) => - await stopIosDeviceRecording({ - req, - activeSession, - device, - logPath, - deps, - recording, - }), -}; - -const macOsRecordingBackend: RecordingBackend<'macos-runner'> = { - recordingBackend: 'runner AVAssetWriter', - resolveOutputPath: resolveNativeRecordingOutputPath, - start: async ({ req, activeSession, device, logPath, deps, fpsFlag, recordingBase }) => { - const appBundleId = normalizeAppBundleId(activeSession); - if (!appBundleId) { - return errorResponse( - 'INVALID_ARGS', - 'record on macOS requires an active app session; run open first', - ); - } - return await startMacOsRecording({ - req, - activeSession, - device, - logPath, - deps, - fpsFlag, - recordingBase, - appBundleId, - }); - }, - stop: async ({ req, activeSession, device, logPath, deps, recording }) => - await stopMacOsRecording({ - req, - activeSession, - device, - logPath, - deps, - recording, - }), -}; - -const iosSimulatorRecordingBackend: RecordingBackend<'ios'> = { - recordingBackend: 'simctl recordVideo', - resolveOutputPath: resolveNativeRecordingOutputPath, - start: async ({ req, activeSession, device, logPath, deps, recordingBase, resolvedOut }) => { - const appBundleId = normalizeAppBundleId(activeSession); - const appScopedRecording = !isWholeScreenRecordingScope(recordingBase.recordingScope ?? 'app'); - if (appScopedRecording && !appBundleId) { - return errorResponse( - 'INVALID_ARGS', - 'record on iOS Simulator with app scope requires an active app session; run open first, or use --scope device to record the full simulator screen', - ); - } - if (appScopedRecording && appBundleId && isAgentDeviceRunnerBundle(appBundleId)) { - return errorResponse( - 'INVALID_ARGS', - 'record on iOS Simulator cannot use Agent Device Runner as the active app session; run open first', - ); - } - return await startIosSimulatorRecording({ - req, - activeSession, - device, - logPath, - deps, - recordingBase, - resolvedOut, - }); - }, - stop: async ({ deps, recording, stopRequestedAt }) => - await stopIosSimulatorRecording({ - deps, - recording, - stopRequestedAt, - }), -}; - -const androidRecordingBackend: RecordingBackend<'android'> = { - recordingBackend: 'adb screenrecord', - resolveOutputPath: resolveNativeRecordingOutputPath, - start: async ({ sessionName, activeSession, device, recordingBase }) => - await startAndroidRecording({ sessionName, activeSession, device, recordingBase }), - recoverMissingStop: async ({ sessionName, activeSession, device, recordingBase }) => - await recoverMissingAndroidRecording({ sessionName, activeSession, device, recordingBase }), - stop: async ({ deps, device, recording, stopRequestedAt }) => - await stopAndroidRecording({ - deps, - device, - recording, - stopRequestedAt, - }), -}; - -const harmonyRecordingBackend: RecordingBackend<'harmonyos'> = { - recordingBackend: 'HarmonyOS ScreenRecorder', - validateStart: (req) => validateHarmonyRecordingFlags(req), - resolveOutputPath: resolveNativeRecordingOutputPath, - start: async ({ device, recordingBase }) => - await startHarmonyRecording({ device, recordingBase }), - stop: async ({ deps, device, recording }) => - await stopHarmonyRecording({ deps, device, recording }), -}; - -const unsupportedRecordingBackend: RecordingBackend = { - recordingBackend: 'unsupported', - resolveOutputPath: resolveNativeRecordingOutputPath, - start: async () => - errorResponse('UNSUPPORTED_OPERATION', 'record is not supported on this device'), - stop: async () => - errorResponse('UNSUPPORTED_OPERATION', 'record is not supported on this device'), -}; - -// Maps the neutral {@link RecordingBackendTag} the plugin facet returns back to the -// daemon-owned backend instance. Exhaustive over the tag union (a compile error if a -// tag is added without a backend), so `resolveRecordingBackendForDevice` is a pure -// data lookup with no platform branch of its own. -const RECORDING_BACKENDS_BY_TAG: Record = { - web: webRecordingBackend, - android: androidRecordingBackend, - harmonyos: harmonyRecordingBackend, - macos: macOsRecordingBackend, - 'ios-device': iosDeviceRecordingBackend, - 'ios-simulator': iosSimulatorRecordingBackend, - unsupported: unsupportedRecordingBackend, -}; - -function validateHarmonyRecordingFlags(req: DaemonRequest): DaemonResponse | null { - const scope = req.flags?.recordingScope ?? 'app'; - if (!isWholeScreenRecordingScope(scope)) { - return errorResponse( - 'INVALID_ARGS', - 'HarmonyOS recording captures the whole physical-device screen; use --scope device or --scope system', - ); - } - const unsupportedFlags = harmonyUnsupportedRecordingFlags(req); - return unsupportedFlags.length > 0 - ? errorResponse( - 'INVALID_ARGS', - `HarmonyOS recordings do not support ${unsupportedFlags.join(', ')}`, - ) - : null; -} - -function harmonyUnsupportedRecordingFlags(req: DaemonRequest): string[] { - const flags = req.flags; - const unsupported: string[] = []; - if (flags?.fps !== undefined) unsupported.push('--fps'); - if (flags?.quality !== undefined) unsupported.push('--quality'); - if (flags?.hideTouches !== undefined) unsupported.push('--hide-touches'); - return unsupported; -} - -const WEB_UNSUPPORTED_RECORDING_FLAGS = [ - ['fps', '--fps'], - ['quality', '--quality'], - ['hideTouches', '--hide-touches'], -] as const satisfies readonly (readonly [keyof NonNullable, string])[]; - -function webRecordingUnsupportedFlags(req: DaemonRequest): string[] { - const flags = req.flags ?? {}; - const unsupported = WEB_UNSUPPORTED_RECORDING_FLAGS.flatMap(([key, flag]) => - flags[key] !== undefined ? [flag] : [], - ); - return isWholeScreenRecordingScope(flags.recordingScope ?? 'app') - ? [...unsupported, '--scope'] - : unsupported; -} - -function validateWebRecordingFlags(req: DaemonRequest): DaemonResponse | null { - const unsupportedWebFlags = webRecordingUnsupportedFlags(req); - if (unsupportedWebFlags.length > 0) { - return errorResponse( - 'INVALID_ARGS', - `web recordings do not support ${unsupportedWebFlags.join(', ')}; agent-browser records WebM directly`, - ); - } - return null; -} - -function validateWebRecordingOutputPath(outPath: string): DaemonResponse | null { - if (path.extname(outPath).toLowerCase() !== WEB_RECORDING_EXTENSION) { - return errorResponse( - 'INVALID_ARGS', - `web recordings must use a ${WEB_RECORDING_EXTENSION} output path`, - ); - } - return null; -} - -function isAgentDeviceRunnerBundle(bundleId: string): boolean { - return IOS_RUNNER_CONTAINER_BUNDLE_IDS.includes(bundleId); -} - -function removeInvalidRecordingOutput(outPath: string): void { - try { - fs.rmSync(outPath, { force: true }); - } catch { - // Best effort: the error response still reports the failed finalization. - } -} - -async function stopWebRecording(params: { - recording: Extract; -}): Promise { - const { recording } = params; - const provider = resolveWebProvider(); - if (!provider.stopRecording) { - return errorResponse('UNSUPPORTED_OPERATION', 'record is not supported by this web provider'); - } - await provider.stopRecording(); - if (!hasNonEmptyFile(recording.outPath)) { - removeInvalidRecordingOutput(recording.outPath); - return errorResponse( - 'COMMAND_FAILED', - `failed to stop recording: ${recording.outPath} was not finalized into a WebM video`, - ); - } - return null; -} - -function hasNonEmptyFile(outPath: string): boolean { - try { - return fs.statSync(outPath).size > 0; - } catch { - return false; - } -} diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts deleted file mode 100644 index 3f8709fd7d..0000000000 --- a/src/daemon/handlers/record-trace-recording.ts +++ /dev/null @@ -1,668 +0,0 @@ -import { - DEFAULT_RECORDING_EXPORT_QUALITY, - RECORDING_EXPORT_QUALITIES, - RECORDING_SCOPE_VALUES, - type RecordingCommandResult, - type RecordingScope, - isWholeScreenRecordingScope, - recordingQualityInputToExportQuality, -} from '@agent-device/contracts/recording'; -import { AppError, toAppErrorCode } from '@agent-device/kernel/errors'; -import { retiredScreenshotMaxSizeFlagError } from '@agent-device/contracts/capture'; -import fs from 'node:fs'; -import path from 'node:path'; -import { resolveTargetDevice } from '../../core/dispatch.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { runXcrun } from '../../platforms/apple/core/tool-provider.ts'; -import { overlayRecordingTouches, trimRecordingStart } from '../../recording/overlay.ts'; -import { runCmd } from '../../utils/exec.ts'; -import { sleep } from '../../utils/timeouts.ts'; -import { isPlayableVideo, waitForStableFile } from '../../utils/video.ts'; -import { ensureDeviceReady } from '../device-ready.ts'; -import { resolveRecordingProvider } from '../recording-provider.ts'; -import { deriveRecordingTelemetryPath } from '../recording-telemetry.ts'; -import { hasExplicitSessionFlag, resolveImplicitSessionScope } from '../session-routing.ts'; -import { SessionStore } from '../session-store.ts'; -import type { DaemonArtifact, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { recordSessionAction } from './handler-utils.ts'; -import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; -import { - resolveRecordingBackendForDevice, - stopActiveRecording, -} from './record-trace-recording-backends.ts'; -import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; -import { errorResponse, requireCommandSupported } from './response.ts'; - -const IOS_DEVICE_RECORD_MIN_FPS = 1; -const IOS_DEVICE_RECORD_MAX_FPS = 120; -const IOS_SIMULATOR_RECORDING_TAIL_SETTLE_MS = 350; - -type StartRecordingParams = { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - activeSession: SessionState; - device: SessionState['device']; - recordingScope: RecordingScope; - logPath?: string; - deps: RecordTraceDeps; -}; - -type StopRecordingParams = { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - activeSession: SessionState; - device: SessionState['device']; - logPath?: string; - deps: RecordTraceDeps; -}; - -type PreparedRecordingStart = { - outPath: string; - resolvedOut: string; - recordingBase: RecordingBase; -}; -type RecordingQualityInput = Parameters[0]; -type RecordingStartBackend = ReturnType; -type RecordingStartPlan = PreparedRecordingStart & { - backend: RecordingStartBackend; - fpsFlag: number | undefined; -}; - -function buildRecordTraceDeps(): RecordTraceDeps { - return { - runCmd: async (cmd, args, options) => - cmd === 'xcrun' ? await runXcrun(args, options) : await runCmd(cmd, args, options), - startIosSimulatorRecording: (request) => - resolveRecordingProvider().startIosSimulatorRecording(request), - runAppleRunnerCommand, - waitForRecordingTail, - waitForStableFile, - isPlayableVideo, - trimRecordingStart, - overlayRecordingTouches, - }; -} - -async function waitForRecordingTail( - recording: RecordingBase & { platform: 'ios' | 'android' }, -): Promise { - if (recording.platform !== 'ios') return; - if (recording.gestureEvents.length === 0) return; - await sleep(IOS_SIMULATOR_RECORDING_TAIL_SETTLE_MS); -} - -function buildRecordingBase(params: { - req: DaemonRequest; - outPath: string; - activeSession: SessionState; - recordingBackend: string; - recordingScope: RecordingScope; -}): RecordingBase { - const { req, outPath, activeSession, recordingBackend, recordingScope } = params; - const exportQuality = recordingQualityInputToExportQuality(req.flags?.quality); - return { - outPath, - clientOutPath: req.meta?.clientArtifactPaths?.outPath, - startedAt: Date.now(), - recordingScope, - recordingBackend, - recordOnlySession: activeSession.recordOnlySession === true, - activeSessionApp: activeSession.appBundleId - ? { - bundleId: activeSession.appBundleId, - ...(activeSession.appName ? { name: activeSession.appName } : {}), - } - : undefined, - exportQuality: exportQuality ?? DEFAULT_RECORDING_EXPORT_QUALITY, - showTouches: req.flags?.hideTouches !== true, - gestureEvents: [], - }; -} - -function buildRequestedRecordingEventDetails( - recording: Pick | undefined, -): { requestedFileName?: string } { - if (!recording?.clientOutPath) return {}; - return { requestedFileName: path.basename(recording.clientOutPath) }; -} - -// --- Start recording orchestrator --- - -async function startRecording(params: StartRecordingParams): Promise { - const { req, sessionName, sessionStore, activeSession, device, logPath, deps } = params; - const startPlan = resolveRecordingStartPlan(params); - if (!('backend' in startPlan)) return startPlan; - - const recording = await startPlan.backend.start({ - req, - sessionName, - activeSession, - sessionStore, - device, - logPath, - deps, - fpsFlag: startPlan.fpsFlag, - recordingBase: startPlan.recordingBase, - resolvedOut: startPlan.resolvedOut, - }); - - return persistStartedRecording({ - req, - sessionName, - sessionStore, - activeSession, - recording, - outPath: startPlan.outPath, - }); -} - -function resolveRecordingStartPlan( - params: StartRecordingParams, -): DaemonResponse | RecordingStartPlan { - const { req, activeSession, device, recordingScope } = params; - const backend = resolveRecordingBackendForDevice(device); - const startError = validateRecordingStartRequest({ req, activeSession, device, backend }); - if (startError) return startError; - - return { - ...prepareRecordingStart(req, backend, activeSession, recordingScope), - backend, - fpsFlag: req.flags?.fps, - }; -} - -function validateRecordingStartRequest(params: { - req: DaemonRequest; - activeSession: SessionState; - device: SessionState['device']; - backend: RecordingStartBackend; -}): DaemonResponse | null { - const { req, activeSession, device, backend } = params; - const validators = [ - () => validateNoActiveRecording(activeSession), - () => validateRemovedRecordingMaxSizeFlag(req.flags), - () => backend.validateStart?.(req) ?? null, - () => - validateRecordingStartFlags({ - fpsFlag: req.flags?.fps, - qualityFlag: req.flags?.quality, - }), - () => requireCommandSupported('record', device), - ]; - for (const validate of validators) { - const error = validate(); - if (error) return error; - } - return null; -} - -function persistStartedRecording(params: { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - activeSession: SessionState; - recording: Awaited>; - outPath: string; -}): DaemonResponse { - const { req, sessionName, sessionStore, activeSession, recording, outPath } = params; - if ('ok' in recording) { - return recording; - } - - activeSession.recording = recording; - sessionStore.set(sessionName, activeSession); - const sessionStateDir = sessionStore.ensureSessionDir(sessionName); - recordSessionAction(sessionStore, activeSession, req, req.command, { - action: 'start', - ...buildRequestedRecordingEventDetails(recording), - showTouches: recording.showTouches, - }); - - return { - ok: true, - data: { - recording: 'started', - outPath: recording.clientOutPath ?? outPath, - sessionStateDir, - recordingBackend: recording.recordingBackend, - recordingScope: recording.recordingScope, - recordOnlySession: recording.recordOnlySession, - activeSessionApp: recording.activeSessionApp, - showTouches: recording.showTouches, - } satisfies RecordingCommandResult, - }; -} - -function validateNoActiveRecording(activeSession: SessionState): DaemonResponse | null { - return activeSession.recording - ? errorResponse('INVALID_ARGS', 'recording already in progress') - : null; -} - -function validateRecordingStartFlags(flags: { - fpsFlag: number | undefined; - qualityFlag: RecordingQualityInput; -}): DaemonResponse | null { - const { fpsFlag, qualityFlag } = flags; - return validateRecordingFpsFlag(fpsFlag) ?? validateRecordingQualityFlag(qualityFlag); -} - -function validateRemovedRecordingMaxSizeFlag(flags: DaemonRequest['flags']): DaemonResponse | null { - const message = retiredScreenshotMaxSizeFlagError('record', flags); - return message ? errorResponse('INVALID_ARGS', message) : null; -} - -function validateRecordingFpsFlag(fpsFlag: number | undefined): DaemonResponse | null { - if ( - fpsFlag !== undefined && - (!Number.isInteger(fpsFlag) || - fpsFlag < IOS_DEVICE_RECORD_MIN_FPS || - fpsFlag > IOS_DEVICE_RECORD_MAX_FPS) - ) { - return errorResponse( - 'INVALID_ARGS', - `fps must be an integer between ${IOS_DEVICE_RECORD_MIN_FPS} and ${IOS_DEVICE_RECORD_MAX_FPS}`, - ); - } - return null; -} - -function validateRecordingQualityFlag(qualityFlag: RecordingQualityInput): DaemonResponse | null { - if ( - qualityFlag !== undefined && - recordingQualityInputToExportQuality(qualityFlag) === undefined - ) { - return errorResponse( - 'INVALID_ARGS', - `quality must be one of: ${RECORDING_EXPORT_QUALITIES.join(', ')} (legacy numeric values 5-10 are accepted)`, - ); - } - return null; -} - -function prepareRecordingStart( - req: DaemonRequest, - backend: ReturnType, - activeSession: SessionState, - recordingScope: RecordingScope, -): PreparedRecordingStart { - const outPath = backend.resolveOutputPath({ req }); - const resolvedOut = SessionStore.expandHome(outPath, req.meta?.cwd); - const recordingBase = buildRecordingBase({ - req, - outPath: resolvedOut, - activeSession, - recordingBackend: backend.recordingBackend, - recordingScope, - }); - fs.mkdirSync(path.dirname(resolvedOut), { recursive: true }); - fs.rmSync(resolvedOut, { force: true }); - return { outPath, resolvedOut, recordingBase }; -} - -async function stopRecording(params: StopRecordingParams): Promise { - const { req, activeSession, device, logPath, deps } = params; - - const recording = await resolveRecordingToStop(params); - if (recording && 'ok' in recording) return recording; - if (!recording) { - return errorResponse('INVALID_ARGS', 'no active recording'); - } - - const stopRequestedAt = Date.now(); - const invalidatedReason = recording.invalidatedReason; - activeSession.recording = undefined; - const stopError = await stopActiveRecording({ - req, - activeSession, - device, - logPath, - deps, - recording, - stopRequestedAt, - }); - if (stopError) { - return stopError; - } - - const invalidatedError = applyRecordingInvalidation(recording, invalidatedReason); - if (invalidatedError) return invalidatedError; - - return buildRecordStopResponse(recording); -} - -async function resolveRecordingToStop( - params: StopRecordingParams, -): Promise | null> { - if (params.activeSession.recording) { - return params.activeSession.recording; - } - return await recoverMissingRecordingState(params); -} - -async function recoverMissingRecordingState( - params: StopRecordingParams, -): Promise | null> { - const { req, sessionName, sessionStore, activeSession, device, logPath, deps } = params; - if (hasActiveRecordingSessionForDevice(sessionStore, device.id)) { - return null; - } - - const backend = resolveRecordingBackendForDevice(device); - if (!backend.recoverMissingStop) { - return null; - } - - const { resolvedOut, recordingBase } = prepareRecoveredRecording(req, backend, activeSession); - const recovered = await backend.recoverMissingStop({ - req, - sessionName, - activeSession, - sessionStore, - device, - logPath, - deps, - recordingBase, - resolvedOut, - }); - if (!recovered) { - return null; - } - if (!('ok' in recovered)) { - resetRecoveredRecordingOutput(resolvedOut); - } - return recovered; -} - -function prepareRecoveredRecording( - req: DaemonRequest, - backend: ReturnType, - activeSession: SessionState, -): Pick { - const outPath = backend.resolveOutputPath({ req }); - const resolvedOut = SessionStore.expandHome(outPath, req.meta?.cwd); - const recordingBase = buildRecordingBase({ - req, - outPath: resolvedOut, - activeSession, - recordingBackend: backend.recordingBackend, - recordingScope: activeSession.recording?.recordingScope ?? 'app', - }); - return { resolvedOut, recordingBase }; -} - -function resetRecoveredRecordingOutput(resolvedOut: string): void { - fs.mkdirSync(path.dirname(resolvedOut), { recursive: true }); - fs.rmSync(resolvedOut, { force: true }); -} - -function applyRecordingInvalidation( - recording: NonNullable, - invalidatedReason: string | undefined, -): DaemonResponse | null { - if (!invalidatedReason) { - return null; - } - if (recording.platform === 'ios' && recording.showTouches) { - recording.overlayWarning ??= `overlay unavailable: ${invalidatedReason}`; - return null; - } - return errorResponse('COMMAND_FAILED', invalidatedReason); -} - -function hasActiveRecordingSessionForDevice(sessionStore: SessionStore, deviceId: string): boolean { - for (const session of sessionStore.values()) { - if (session.recording && session.device.id === deviceId) { - return true; - } - } - return false; -} - -function buildRecordStopResponse( - recording: NonNullable, -): DaemonResponse { - const chunks = recording.platform === 'android' ? recording.chunks : undefined; - const artifacts: DaemonArtifact[] = [ - { - field: 'outPath', - artifactType: 'screen-recording', - path: recording.outPath, - localPath: recording.clientOutPath, - fileName: path.basename(recording.clientOutPath ?? recording.outPath), - }, - ]; - if (chunks && chunks.length > 1) { - artifacts.push( - ...chunks.slice(1).map((chunk) => ({ - field: 'chunkPath', - artifactType: 'screen-recording-chunk' as const, - path: chunk.path, - localPath: deriveAndroidChunkClientPath(recording, chunk.index), - fileName: path.basename(deriveAndroidChunkClientPath(recording, chunk.index) ?? chunk.path), - })), - ); - } - if (recording.telemetryPath) { - artifacts.push({ - field: 'telemetryPath', - artifactType: 'screen-recording-telemetry', - path: recording.telemetryPath, - localPath: deriveClientTelemetryPath(recording), - fileName: path.basename(recording.telemetryPath), - }); - } - - return { - ok: true, - data: { - recording: 'stopped', - outPath: recording.outPath, - telemetryPath: recording.telemetryPath, - artifacts, - recordingBackend: recording.recordingBackend, - recordingScope: recording.recordingScope, - recordOnlySession: recording.recordOnlySession, - activeSessionApp: recording.activeSessionApp, - durationMs: Date.now() - recording.startedAt, - showTouches: recording.showTouches, - warning: recording.warning, - overlayWarning: recording.overlayWarning, - chunks: chunks?.map((chunk) => ({ - index: chunk.index, - path: deriveAndroidChunkClientPath(recording, chunk.index) ?? chunk.path, - })), - } satisfies RecordingCommandResult, - }; -} - -function deriveAndroidChunkClientPath( - recording: NonNullable, - chunkIndex: number, -): string | undefined { - if (recording.platform !== 'android' || !recording.clientOutPath) { - return undefined; - } - return deriveAndroidChunkOutPath(recording.clientOutPath, chunkIndex); -} - -function deriveClientTelemetryPath( - recording: NonNullable, -): string | undefined { - if (!recording.clientOutPath) { - return undefined; - } - return deriveRecordingTelemetryPath(recording.clientOutPath); -} - -/** - * #1478 (P4-pre): a record-only session is created by `record` itself and never - * by `open`, so the only way it could ever have carried `recordSession` was a - * raw `record --save-script` request — the arming path now rejected at the - * daemon request seam (`unsupportedSaveScriptFlagResponse`). With that closed, - * the immediate `writeSessionLog` this used to run at `record stop` could only - * ever be a no-op, so it is gone: releasing a record-only session is backend - * cleanup plus store removal. - */ -async function releaseRecordOnlySession( - sessionStore: SessionStore, - sessionName: string, - session: SessionState, -): Promise { - if (!session.recordOnlySession) { - return; - } - const backend = resolveRecordingBackendForDevice(session.device); - await backend.cleanupRecordOnlySession?.(session); - sessionStore.delete(sessionName); -} - -/** - * Best-effort finalization of a session's still-active recording during - * teardown (session close or daemon shutdown). The normal `test --record-video` - * and `record stop` flows stop the recorder explicitly, but a session torn down - * while a recording is still active — e.g. the daemon is signalled/reaped or the - * session is closed before an explicit stop — otherwise leaks its recorder - * process. On the iOS simulator the `simctl io … recordVideo` child then - * reparents to launchd (PPID 1) and, because simctl only finalizes the mp4 on - * SIGINT, leaves a 0-byte file that also holds the device's single host - * recording slot (later attempts fail with "Host recording is already in - * progress"). Routing through the normal {@link stopActiveRecording} path sends - * SIGINT to the recorder and awaits the finalized file on every platform. - * - * The recording is detached from the session first so a late explicit - * `record stop` (or a second teardown pass) cannot double-stop the same - * recorder. A typed stop failure (the recorder could not be finalized) is - * rethrown as an {@link AppError} so both callers' isolated cleanup channels - * (`runIsolatedSessionCleanup` / `attemptCleanup`) record it as a `recording` - * cleanup failure instead of silently reporting successful cleanup; later - * cleanup steps still run because those channels isolate per-step failures. - */ -export async function stopSessionRecordingForTeardown( - session: SessionState, - logPath?: string, -): Promise { - const recording = session.recording; - if (!recording) return; - session.recording = undefined; - const req: DaemonRequest = { - token: '', - session: session.name, - command: 'record', - positionals: ['stop'], - flags: {}, - }; - const stopFailure = await stopActiveRecording({ - req, - activeSession: session, - device: session.device, - logPath, - deps: buildRecordTraceDeps(), - recording, - stopRequestedAt: Date.now(), - }); - if (stopFailure && stopFailure.ok === false) { - throw new AppError(toAppErrorCode(stopFailure.error.code), stopFailure.error.message); - } -} - -// --- Main command handler --- - -export async function handleRecordCommand(params: { - req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; - logPath?: string; -}): Promise { - const { req, sessionName, sessionStore, logPath } = params; - const deps = buildRecordTraceDeps(); - const session = sessionStore.get(sessionName); - const action = (req.positionals?.[0] ?? '').toLowerCase(); - if (!['start', 'stop'].includes(action)) { - return errorResponse('INVALID_ARGS', 'record requires start|stop'); - } - const recordingScope = readRecordingScope(req); - if (typeof recordingScope === 'object') { - return recordingScope; - } - - if (action === 'start' && !session && !isWholeScreenRecordingScope(recordingScope)) { - return errorResponse( - 'INVALID_ARGS', - hasExplicitSessionFlag(req) - ? 'record start with app scope and an explicit session requires an active app session; run open first, or use --scope device to record the full screen' - : 'record start defaults to app scope and requires an active app session; run open first, or use --scope device to record the full screen', - ); - } - - const device = session?.device ?? (await resolveTargetDevice(req.flags ?? {})); - if (!session) { - await ensureDeviceReady(device); - } - - const activeSession = - session ?? - ({ - name: sessionName, - sessionScope: resolveImplicitSessionScope(req), - device, - createdAt: Date.now(), - recordOnlySession: true, - actions: [], - } satisfies SessionState); - - if (action === 'start') { - return startRecording({ - req, - sessionName, - sessionStore, - activeSession, - device, - recordingScope, - logPath, - deps, - }); - } - - const requestedRecordingEventDetails = buildRequestedRecordingEventDetails( - activeSession.recording, - ); - const response = await stopRecording({ - req, - sessionName, - sessionStore, - activeSession, - device, - logPath, - deps, - }); - if (!response.ok) { - await releaseRecordOnlySession(sessionStore, sessionName, activeSession); - return response; - } - - recordSessionAction(sessionStore, activeSession, req, req.command, { - action: 'stop', - outPath: response.data?.outPath, - ...requestedRecordingEventDetails, - showTouches: response.data?.showTouches, - }); - await releaseRecordOnlySession(sessionStore, sessionName, activeSession); - return response; -} - -function readRecordingScope(req: DaemonRequest): RecordingScope | DaemonResponse { - const value = req.flags?.recordingScope; - if (value === undefined) return 'app'; - if (isRecordingScope(value)) return value; - return errorResponse( - 'INVALID_ARGS', - `record scope must be one of: ${RECORDING_SCOPE_VALUES.join(', ')}`, - ); -} - -function isRecordingScope(value: unknown): value is RecordingScope { - return typeof value === 'string' && RECORDING_SCOPE_VALUES.includes(value as RecordingScope); -} diff --git a/src/daemon/handlers/record-trace-types.ts b/src/daemon/handlers/record-trace-types.ts deleted file mode 100644 index fb4059a235..0000000000 --- a/src/daemon/handlers/record-trace-types.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { RecordingExportQuality, RecordingScope } from '@agent-device/contracts/recording'; -import type { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import type { overlayRecordingTouches, trimRecordingStart } from '../../recording/overlay.ts'; -import type { runCmd } from '../../utils/exec.ts'; -import type { isPlayableVideo, waitForStableFile } from '../../utils/video.ts'; -import type { RecordingProvider } from '../recording-provider.ts'; -import type { RecordingGestureEvent } from '../types.ts'; - -export type RecordTraceDeps = { - runCmd: typeof runCmd; - startIosSimulatorRecording: RecordingProvider['startIosSimulatorRecording']; - runAppleRunnerCommand: typeof runAppleRunnerCommand; - waitForRecordingTail: ( - recording: RecordingBase & { platform: 'ios' | 'android' }, - ) => Promise; - waitForStableFile: typeof waitForStableFile; - isPlayableVideo: typeof isPlayableVideo; - trimRecordingStart: typeof trimRecordingStart; - overlayRecordingTouches: typeof overlayRecordingTouches; -}; - -export type RecordingBase = { - outPath: string; - clientOutPath?: string; - startedAt: number; - recordingScope?: RecordingScope; - recordingBackend?: string; - recordOnlySession?: boolean; - activeSessionApp?: { - bundleId: string; - name?: string; - }; - exportQuality?: RecordingExportQuality; - showTouches: boolean; - gestureEvents: RecordingGestureEvent[]; -}; diff --git a/src/daemon/handlers/record-trace.ts b/src/daemon/handlers/record-trace.ts index e454fe5860..df2a02cb43 100644 --- a/src/daemon/handlers/record-trace.ts +++ b/src/daemon/handlers/record-trace.ts @@ -1,85 +1,42 @@ -import fs from 'node:fs'; -import path from 'node:path'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { handleRecordCommand } from './record-trace-recording.ts'; -import { errorResponse } from './response.ts'; -import { recordSessionAction } from './handler-utils.ts'; -import type { TraceCommandResult } from '@agent-device/contracts/recording'; +import { handleRecordCommand } from './record-runtime.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; +import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; +import { handleTraceCommand } from './trace-runtime.ts'; export async function handleRecordTraceCommands(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; logPath?: string; + bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; + admissionLedger: ScreenRecordingAdmissionLedger; + requestScope: PlatformRequestScope; + retainDeviceExecutionLock(deviceId: string): Promise; + throwIfCanceled(): void; }): Promise { - const { req, sessionName, sessionStore, logPath } = params; + const { req, sessionName, sessionStore } = params; const command = req.command; if (command === 'record') { - return handleRecordCommand({ req, sessionName, sessionStore, logPath }); + return handleRecordCommand({ + req, + sessionName, + sessionStore, + bindDevice: params.bindDevice, + bindExactDevice: params.bindExactDevice, + admissionLedger: params.admissionLedger, + requestScope: params.requestScope, + retainDeviceExecutionLock: params.retainDeviceExecutionLock, + throwIfCanceled: params.throwIfCanceled, + }); } if (command === 'trace') { - const action = (req.positionals?.[0] ?? '').toLowerCase(); - if (!['start', 'stop'].includes(action)) { - return errorResponse('INVALID_ARGS', 'trace requires start|stop'); - } - const session = sessionStore.get(sessionName); - if (!session) { - return errorResponse('SESSION_NOT_FOUND', 'No active session'); - } - if (action === 'start') { - if (session.trace) { - return errorResponse('INVALID_ARGS', 'trace already in progress'); - } - const outPath = req.positionals?.[1] ?? sessionStore.defaultTracePath(session); - const resolvedOut = SessionStore.expandHome(outPath); - fs.mkdirSync(path.dirname(resolvedOut), { recursive: true }); - fs.appendFileSync(resolvedOut, ''); - session.trace = { outPath: resolvedOut, startedAt: Date.now() }; - recordSessionAction(sessionStore, session, req, command, { - action: 'start', - outPath: resolvedOut, - }); - return { - ok: true, - data: { trace: 'started', outPath: resolvedOut } satisfies TraceCommandResult, - }; - } - if (!session.trace) { - return errorResponse('INVALID_ARGS', 'no active trace'); - } - let outPath = session.trace.outPath; - if (req.positionals?.[1]) { - const resolvedOut = SessionStore.expandHome(req.positionals[1]); - fs.mkdirSync(path.dirname(resolvedOut), { recursive: true }); - if (fs.existsSync(outPath)) { - fs.renameSync(outPath, resolvedOut); - } else { - fs.appendFileSync(resolvedOut, ''); - } - outPath = resolvedOut; - } - session.trace = undefined; - recordSessionAction(sessionStore, session, req, command, { action: 'stop', outPath }); - const clientOutPath = req.meta?.clientArtifactPaths?.outPath ?? outPath; - return { - ok: true, - data: { - trace: 'stopped', - outPath, - artifacts: [ - { - field: 'outPath', - artifactType: 'trace-log', - path: outPath, - localPath: clientOutPath, - fileName: path.basename(clientOutPath), - }, - ], - } satisfies TraceCommandResult, - }; + return handleTraceCommand({ req, sessionName, sessionStore }); } return null; diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 491ac90d47..153d53c0b8 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -23,7 +23,6 @@ import { } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; import { expireRefFrame } from '../ref-frame.ts'; -import { stopSessionRecordingForTeardown } from './record-trace-recording.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { releaseSessionLease } from '../lease-lifecycle.ts'; import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; @@ -35,6 +34,7 @@ import { import { isAuthoringArmedSession } from '../session-script-publication-capability.ts'; import { reportSessionCleanupFailures, + finishSessionScreenRecording, restoreSessionAndroidIme, stopAppleRunnerForClose, stopSessionAndroidNativePerfCapture, @@ -66,7 +66,7 @@ function shouldRetainAppleRunnerAfterClose(req: DaemonRequest, session: SessionS return ( isIosSimulator(session.device) && !req.flags?.shutdown && - !session.recording && + !session.screenRecording && !session.lease && !session.device.simulatorSetPath ); @@ -130,7 +130,16 @@ async function stopBestEffortSessionResources( attemptCleanup: CleanupRunner, ): Promise { // Recording overlay finalization needs the Apple runner. - await attemptCleanup('recording', () => stopSessionRecordingForTeardown(session)); + const currentSession = sessionStore.get(session.name) ?? session; + if (currentSession.screenRecording) { + await attemptCleanup('recording', () => + finishSessionScreenRecording({ + session: currentSession, + sessionName: session.name, + sessionStore, + }), + ); + } await attemptCleanup('app_log', () => stopSessionAppLog({ session, sessionStore })); await attemptCleanup('audio_probe', async () => { await stopSessionAudioProbe(session, 'session-close'); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index cae4f54081..29c055ccb8 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -24,7 +24,11 @@ import { getRequestSignal } from '../../request/cancel.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; import { errorResponse, requireCommandSupported } from './response.ts'; import { resolveImplicitSessionScope, sessionMatchesScope } from '../session-routing.ts'; -import { appLogAdmissionUse, networkAdmissionUse } from '@agent-device/contracts/platform'; +import { + appLogAdmissionUse, + networkAdmissionUse, + screenRecordingAdmissionUse, +} from '@agent-device/contracts/platform'; import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; export async function handleSessionInventoryCommands(params: { @@ -174,7 +178,7 @@ async function capabilitiesInventoryResponse(params: { }); if ('response' in resolution) return resolution.response; const { device } = resolution; - const [logsAvailable, networkAvailable] = params.bindDevice + const [logsAvailable, networkAvailable, recordingAvailable] = params.bindDevice ? await Promise.all([ params .bindDevice(device, appLogAdmissionUse) @@ -182,8 +186,11 @@ async function capabilitiesInventoryResponse(params: { params .bindDevice(device, networkAdmissionUse) .then((runtime) => runtime.facts.networkDump.available), + params + .bindDevice(device, screenRecordingAdmissionUse) + .then((runtime) => runtime.facts.screenRecordingStart.available), ]) - : [false, false]; + : [false, false, false]; return { ok: true, data: { @@ -193,7 +200,9 @@ async function capabilitiesInventoryResponse(params: { ? logsAvailable : command === 'network' ? networkAvailable - : isCommandSupportedOnDevice(command, device), + : command === 'record' + ? recordingAvailable + : isCommandSupportedOnDevice(command, device), ), }, }; diff --git a/src/daemon/handlers/session-replay-video-recording.ts b/src/daemon/handlers/session-replay-video-recording.ts index 1682802183..59321498a0 100644 --- a/src/daemon/handlers/session-replay-video-recording.ts +++ b/src/daemon/handlers/session-replay-video-recording.ts @@ -3,7 +3,10 @@ import type { DaemonOpenLifecycle, DaemonRequest, DaemonResponse } from '../type import type { SessionStore } from '../session-store.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { sleep } from '../../utils/timeouts.ts'; -import { handleRecordCommand } from './record-trace-recording.ts'; +import { handleRecordCommand } from './record-runtime.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; +import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; import { defaultRecordingPath, @@ -25,20 +28,24 @@ export function buildReplayTestVideoOpenLifecycle( type ReplayTestVideoRecordingParams = { req: DaemonRequest; sessionName: string; - logPath: string; sessionStore: SessionStore; artifactsDir: string | undefined; - tracePath: string | undefined; + bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; + screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; + requestScope: PlatformRequestScope; + retainDeviceExecutionLock(deviceId: string): Promise; + throwIfCanceled(): void; appendTimingEvent: (event: Record) => void; }; export async function startReplayTestVideoRecordingIfReady( params: ReplayTestVideoRecordingParams, ): Promise { - const { req, sessionName, logPath, sessionStore, artifactsDir, appendTimingEvent } = params; + const { req, sessionName, sessionStore, artifactsDir, appendTimingEvent } = params; if (req.flags?.recordVideo !== true) return undefined; const activeSession = sessionStore.get(sessionName); - if (!activeSession || activeSession.recording) return undefined; + if (!activeSession || activeSession.screenRecording) return undefined; const extension = recordingExtensionForPlatform(activeSession.device.platform); const videoPath = artifactsDir @@ -53,6 +60,7 @@ export async function startReplayTestVideoRecordingIfReady( phase: 'replay_test_video_recording_start', data: { session: sessionName, videoPath }, }); + params.throwIfCanceled(); const startResponse = await handleRecordCommand({ req: { token: req.token, @@ -64,7 +72,12 @@ export async function startReplayTestVideoRecordingIfReady( }, sessionName, sessionStore, - logPath, + bindDevice: params.bindDevice, + bindExactDevice: params.bindExactDevice, + admissionLedger: params.screenRecordingAdmissionLedger, + requestScope: params.requestScope, + retainDeviceExecutionLock: params.retainDeviceExecutionLock, + throwIfCanceled: params.throwIfCanceled, }); if (!startResponse.ok) { appendVideoTimingEvent(appendTimingEvent, { @@ -97,9 +110,9 @@ export async function finalizeReplayTestVideoRecording( artifactPaths: Set; }, ): Promise { - const { req, sessionName, logPath, sessionStore, artifactPaths, appendTimingEvent } = params; + const { req, sessionName, sessionStore, artifactPaths, appendTimingEvent } = params; if (req.flags?.recordVideo !== true) return undefined; - if (!sessionStore.get(sessionName)?.recording) return undefined; + if (!sessionStore.get(sessionName)?.screenRecording) return undefined; appendVideoTimingEvent(appendTimingEvent, { type: 'video_tail_start', @@ -120,7 +133,12 @@ export async function finalizeReplayTestVideoRecording( }, sessionName, sessionStore, - logPath, + bindDevice: params.bindDevice, + bindExactDevice: params.bindExactDevice, + admissionLedger: params.screenRecordingAdmissionLedger, + requestScope: params.requestScope, + retainDeviceExecutionLock: params.retainDeviceExecutionLock, + throwIfCanceled: params.throwIfCanceled, }); collectReplayActionArtifactPaths(stopResponse).forEach((entry) => artifactPaths.add(entry)); appendVideoTimingEvent(appendTimingEvent, { diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index a2ca7a1c50..078ce6984e 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -30,6 +30,9 @@ import { } from './session-test-shard-devices.ts'; import { toReplayTestAttemptOutcome, toReplayTestFinalizeFailure } from './session-test-outcome.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; +import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; import { buildReplayTestVideoOpenLifecycle, finalizeReplayTestVideoRecording, @@ -123,6 +126,12 @@ export async function handleSessionReplayCommands(params: { sessionStore: SessionStore; leaseRegistry: LeaseRegistry; invoke: DaemonInvokeFn; + bindDevice?: BindDeviceRuntime; + bindExactDevice?: BindExactDeviceRuntime; + screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; + requestScope?: PlatformRequestScope; + retainDeviceExecutionLock?: (deviceId: string) => Promise; + throwIfCanceled?: () => void; }): Promise { const { req, sessionName, logPath, sessionStore, leaseRegistry, invoke } = params; @@ -137,6 +146,13 @@ export async function handleSessionReplayCommands(params: { } if (req.command === 'test') { + const replayVideoRuntime = resolveReplayVideoRuntime(params); + if (req.flags?.recordVideo === true && replayVideoRuntime === undefined) { + return errorResponse( + 'COMMAND_FAILED', + 'Screen-recording runtime is not configured for replay video capture', + ); + } // `test` shares replay execution below, but replay-only flags must not fan // into every nested suite attempt. Keep the raw-daemon defense declarative // and aligned with the command grammar; the CLI rejects these earlier. @@ -191,16 +207,19 @@ export async function handleSessionReplayCommands(params: { shard, }); - const videoRecordingParams = { - req, - sessionName: testSessionName, - logPath, - sessionStore, - artifactsDir, - tracePath, - appendTimingEvent, - }; - const openLifecycle = buildReplayTestVideoOpenLifecycle(videoRecordingParams); + const videoRecordingParams = replayVideoRuntime + ? { + req, + sessionName: testSessionName, + sessionStore, + artifactsDir, + appendTimingEvent, + ...replayVideoRuntime, + } + : undefined; + const openLifecycle = videoRecordingParams + ? buildReplayTestVideoOpenLifecycle(videoRecordingParams) + : undefined; const replayResponse = await runReplayScriptFile({ req: { ...req, @@ -227,7 +246,9 @@ export async function handleSessionReplayCommands(params: { tracePath, onStep, invoke: async (nestedReq) => { - const startResponse = await startReplayTestVideoRecordingIfReady(videoRecordingParams); + const startResponse = videoRecordingParams + ? await startReplayTestVideoRecordingIfReady(videoRecordingParams) + : undefined; if (startResponse && !startResponse.ok) return startResponse; const response = captureArtifacts(await invoke(nestedReq)); return response; @@ -239,21 +260,21 @@ export async function handleSessionReplayCommands(params: { sessionName: testSessionName, artifactPaths, artifactsDir, - tracePath, appendTimingEvent, - }) => - toReplayTestFinalizeFailure( + }) => { + if (!replayVideoRuntime) return undefined; + return toReplayTestFinalizeFailure( await finalizeReplayTestVideoRecording({ req, sessionName: testSessionName, - logPath, sessionStore, artifactsDir, - tracePath, appendTimingEvent, artifactPaths, + ...replayVideoRuntime, }), - ), + ); + }, discoverSources: buildReplayTestSourceDiscovery(req.flags?.replayBackend), resolveShardTargets: buildReplayTestShardTargetResolver(req.flags), cleanupSession: async (testSessionName) => { @@ -282,6 +303,43 @@ export async function handleSessionReplayCommands(params: { return null; } +type ReplayVideoRuntime = Readonly<{ + bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; + screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; + requestScope: PlatformRequestScope; + retainDeviceExecutionLock(deviceId: string): Promise; + throwIfCanceled(): void; +}>; + +function resolveReplayVideoRuntime(params: { + bindDevice?: BindDeviceRuntime; + bindExactDevice?: BindExactDeviceRuntime; + screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; + requestScope?: PlatformRequestScope; + retainDeviceExecutionLock?: (deviceId: string) => Promise; + throwIfCanceled?: () => void; +}): ReplayVideoRuntime | undefined { + if ( + !params.bindDevice || + !params.bindExactDevice || + !params.screenRecordingAdmissionLedger || + !params.requestScope || + !params.retainDeviceExecutionLock || + !params.throwIfCanceled + ) { + return undefined; + } + return { + bindDevice: params.bindDevice, + bindExactDevice: params.bindExactDevice, + screenRecordingAdmissionLedger: params.screenRecordingAdmissionLedger, + requestScope: params.requestScope, + retainDeviceExecutionLock: params.retainDeviceExecutionLock, + throwIfCanceled: params.throwIfCanceled, + }; +} + /** * Translates a daemon `test` request into the scheduler's neutral request (#1478 P3b). * diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 2c12796552..130c873b69 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -46,8 +46,10 @@ import { LeaseRegistry } from '../lease-registry.ts'; import { PREPARE_REQUEST_TIMEOUT_MS } from '../../core/command-descriptor/timeout-policy.ts'; import { Deadline } from '../../utils/retry.ts'; import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; -import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; +import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; const PREPARE_IOS_RUNNER_TIMING_NOTE = 'Top-level prepare timing fields are diagnostic and may overlap; use timing.additiveParts for additive wall-clock phases.'; @@ -268,21 +270,29 @@ async function handleClipboardCommand(params: { return { ok: true, data: { platform: publicPlatformString(device), ...(result ?? {}) } }; } -type SessionCommandParams = { +type SessionCommandInput = { req: DaemonRequest; sessionName: string; logPath: string; sessionStore: SessionStore; - leaseRegistry: LeaseRegistry; + leaseRegistry?: LeaseRegistry; leaseLifecycleProvider?: LeaseLifecycleProvider; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; bindDevice?: BindDeviceRuntime; + bindExactDevice?: BindExactDeviceRuntime; appLogAdmissionLedger?: AppLogAdmissionLedger; + screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; + requestScope?: PlatformRequestScope; + retainDeviceExecutionLock?: (deviceId: string) => Promise; throwIfCanceled?: () => void; }; +type SessionCommandParams = Omit & { + leaseRegistry: LeaseRegistry; +}; + type SessionCommandHandler = (params: SessionCommandParams) => Promise; const handleSessionInventoryCommandGroup: SessionCommandHandler = async ({ @@ -326,6 +336,12 @@ const handleSessionReplayCommandGroup: SessionCommandHandler = async ({ leaseRegistry, invoke, invokeReplayAction, + bindDevice, + bindExactDevice, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock, + throwIfCanceled, }) => await handleSessionReplayCommands({ req, @@ -334,6 +350,12 @@ const handleSessionReplayCommandGroup: SessionCommandHandler = async ({ sessionStore, leaseRegistry, invoke: invokeReplayAction ?? invoke, + bindDevice, + bindExactDevice, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock, + throwIfCanceled, }); async function handleKeyboardCommand(params: SessionCommandParams): Promise { @@ -496,20 +518,9 @@ const SESSION_COMMAND_HANDLER_IMPLS = { }), } satisfies Record; -export async function handleSessionCommands(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - leaseRegistry?: LeaseRegistry; - leaseLifecycleProvider?: LeaseLifecycleProvider; - invoke: DaemonInvokeFn; - invokeReplayAction?: DaemonInvokeFn; - androidAdbExecutor?: AndroidAdbExecutor; - bindDevice?: BindDeviceRuntime; - appLogAdmissionLedger?: AppLogAdmissionLedger; - throwIfCanceled?: () => void; -}): Promise { +export async function handleSessionCommands( + params: SessionCommandInput, +): Promise { const { req, sessionName, @@ -521,7 +532,11 @@ export async function handleSessionCommands(params: { invokeReplayAction, androidAdbExecutor, bindDevice, + bindExactDevice, appLogAdmissionLedger, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock, throwIfCanceled, } = params; @@ -540,7 +555,11 @@ export async function handleSessionCommands(params: { invokeReplayAction, androidAdbExecutor, bindDevice, + bindExactDevice, appLogAdmissionLedger, + screenRecordingAdmissionLedger, + requestScope, + retainDeviceExecutionLock, throwIfCanceled, }); } diff --git a/src/daemon/handlers/trace-runtime.ts b/src/daemon/handlers/trace-runtime.ts new file mode 100644 index 0000000000..fb832774e8 --- /dev/null +++ b/src/daemon/handlers/trace-runtime.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { TraceCommandResult } from '@agent-device/contracts/recording'; +import { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { recordSessionAction } from './handler-utils.ts'; +import { errorResponse } from './response.ts'; + +export function handleTraceCommand(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; +}): DaemonResponse { + const action = (params.req.positionals?.[0] ?? '').toLowerCase(); + if (action !== 'start' && action !== 'stop') { + return errorResponse('INVALID_ARGS', 'trace requires start|stop'); + } + const session = params.sessionStore.get(params.sessionName); + if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session'); + return action === 'start' + ? startTrace(params.req, params.sessionStore, session) + : stopTrace(params.req, params.sessionStore, session); +} + +function startTrace( + req: DaemonRequest, + sessionStore: SessionStore, + session: SessionState, +): DaemonResponse { + if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); + const outPath = SessionStore.expandHome( + req.positionals?.[1] ?? sessionStore.defaultTracePath(session), + ); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.appendFileSync(outPath, ''); + session.trace = { outPath, startedAt: Date.now() }; + recordSessionAction(sessionStore, session, req, req.command, { action: 'start', outPath }); + return { + ok: true, + data: { trace: 'started', outPath } satisfies TraceCommandResult, + }; +} + +function stopTrace( + req: DaemonRequest, + sessionStore: SessionStore, + session: SessionState, +): DaemonResponse { + if (!session.trace) return errorResponse('INVALID_ARGS', 'no active trace'); + const outPath = relocateTraceOutput(session.trace.outPath, req.positionals?.[1]); + session.trace = undefined; + recordSessionAction(sessionStore, session, req, req.command, { action: 'stop', outPath }); + const clientOutPath = req.meta?.clientArtifactPaths?.outPath ?? outPath; + return { + ok: true, + data: { + trace: 'stopped', + outPath, + artifacts: [ + { + field: 'outPath', + artifactType: 'trace-log', + path: outPath, + localPath: clientOutPath, + fileName: path.basename(clientOutPath), + }, + ], + } satisfies TraceCommandResult, + }; +} + +function relocateTraceOutput(currentPath: string, requestedPath: string | undefined): string { + if (!requestedPath) return currentPath; + const resolved = SessionStore.expandHome(requestedPath); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + if (fs.existsSync(currentPath)) fs.renameSync(currentPath, resolved); + else fs.appendFileSync(resolved, ''); + return resolved; +} diff --git a/src/daemon/recording-gesture-events.ts b/src/daemon/recording-gesture-events.ts index bc14339f62..d14e4c48a0 100644 --- a/src/daemon/recording-gesture-events.ts +++ b/src/daemon/recording-gesture-events.ts @@ -1,4 +1,4 @@ -import type { RecordingGestureEvent } from './types.ts'; +import type { RecordingGestureEvent } from '@agent-device/contracts/platform'; import type { TouchReferenceFrame as ReferenceFrame } from './touch-reference-frame.ts'; import { readRecordingNumber, resolveRecordingDurationMs } from './recording-values.ts'; diff --git a/src/daemon/recording-gestures.ts b/src/daemon/recording-gestures.ts index 84c2ed7561..75a9c8e806 100644 --- a/src/daemon/recording-gestures.ts +++ b/src/daemon/recording-gestures.ts @@ -1,5 +1,6 @@ import { isIosFamily } from '@agent-device/kernel/device'; -import type { RecordingGestureEvent, SessionState } from './types.ts'; +import type { RecordingGestureEvent } from '@agent-device/contracts/platform'; +import type { SessionState } from './types.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { resolveGestureDurationMs, @@ -35,8 +36,9 @@ export function recordTouchVisualizationEvent( startedAtMs = Date.now(), finishedAtMs = Date.now(), ): void { - const recording = session.recording; - if (!recording) return; + const handle = session.screenRecording?.handle; + if (!handle) return; + const recording = handle.inspect(); const merged = { ...fallback, ...(result ?? {}) }; const reportedDurationMs = @@ -45,8 +47,7 @@ export function recordTouchVisualizationEvent( recordingStartedAt: recording.startedAt, gestureClockOriginAtMs: recording.gestureClockOriginAtMs, gestureClockOriginUptimeMs: recording.gestureClockOriginUptimeMs, - runnerStartedAtUptimeMs: - recording.platform === 'ios-device-runner' ? recording.runnerStartedAtUptimeMs : undefined, + runnerStartedAtUptimeMs: recording.runnerStartedAtUptimeMs, gestureStartUptimeMs: readRecordingNumber(merged.gestureStartUptimeMs), gestureEndUptimeMs: readRecordingNumber(merged.gestureEndUptimeMs), fallbackStartedAtMs: startedAtMs, @@ -75,7 +76,7 @@ export function recordTouchVisualizationEvent( referenceFrame, ); if (events.length === 0) return; - recording.gestureEvents.push(...events); + handle.appendGestureEvents(events); emitDiagnostic({ level: 'debug', phase: 'record_touch_visualization_event', diff --git a/src/daemon/recording-provider.ts b/src/daemon/recording-provider.ts deleted file mode 100644 index f32418b40d..0000000000 --- a/src/daemon/recording-provider.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { buildSimctlArgsForDevice } from '../platforms/apple/core/simctl.ts'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { runCmdBackground, type ExecBackgroundResult, type ExecResult } from '../utils/exec.ts'; -import { createScopedProvider } from '../utils/scoped-provider.ts'; - -export type RecordingProcess = { - child: Pick; - wait: Promise; -}; - -export type IosSimulatorRecordingRequest = { - device: DeviceInfo; - outPath: string; -}; - -export type RecordingProvider = { - startIosSimulatorRecording(request: IosSimulatorRecordingRequest): RecordingProcess; -}; - -const localRecordingProvider: RecordingProvider = { - startIosSimulatorRecording({ device, outPath }) { - return runCmdBackground( - 'xcrun', - buildSimctlArgsForDevice(device, ['io', device.id, 'recordVideo', outPath]), - { allowFailure: true }, - ); - }, -}; - -const recordingProviderScope = createScopedProvider( - localRecordingProvider, - createLocalRecordingProvider, -); - -export function createLocalRecordingProvider( - provider: Partial = {}, -): RecordingProvider { - return { - ...localRecordingProvider, - ...provider, - }; -} - -export function resolveRecordingProvider(provider?: RecordingProvider): RecordingProvider { - return recordingProviderScope.resolve(provider); -} - -export async function withRecordingProvider( - provider: RecordingProvider | undefined, - fn: () => Promise, -): Promise { - return await recordingProviderScope.run(provider, fn); -} diff --git a/src/daemon/recording-telemetry.ts b/src/daemon/recording-telemetry.ts index 34989810ec..b8a80dd20a 100644 --- a/src/daemon/recording-telemetry.ts +++ b/src/daemon/recording-telemetry.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { RecordingGestureEvent } from './types.ts'; +import type { RecordingGestureEvent } from '@agent-device/contracts/platform'; type RecordingTelemetryEnvelope = { version: 1; diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 95ac487ea5..519ad09747 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -45,7 +45,11 @@ import type { PlatformRuntimeOperations, PlatformRequestScope, } from '@agent-device/contracts/platform'; -import { createRequestRuntimeBindings, type BindDeviceRuntime } from './request-runtime-binding.ts'; +import { + createRequestRuntimeBindings, + type BindDeviceRuntime, + type BindExactDeviceRuntime, +} from './request-runtime-binding.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -66,6 +70,7 @@ export type RequestExecutionScope = AsyncDisposable & { runLocked(task: () => Promise): Promise; retainDeviceExecutionLock(deviceId: string): Promise; bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; throwIfCanceled(): void; }; @@ -76,6 +81,7 @@ export type LockedRequestScope = { existingSession: SessionState | undefined; retainDeviceExecutionLock(deviceId: string): Promise; bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; throwIfCanceled(): void; contextFromFlags( flags: CommandFlags | undefined, @@ -175,6 +181,15 @@ export async function createRequestExecutionScope(params: { { reason: 'runtime-gateway-missing' }, ); }), + bindExactDevice: + runtimeBindings?.bindExactDevice ?? + (async () => { + throw new AppError( + 'COMMAND_FAILED', + 'Device runtime gateway is not configured for this request scope', + { reason: 'runtime-gateway-missing' }, + ); + }), throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runAdmitted: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); @@ -294,17 +309,16 @@ export function prepareLockedRequestScope(params: { }; requestScopeFinalizers.set(scope, finalize); - if ( - existingSession?.recording?.invalidatedReason && - shouldBlockForInvalidRecording(scope.command) - ) { + const recordingInvalidatedReason = + existingSession?.screenRecording?.handle.inspect().invalidatedReason; + if (recordingInvalidatedReason && shouldBlockForInvalidRecording(scope.command)) { return { type: 'response', response: { ok: false, error: { code: 'COMMAND_FAILED', - message: existingSession.recording.invalidatedReason, + message: recordingInvalidatedReason, }, }, }; @@ -334,6 +348,7 @@ export function prepareLockedRequestScope(params: { existingSession, retainDeviceExecutionLock: scope.retainDeviceExecutionLock, bindDevice: scope.bindDevice, + bindExactDevice: scope.bindExactDevice, throwIfCanceled: scope.throwIfCanceled, contextFromFlags, handlerContextFromFlags: (flags, appBundleId, traceLogPath) => diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index 3aa666c9a4..84f5ae3858 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -213,7 +213,7 @@ async function ensureGenericCommandReady( if ( session.device.platform !== 'android' || isActiveProviderDevice(session.device) || - !session.recording || + !session.screenRecording || platformCommand === 'record' || (await recoverAndroidBlockingSystemDialog({ session })).status !== 'failed' ) { diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 0d061a8aaf..7dcc6e9695 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -9,8 +9,10 @@ import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; import type { LeaseRegistry } from './lease-registry.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './types.ts'; -import type { BindDeviceRuntime } from './request-runtime-binding.ts'; +import type { BindDeviceRuntime, BindExactDeviceRuntime } from './request-runtime-binding.ts'; import type { AppLogAdmissionLedger } from './app-log-admission-ledger.ts'; +import type { ScreenRecordingAdmissionLedger } from './screen-recording-admission-ledger.ts'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -26,7 +28,11 @@ type RequestHandlerChainParams = { invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; appLogAdmissionLedger?: AppLogAdmissionLedger; + screenRecordingAdmissionLedger: ScreenRecordingAdmissionLedger; + requestScope: PlatformRequestScope; + retainDeviceExecutionLock(deviceId: string): Promise; throwIfCanceled(): void; contextFromFlags: ( flags: CommandFlags | undefined, @@ -123,7 +129,11 @@ async function runSessionHandler( invokeReplayAction: params.invokeReplayAction, androidAdbExecutor: params.androidAdbExecutor, bindDevice: params.bindDevice, + bindExactDevice: params.bindExactDevice, appLogAdmissionLedger: params.appLogAdmissionLedger, + screenRecordingAdmissionLedger: params.screenRecordingAdmissionLedger, + requestScope: params.requestScope, + retainDeviceExecutionLock: params.retainDeviceExecutionLock, throwIfCanceled: params.throwIfCanceled, }), ); @@ -174,6 +184,12 @@ async function runRecordTraceHandler( sessionName: params.sessionName, sessionStore: params.sessionStore, logPath: params.logPath, + bindDevice: params.bindDevice, + bindExactDevice: params.bindExactDevice, + admissionLedger: params.screenRecordingAdmissionLedger, + requestScope: params.requestScope, + retainDeviceExecutionLock: params.retainDeviceExecutionLock, + throwIfCanceled: params.throwIfCanceled, }), ); } diff --git a/src/daemon/request-platform-providers.ts b/src/daemon/request-platform-providers.ts index 0fc462a7f3..e2abef9ef5 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/daemon/request-platform-providers.ts @@ -12,8 +12,9 @@ import type { LinuxToolProvider } from '../platforms/linux/tool-provider.ts'; import type { VegaToolProvider } from '../platforms/vega/tool-provider.ts'; import { withWebProvider, type WebProvider } from '../platforms/web/provider.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppleSimulatorScreenRecordingTransport } from '../platform-runtime-screen-recording-apple-transport.ts'; +import type { AppleRunnerScreenRecordingTransport } from '../platform-runtime-screen-recording-apple-runner-transport.ts'; import { hasExplicitDeviceSelector } from './device-selector-intent.ts'; -import { withRecordingProvider, type RecordingProvider } from './recording-provider.ts'; import type { DaemonRequest, SessionState } from './types.ts'; import { resolveProviderDeviceResolutionIntent } from './daemon-command-registry.ts'; @@ -42,7 +43,13 @@ export type VegaToolProviderResolver = PlatformProviderResolver; -export type RecordingProviderResolver = PlatformProviderResolver; +export type AppleSimulatorScreenRecordingTransportResolver = PlatformProviderResolver< + AppleSimulatorScreenRecordingTransport | undefined +>; + +export type AppleRunnerScreenRecordingTransportResolver = PlatformProviderResolver< + AppleRunnerScreenRecordingTransport | undefined +>; export type PlatformProviderResolvers = { androidAdbProvider?: AndroidAdbProviderResolver; @@ -51,7 +58,8 @@ export type PlatformProviderResolvers = { linuxToolProvider?: LinuxToolProviderResolver; vegaToolProvider?: VegaToolProviderResolver; webProvider?: WebProviderResolver; - recordingProvider?: RecordingProviderResolver; + appleRunnerScreenRecordingTransport?: AppleRunnerScreenRecordingTransportResolver; + appleSimulatorScreenRecordingTransport?: AppleSimulatorScreenRecordingTransportResolver; }; // Compile-time: every gated key is a real resolver key (so the facet can never name a @@ -119,8 +127,11 @@ type ResolvedRequestPlatformProviders = { web?: { provider?: WebProvider; }; - recording?: { - provider?: RecordingProvider; + appleSimulatorScreenRecording?: { + provider?: AppleSimulatorScreenRecordingTransport; + }; + appleRunnerScreenRecording?: { + provider?: AppleRunnerScreenRecordingTransport; }; }; @@ -186,15 +197,17 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ if (!scopedProviders.appleRunner?.provider) return; const { withAppleRunnerProvider } = await import('../platforms/apple/core/runner/runner-provider.ts'); - appendRequestProviderWrapper(wrappers, scopedProviders.appleRunner, (provider, task) => - withAppleRunnerProvider( - provider, - { - deviceId: scopedProviders.appleRunner?.deviceId ?? '', - requestId: scopedProviders.appleRunner?.requestId, - }, - task, - ), + const resolved = scopedProviders.appleRunner; + wrappers.push( + async (task) => + await withAppleRunnerProvider( + resolved.provider, + { + deviceId: resolved.deviceId ?? '', + requestId: resolved.requestId, + }, + task, + ), ); }, }, @@ -253,15 +266,35 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ }, }, { - resolverKey: 'recordingProvider', + resolverKey: 'appleRunnerScreenRecordingTransport', resolve(providers, context) { - const recordingProvider = providers.recordingProvider; - if (!recordingProvider) return {}; - return { recording: { provider: recordingProvider(context) } }; + const resolver = providers.appleRunnerScreenRecordingTransport; + if (!resolver) return {}; + return { appleRunnerScreenRecording: { provider: resolver(context) } }; }, async appendWrapper(scopedProviders, wrappers) { - if (!scopedProviders.recording?.provider) return; - appendRequestProviderWrapper(wrappers, scopedProviders.recording, withRecordingProvider); + const runner = scopedProviders.appleRunnerScreenRecording?.provider; + if (!runner && !scopedProviders.appleRunner?.provider) return; + const { withAppleRunnerScreenRecordingTransport } = + await import('../platform-runtime-screen-recording-apple-runner-transport.ts'); + wrappers.push(async (task) => await withAppleRunnerScreenRecordingTransport(runner, task)); + }, + }, + { + resolverKey: 'appleSimulatorScreenRecordingTransport', + resolve(providers, context) { + const resolver = providers.appleSimulatorScreenRecordingTransport; + if (!resolver) return {}; + return { appleSimulatorScreenRecording: { provider: resolver(context) } }; + }, + async appendWrapper(scopedProviders, wrappers) { + const simulator = scopedProviders.appleSimulatorScreenRecording?.provider; + if (!simulator && !scopedProviders.appleRunner?.provider) return; + const { withAppleSimulatorScreenRecordingTransport } = + await import('../platform-runtime-screen-recording-apple-transport.ts'); + wrappers.push( + async (task) => await withAppleSimulatorScreenRecordingTransport(simulator, task), + ); }, }, ] satisfies RequestPlatformProviderDescriptor[]; diff --git a/src/daemon/request-recording-health.ts b/src/daemon/request-recording-health.ts index 37d4b7f3db..b7b38348e5 100644 --- a/src/daemon/request-recording-health.ts +++ b/src/daemon/request-recording-health.ts @@ -6,29 +6,29 @@ export function refreshRecordingHealth(session: SessionState): void { if (!recordingRequiresRunnerHealth(session)) { return; } - const recording = session.recording!; + const recording = session.screenRecording!.handle; + const state = recording.inspect(); const snapshot = getRunnerSessionSnapshot(session.device.id); - if (!recording.runnerSessionId) { + if (!state.runnerSessionId) { if (snapshot?.alive) { - recording.runnerSessionId = snapshot.sessionId; + recording.setRunnerSessionId(snapshot.sessionId); } return; } if (!snapshot?.alive) { - recording.invalidatedReason ??= 'iOS runner session exited during recording'; + recording.invalidate('iOS runner session exited during recording'); return; } - if (snapshot.sessionId !== recording.runnerSessionId) { - recording.invalidatedReason ??= 'iOS runner session restarted during recording'; + if (snapshot.sessionId !== state.runnerSessionId) { + recording.invalidate('iOS runner session restarted during recording'); } } function recordingRequiresRunnerHealth(session: SessionState): boolean { - const recording = session.recording; + const recording = session.screenRecording?.handle.inspect(); if (!recording || !isIosFamily(session.device)) return false; - if (recording.platform === 'ios') return false; - return recording.showTouches !== false; + return recording.backend === 'runner AVAssetWriter' && recording.showTouches !== false; } diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 2b71e8b52a..a53853192b 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -23,10 +23,11 @@ import { errorResponse, noActiveSessionError } from './handlers/response.ts'; import { type AndroidAdbProviderResolver, type AppleRunnerProviderResolver, + type AppleRunnerScreenRecordingTransportResolver, type AppleToolProviderResolver, type LinuxToolProviderResolver, type RequestPlatformProviderScope, - type RecordingProviderResolver, + type AppleSimulatorScreenRecordingTransportResolver, type VegaToolProviderResolver, type WebProviderResolver, withRequestPlatformProviderScope, @@ -61,6 +62,10 @@ import { createAppLogAdmissionLedger, type AppLogAdmissionLedger, } from './app-log-admission-ledger.ts'; +import { + createScreenRecordingAdmissionLedger, + type ScreenRecordingAdmissionLedger, +} from './screen-recording-admission-ledger.ts'; // --------------------------------------------------------------------------- // Request handler API @@ -74,14 +79,16 @@ export type RequestRouterDeps = { leaseRegistry: LeaseRegistry; androidAdbProvider?: AndroidAdbProviderResolver; appleRunnerProvider?: AppleRunnerProviderResolver; + appleRunnerScreenRecordingTransport?: AppleRunnerScreenRecordingTransportResolver; appleToolProvider?: AppleToolProviderResolver; linuxToolProvider?: LinuxToolProviderResolver; vegaToolProvider?: VegaToolProviderResolver; webProvider?: WebProviderResolver; - recordingProvider?: RecordingProviderResolver; + appleSimulatorScreenRecordingTransport?: AppleSimulatorScreenRecordingTransportResolver; deviceInventoryGateways: ComposedDeviceInventoryGateways; deviceRuntimeGateway: DeviceRuntimeGateway; appLogAdmissionLedger?: AppLogAdmissionLedger; + screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; providerRuntimeIds?: readonly string[]; providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; @@ -102,14 +109,16 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { token, androidAdbProvider, appleRunnerProvider, + appleRunnerScreenRecordingTransport, appleToolProvider, linuxToolProvider, vegaToolProvider, webProvider, - recordingProvider, + appleSimulatorScreenRecordingTransport, deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger = createAppLogAdmissionLedger(), + screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger(), providerRuntimeIds, providerRuntimeRequiredIds, leaseLifecycleProvider, @@ -225,6 +234,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providers: { androidAdbProvider, appleRunnerProvider, + appleRunnerScreenRecordingTransport, appleToolProvider, linuxToolProvider, vegaToolProvider, @@ -233,7 +243,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { (shouldUseDefaultWebProvider(lockedScope) ? createDefaultWebProvider(stateDir, sessionStore) : undefined), - recordingProvider, + appleSimulatorScreenRecordingTransport, }, }, executeLocked, @@ -265,7 +275,11 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { : undefined, androidAdbExecutor: providerScope.androidAdbExecutor, bindDevice: lockedScope.bindDevice, + bindExactDevice: lockedScope.bindExactDevice, appLogAdmissionLedger, + screenRecordingAdmissionLedger, + requestScope: createPlatformRequestScope(lockedScope.req), + retainDeviceExecutionLock: lockedScope.retainDeviceExecutionLock, throwIfCanceled: lockedScope.throwIfCanceled, contextFromFlags: lockedScope.handlerContextFromFlags, }); diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts index fe0da1d053..3308cf9cea 100644 --- a/src/daemon/request-runtime-binding.ts +++ b/src/daemon/request-runtime-binding.ts @@ -7,7 +7,9 @@ import { type DeviceRuntimeGateway, type PlatformRuntimeOperations, type PlatformRequestScope, + type ResourceOwnershipFence, type RuntimeOperationKey, + type RuntimeOwnerRef, type RuntimeUse, } from '@agent-device/contracts/platform'; @@ -22,9 +24,24 @@ export type BindDeviceRuntime = < use: RuntimeUse, ) => Promise>>; +export type BindExactDeviceRuntime = < + const Required extends readonly RuntimeOperationKey[], + const Preferred extends readonly Exclude< + RuntimeOperationKey, + Required[number] + >[], +>( + device: DeviceInfo, + owner: RuntimeOwnerRef, + fence: ResourceOwnershipFence, + use: RuntimeUse, + scope: PlatformRequestScope, +) => Promise>>; + export type RequestRuntimeBindings = AsyncDisposable & Readonly<{ bindDevice: BindDeviceRuntime; + bindExactDevice: BindExactDeviceRuntime; }>; /** Private broad-binding cache; handlers receive only the selected projection. */ @@ -55,8 +72,47 @@ export function createRequestRuntimeBindings(params: { return narrowDeviceBinding(binding, use); }; + const bindExactDevice: BindExactDeviceRuntime = async (device, owner, fence, use, scope) => { + const published = await params.gateway.bind({ + device, + intent: { kind: 'exact-owner', owner, fence }, + scope, + }); + const binding = await adoptExactBinding(cleanups, published, scope); + return narrowDeviceBinding(binding, use); + }; + return { bindDevice, + bindExactDevice, [Symbol.asyncDispose]: async () => await cleanups[Symbol.asyncDispose](), }; } + +async function adoptExactBinding( + cleanups: AsyncCleanupStack, + binding: DeviceBinding, + scope: PlatformRequestScope, +): Promise> { + try { + return cleanups.use(binding); + } catch (primaryError) { + try { + await binding[Symbol.asyncDispose](); + } catch (cleanupError) { + scope.diagnostics.emit({ + level: 'error', + phase: 'request_runtime_late_binding_cleanup_failed', + data: { + error: errorMessage(cleanupError), + primaryError: errorMessage(primaryError), + }, + }); + } + throw primaryError; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/daemon/screen-recording-admission-ledger.ts b/src/daemon/screen-recording-admission-ledger.ts new file mode 100644 index 0000000000..e5ef4d6182 --- /dev/null +++ b/src/daemon/screen-recording-admission-ledger.ts @@ -0,0 +1,10 @@ +import { + createDurableCaptureAdmissionLedger, + type DurableCaptureAdmissionLedger, +} from './durable-capture-admission-ledger.ts'; + +export type ScreenRecordingAdmissionLedger = DurableCaptureAdmissionLedger; + +export function createScreenRecordingAdmissionLedger(): ScreenRecordingAdmissionLedger { + return createDurableCaptureAdmissionLedger({ displayName: 'screen recording' }); +} diff --git a/src/daemon/screen-recording-resource-store.ts b/src/daemon/screen-recording-resource-store.ts new file mode 100644 index 0000000000..f33de339b5 --- /dev/null +++ b/src/daemon/screen-recording-resource-store.ts @@ -0,0 +1,8 @@ +import { SCREEN_RECORDING_RESOURCE_KIND } from '@agent-device/contracts/platform'; +import { createDurableCaptureResourceStore } from './durable-capture-resource-store.ts'; + +export const screenRecordingResourceStore = createDurableCaptureResourceStore({ + resourceKind: SCREEN_RECORDING_RESOURCE_KIND, + fileName: 'screen-recording.resource.json', + displayName: 'screen recording', +}); diff --git a/src/daemon/screen-recording-session-resource.ts b/src/daemon/screen-recording-session-resource.ts new file mode 100644 index 0000000000..539cd33d16 --- /dev/null +++ b/src/daemon/screen-recording-session-resource.ts @@ -0,0 +1,85 @@ +import type { + DurableResourceEnvelope, + PendingTransferGuard, + PlatformRequestScope, + ResourceOwnershipFence, + RuntimeOwnerRef, + ScreenRecordingCompletion, + ScreenRecordingLiveHandle, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { DurableCaptureRecoveryControl } from './durable-capture-recovery-authority.ts'; +import { createDurableCaptureResource } from './durable-capture-resource.ts'; +import type { ScreenRecordingAdmissionLedger } from './screen-recording-admission-ledger.ts'; +import { screenRecordingResourceStore } from './screen-recording-resource-store.ts'; +import type { SessionStore } from './session-store.ts'; +import type { SessionState } from './types.ts'; + +export const screenRecordingDurableResource = createDurableCaptureResource< + 'screen-recording', + ScreenRecordingLiveHandle, + ScreenRecordingCompletion +>({ + resourceKind: 'screen-recording', + displayName: 'screen recording', + store: screenRecordingResourceStore, + sessionSlot: { + read: (session) => session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }, + completionMetadata: (completion) => ({ + backend: completion.backend, + outputPath: completion.outPath, + startedAt: completion.startedAt, + completedAt: completion.completedAt, + scope: completion.scope, + showTouches: completion.showTouches, + recordOnlySession: completion.recordOnlySession, + }), + messages: { + noActive: 'no active recording', + cleanupPendingHint: + 'Keep screen-recording.resource.json and retry stop through its exact runtime owner.', + }, +}); + +export function adoptStartedScreenRecording(params: { + admissionLedger: ScreenRecordingAdmissionLedger; + session: SessionState; + sessionName: string; + sessionStore: SessionStore; + device: DeviceInfo; + owner: RuntimeOwnerRef; + fence: ResourceOwnershipFence; + pendingHandle: PendingTransferGuard; + envelope: DurableResourceEnvelope<'screen-recording'>; + throwIfCanceled(): void; +}): Promise { + return screenRecordingDurableResource.adoptStarted(params); +} + +export function finishLiveScreenRecording(params: { + session: SessionState; + sessionName: string; + sessionStore: SessionStore; +}): Promise { + return screenRecordingDurableResource.finishLive(params); +} + +export function finishRecoveredScreenRecording(params: { + resourcePath: string; + scope: PlatformRequestScope; + acquireControl( + envelope: DurableResourceEnvelope<'screen-recording'>, + scope: PlatformRequestScope, + ): Promise< + DurableCaptureRecoveryControl< + 'screen-recording', + ScreenRecordingLiveHandle, + ScreenRecordingCompletion + > + >; + deadlineMs?: number; +}): Promise { + return screenRecordingDurableResource.finishRecovered(params); +} diff --git a/src/daemon/server/daemon-idle-reap.test.ts b/src/daemon/server/daemon-idle-reap.test.ts index 28dc079c2d..16ceaa5563 100644 --- a/src/daemon/server/daemon-idle-reap.test.ts +++ b/src/daemon/server/daemon-idle-reap.test.ts @@ -13,6 +13,7 @@ import { resolveDaemonIdleReapMs, } from './daemon-idle-reap.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; let stateDir: string; let sessionStore: SessionStore; @@ -61,20 +62,14 @@ test('hasActiveRecording is true only when a stored session carries a recording' sessionStore.set('default', makeSession()); assert.equal(hasActiveRecording(sessionStore), false); - sessionStore.set( - 'default', - makeSession({ - recording: { - platform: 'ios', - outPath: '/tmp/demo.mp4', - startedAt: Date.now(), - showTouches: false, - gestureEvents: [], - child: { kill: () => true }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }), - ); + const recordingSession = makeSession(); + recordingSession.screenRecording = makeTestScreenRecordingResource(recordingSession, { + backend: 'simctl recordVideo', + outPath: '/tmp/demo.mp4', + startedAt: Date.now(), + showTouches: false, + }); + sessionStore.set('default', recordingSession); assert.equal(hasActiveRecording(sessionStore), true); }); @@ -190,20 +185,14 @@ test('idle reap does not fire while a session is open', async () => { test('idle reap does not fire while a recording is active', async () => { vi.useFakeTimers(); let reaped = 0; - sessionStore.set( - 'default', - makeSession({ - recording: { - platform: 'ios', - outPath: '/tmp/demo.mp4', - startedAt: Date.now(), - showTouches: false, - gestureEvents: [], - child: { kill: () => true }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - }, - }), - ); + const recordingSession = makeSession(); + recordingSession.screenRecording = makeTestScreenRecordingResource(recordingSession, { + backend: 'simctl recordVideo', + outPath: '/tmp/demo.mp4', + startedAt: Date.now(), + showTouches: false, + }); + sessionStore.set('default', recordingSession); const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, diff --git a/src/daemon/server/daemon-idle-reap.ts b/src/daemon/server/daemon-idle-reap.ts index 19ad08c687..de7313badb 100644 --- a/src/daemon/server/daemon-idle-reap.ts +++ b/src/daemon/server/daemon-idle-reap.ts @@ -50,12 +50,12 @@ function isReapableRepairSession(session: SessionState): boolean { return isUncommittedRepairSession(session); } -// Recording lifecycle is session-scoped (session.recording), so a recording +// Recording lifecycle is session-scoped (session.screenRecording), so a recording // only ever exists alongside an open session. Kept as an explicit, // independently testable guard so a future recording path that outlives its // session cannot silently lose this protection. export function hasActiveRecording(sessionStore: SessionStore): boolean { - return sessionStore.toArray().some((session) => Boolean(session.recording)); + return sessionStore.toArray().some((session) => Boolean(session.screenRecording)); } export function isDaemonIdle(params: { diff --git a/src/daemon/server/daemon-runtime-recording-teardown.test.ts b/src/daemon/server/daemon-runtime-recording-teardown.test.ts index 949f07a2a7..4ab9a4280d 100644 --- a/src/daemon/server/daemon-runtime-recording-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-recording-teardown.test.ts @@ -1,27 +1,18 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + type ScreenRecordingCompletion, + type ScreenRecordingLiveHandle, +} from '@agent-device/contracts/platform'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; - -vi.mock('../../utils/exec.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })), - }; -}); -vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) }; -}); - +import { screenRecordingResourceStore } from '../screen-recording-resource-store.ts'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; -import { IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS } from '../handlers/record-trace-ios-simulator.ts'; import { resolveDaemonSessionTeardownTimeoutMs, + SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS, teardownDaemonSessionForShutdown, } from './daemon-runtime.ts'; @@ -30,88 +21,109 @@ afterEach(() => { vi.clearAllMocks(); }); -function makeRecordingSession(name: string): SessionState { - const session: SessionState = { +function makeRecordingSession(params: { + name: string; + sessionStore: SessionStore; + finish: ScreenRecordingLiveHandle['finish']; +}): SessionState { + const { name, sessionStore, finish } = params; + const device = { + platform: 'apple' as const, + id: 'sim-udid-shutdown', + name: 'iPhone 15', + kind: 'simulator' as const, + booted: true, + }; + const outPath = path.join(sessionStore.resolveSessionDir(name), 'recording.mp4'); + const handle: ScreenRecordingLiveHandle = { + inspect: () => ({ + backend: 'simctl recordVideo', + outPath, + startedAt: Date.now() - 5_000, + scope: 'app', + showTouches: false, + recordOnlySession: false, + gestureEvents: [], + }), + appendGestureEvents: () => {}, + setTouchReferenceFrame: () => {}, + setRunnerSessionId: () => {}, + invalidate: () => {}, + finish, + forceCleanup: async () => ({ status: 'cleaned' }), + [Symbol.asyncDispose]: async () => {}, + }; + const envelope = createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: name, + device: { id: device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, + owner: localRuntimeOwner('apple'), + fence: { token: `${name}-fence`, generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: { recordingId: name } }, + metadata: { phase: 'active' }, + }); + screenRecordingResourceStore.write( + screenRecordingResourceStore.resolvePath(sessionStore.resolveSessionDir(name)), + envelope, + ); + return { name, - device: { - platform: 'apple', - id: 'sim-udid-shutdown', - name: 'iPhone 15', - kind: 'simulator', - booted: true, - }, + device, createdAt: Date.now(), actions: [], + screenRecording: { handle, envelope }, }; - session.recording = { - platform: 'ios', - outPath: path.join(os.tmpdir(), `${name}.mp4`), - startedAt: Date.now() - 5_000, - showTouches: false, - gestureEvents: [], - recorderPid: 4242, - // Slow direct-handle path: the recorder never exits on its own, so the stop - // must run the full SIGINT -> SIGTERM -> SIGKILL escalation. - child: { kill: vi.fn(), pid: 4242 }, - wait: new Promise(() => {}), - }; - return session; } -test('daemon session teardown budget extends past the recorder-stop escalation for recording sessions', () => { - const session = makeRecordingSession('budget-session'); +test('daemon session teardown budget extends for an active durable recording', () => { + const root = mkdtempForTestSync('agent-device-shutdown-recording-budget-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const session = makeRecordingSession({ + name: 'budget-session', + sessionStore, + finish: async () => ({ status: 'cleanup-pending', reason: 'transport-failed' }), + }); const withRecording = resolveDaemonSessionTeardownTimeoutMs(session); - session.recording = undefined; + session.screenRecording = undefined; const withoutRecording = resolveDaemonSessionTeardownTimeoutMs(session); - // The base budget alone is shorter than the recorder-stop escalation, so a - // recording session must get the base budget PLUS the full escalation. - expect(withoutRecording).toBeLessThan(IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS); - expect(withRecording - withoutRecording).toBeGreaterThanOrEqual( - IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS, - ); + expect(withRecording - withoutRecording).toBe(SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS); }); -test('daemon shutdown lets a slow recorder run its full stop escalation instead of timing out', async () => { +test('daemon shutdown awaits durable recording finalization inside its extended budget', async () => { vi.useFakeTimers(); - const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true); const root = mkdtempForTestSync('agent-device-shutdown-recording-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - const sessionName = 'shutdown-slow-recorder-session'; - const session = makeRecordingSession(sessionName); - const recording = session.recording; - const kill = recording?.platform === 'ios' ? vi.mocked(recording.child.kill) : undefined; - sessionStore.set(sessionName, session); + const completion: ScreenRecordingCompletion = { + backend: 'simctl recordVideo', + outPath: path.join(root, 'recording.mp4'), + startedAt: 1, + completedAt: 2, + scope: 'app', + showTouches: false, + recordOnlySession: false, + }; + const finish = vi.fn( + async () => + await new Promise<{ status: 'completed'; result: ScreenRecordingCompletion }>((resolve) => { + setTimeout(() => resolve({ status: 'completed', result: completion }), 10_000); + }), + ); + const session = makeRecordingSession({ name: 'shutdown-recording', sessionStore, finish }); + sessionStore.set(session.name, session); const stderrChunks: string[] = []; - const stderr = { write: (chunk: string) => stderrChunks.push(chunk) }; - try { - const teardownPromise = teardownDaemonSessionForShutdown({ - session, - sessionStore, - stateDir: root, - stderr, - }); - // Advance past the full escalation (direct 5s wait + 3 x 2s retries) but - // NOT past the extended per-session budget: the teardown must win the race. - await vi.advanceTimersByTimeAsync(12_000); - await teardownPromise; + const teardown = teardownDaemonSessionForShutdown({ + session, + sessionStore, + stateDir: root, + stderr: { write: (chunk) => stderrChunks.push(chunk) }, + }); + await vi.advanceTimersByTimeAsync(10_000); + await teardown; - // The recorder was escalated all the way to SIGKILL before shutdown moved on. - expect(kill?.mock.calls.map((call) => call[0])).toEqual(['SIGINT', 'SIGTERM', 'SIGKILL']); - expect(processKill.mock.calls.map((call) => call[1])).toEqual([ - 'SIGINT', - 'SIGTERM', - 'SIGKILL', - 0, - ]); - // The extended budget covered the escalation: teardown completed (surfacing - // the recorder-stop failure) rather than being abandoned by the timeout. - expect(stderrChunks.join('')).toMatch(/Daemon session teardown error .*recording/); - expect(stderrChunks.join('')).not.toMatch(/timed out/); - expect(sessionStore.get(sessionName)).toBeUndefined(); - } finally { - processKill.mockRestore(); - fs.rmSync(root, { recursive: true, force: true }); - } + expect(finish).toHaveBeenCalledOnce(); + expect(stderrChunks.join('')).not.toMatch(/timed out/); + expect(sessionStore.get(session.name)).toBeUndefined(); }); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index a20f69c3ca..df1d74e034 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -18,7 +18,6 @@ import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts' import { clearDaemonShutdownReport, writeDaemonShutdownReport } from '../daemon-shutdown-report.ts'; import { createRequestHandler } from '../request-router.ts'; import { stopSessionAppLog, teardownSessionResources } from '../session-teardown.ts'; -import { IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS } from '../handlers/record-trace-ios-simulator.ts'; import { closeDaemonServers } from './server-shutdown.ts'; import type { DaemonInvokeFn, SessionState } from '../types.ts'; import { createDaemonIdleReap } from './daemon-idle-reap.ts'; @@ -63,8 +62,10 @@ import { } from '../app-log-resource-recovery.ts'; import { createDaemonRecoveryPlatformScope } from '../platform-request-scope.ts'; import { createAppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; +import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; +export const SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS = 11_000; const DAEMON_SESSION_LEASE_RELEASE_TIMEOUT_MS = 1_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; const DAEMON_PROVIDER_RELEASE_DRAIN_TIMEOUT_MS = 2_000; @@ -74,24 +75,20 @@ type WritableOutput = { }; /** - * Per-session teardown budget for daemon shutdown. The base budget is enough - * for ordinary resource cleanup, but a session with an active recording must be - * allowed to run the full recorder-stop escalation (direct-handle SIGINT wait - * plus PID-based SIGINT/SIGTERM/SIGKILL retries), which alone exceeds the base - * budget — racing that against the base 5s would let shutdown advance to - * process exit exactly when fallback cleanup begins, orphaning the recorder - * with an unfinalized mp4. The recording budget EXTENDS the base one so the - * session's remaining cleanup steps keep their usual allowance. + * Per-session teardown budget for daemon shutdown. The base budget covers ordinary resources; + * an active durable recording gets an additional owner-finalization budget so the daemon cannot + * exit while its runtime handle is still producing the terminal media artifact. The base portion + * remains available to cleanup steps that follow recording finalization. */ export function resolveDaemonSessionTeardownTimeoutMs(session: SessionState): number { - if (!session.recording) return DAEMON_SESSION_TEARDOWN_TIMEOUT_MS; - return DAEMON_SESSION_TEARDOWN_TIMEOUT_MS + IOS_SIMULATOR_RECORDING_STOP_ESCALATION_BUDGET_MS; + if (!session.screenRecording) return DAEMON_SESSION_TEARDOWN_TIMEOUT_MS; + return DAEMON_SESSION_TEARDOWN_TIMEOUT_MS + SCREEN_RECORDING_SESSION_TEARDOWN_BUDGET_MS; } /** * Daemon-shutdown teardown of one session: bounded resource cleanup (budget * from {@link resolveDaemonSessionTeardownTimeoutMs}, resolved BEFORE cleanup - * starts since finalizing the recording detaches `session.recording`), then the + * starts since finalizing the recording detaches `session.screenRecording`), then the * repair-commit finalization and session deletion. Cleanup failures — including * a recorder that could not be finalized — surface on stderr instead of being * silently swallowed. @@ -125,6 +122,7 @@ export async function teardownDaemonSessionForShutdown(params: { appLog: 'already-settled', session: sessionAfterAppLog, sessionName: session.name, + sessionStore, stateDir, }).then( () => true, @@ -204,6 +202,7 @@ export async function startDaemonRuntime( const sessionStore = new SessionStore(sessionsDir); const appLogAdmissionLedger = createAppLogAdmissionLedger(); + const screenRecordingAdmissionLedger = createScreenRecordingAdmissionLedger(); const version = readVersion(); const token = crypto.randomBytes(24).toString('hex'); const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; @@ -256,7 +255,10 @@ export async function startDaemonRuntime( deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger, + screenRecordingAdmissionLedger, appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, + appleRunnerScreenRecordingTransport: + providerRuntimeProviders.appleRunnerScreenRecordingTransport, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, diff --git a/src/daemon/session-recovery-hints.ts b/src/daemon/session-recovery-hints.ts index a7d6ccd2b5..4209f4e921 100644 --- a/src/daemon/session-recovery-hints.ts +++ b/src/daemon/session-recovery-hints.ts @@ -15,7 +15,7 @@ export function buildSessionRecoveryHint( context: SessionRecoveryContext, ): string { // Active recording state controls user recovery text; record-only ownership controls cleanup. - if (session.recording) { + if (session.screenRecording) { return buildRecordingSessionRecoveryHint(session, context); } return buildOpenSessionRecoveryHint(session, context); diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index b704d8b514..371867af5b 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -9,11 +9,11 @@ import { stopAndroidSnapshotHelperSessionForDevice } from '../platforms/android/ import { restoreAndroidTestIme } from '../platforms/android/ime-lifecycle.ts'; import { cleanupRetainedMaterializedPathsForSession } from './materialized-path-registry.ts'; import { stopSessionAudioProbe } from './audio-probe.ts'; -import { stopSessionRecordingForTeardown } from './handlers/record-trace-recording.ts'; import type { SessionState } from './types.ts'; import type { SessionStore } from './session-store.ts'; import { forceCleanupSessionAppLog } from './app-log-session-resource.ts'; import { resolveAppLogResourcePath } from './app-log-resource-store.ts'; +import { finishLiveScreenRecording } from './screen-recording-session-resource.ts'; export { stopSessionAudioProbe } from './audio-probe.ts'; @@ -156,19 +156,21 @@ export function reportSessionCleanupFailures(params: { type SessionResourceTeardownRequest = { session: SessionState; sessionName: string; + sessionStore: SessionStore; stateDir?: string; -} & ({ appLog: 'run'; sessionStore: SessionStore } | { appLog: 'already-settled' }); + appLog: 'run' | 'already-settled'; +}; export async function teardownSessionResources( request: SessionResourceTeardownRequest, ): Promise { - const { session, sessionName, stateDir } = request; + const { session, sessionName, sessionStore, stateDir } = request; const appLogSteps: SessionCleanupStep[] = request.appLog === 'run' ? [ { step: 'app_log', - run: () => stopSessionAppLog({ session, sessionStore: request.sessionStore }), + run: () => stopSessionAppLog({ session, sessionStore }), }, ] : []; @@ -178,7 +180,15 @@ export async function teardownSessionResources( // signalling the recorder first prevents a leaked `simctl recordVideo` child // (and its 0-byte, slot-holding mp4) when a session is torn down — including // on daemon shutdown — without an explicit `record stop`. - { step: 'recording', run: () => stopSessionRecordingForTeardown(session) }, + { + step: 'recording', + run: () => + finishSessionScreenRecording({ + session, + sessionName, + sessionStore, + }), + }, ...appLogSteps, { step: 'audio_probe', @@ -206,3 +216,17 @@ export async function teardownSessionResources( }); if (aggregate) throw aggregate; } + +export async function finishSessionScreenRecording(params: { + session: SessionState; + sessionName: string; + sessionStore: SessionStore; +}): Promise { + const currentSession = params.sessionStore.get(params.sessionName) ?? params.session; + if (!currentSession.screenRecording) return; + await finishLiveScreenRecording({ + session: currentSession, + sessionName: params.sessionName, + sessionStore: params.sessionStore, + }); +} diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 198edca2e2..aa674e7d2b 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -1,11 +1,8 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { GestureExecutionProfile, - GestureReferenceFrame, PreresolvedInteractionTarget, - ScrollDirection, } from '@agent-device/contracts/interaction'; -import type { RecordingExportQuality, RecordingScope } from '@agent-device/contracts/recording'; import type { SessionAction, SessionSurface } from '@agent-device/contracts/session'; import type { LeaseBackend, @@ -29,6 +26,7 @@ import type { AppLogFailure, AppLogLiveHandle, DurableResourceEnvelope, + ScreenRecordingLiveHandle, } from '@agent-device/contracts/platform'; import type { AndroidNativePerfSession } from '../platforms/android/perf.ts'; import type { SessionScriptPublicationState } from './session-script-publication-state.ts'; @@ -160,44 +158,6 @@ export type DaemonRequest = Omit Promise; -type RecordingTelemetryBase = { - tMs: number; - x: number; - y: number; - referenceWidth?: number; - referenceHeight?: number; -}; - -type RecordingTelemetryTravel = RecordingTelemetryBase & { - x2: number; - y2: number; - durationMs: number; -}; - -export type RecordingGestureEvent = - | (RecordingTelemetryBase & { - kind: 'tap' | 'longpress'; - durationMs?: number; - }) - | (RecordingTelemetryTravel & { - kind: 'swipe'; - }) - | (RecordingTelemetryTravel & { - kind: 'scroll'; - contentDirection: ScrollDirection; - amount?: number; - pixels?: number; - }) - | (RecordingTelemetryTravel & { - kind: 'back-swipe'; - edge: 'left' | 'right'; - }) - | (RecordingTelemetryBase & { - kind: 'pinch'; - scale: number; - durationMs: number; - }); - export type AndroidSnapshotFreshness = { action: string; markedAt: number; @@ -279,36 +239,6 @@ export type PendingInteractionOutcome = { preSignature: InteractionSurfaceEntry[]; }; -type SessionRecordingBase = { - outPath: string; - clientOutPath?: string; - telemetryPath?: string; - warning?: string; - overlayWarning?: string; - startedAt: number; - recordingScope?: RecordingScope; - recordingBackend?: string; - recordOnlySession?: boolean; - activeSessionApp?: { - bundleId: string; - name?: string; - }; - exportQuality?: RecordingExportQuality; - showTouches: boolean; - gestureEvents: RecordingGestureEvent[]; - touchReferenceFrame?: GestureReferenceFrame; - gestureClockOriginAtMs?: number; - gestureClockOriginUptimeMs?: number; - runnerSessionId?: string; - invalidatedReason?: string; -}; - -export type RecordingChunk = { - index: number; - path: string; - remotePath: string; -}; - type SessionRecordingProcessChild = Pick; export type SessionState = { @@ -485,44 +415,6 @@ export type SessionState = { */ pendingRecordAndHeal?: { expectedFrom: number; actionsCountAtDivergence: number }; actions: SessionAction[]; - recording?: - | (SessionRecordingBase & { - platform: 'ios'; - child: SessionRecordingProcessChild; - wait: Promise; - recorderPid?: number; - remotePath?: string; - }) - | (SessionRecordingBase & { - platform: 'android'; - recordingId?: string; - remotePath: string; - remotePid: string; - remoteStartedAt?: number; - chunks?: RecordingChunk[]; - rotationTimer?: NodeJS.Timeout; - rotationPromise?: Promise; - rotationFailedReason?: string; - stopping?: boolean; - }) - | (SessionRecordingBase & { - platform: 'harmonyos'; - fileName: string; - remotePath: string; - }) - | (SessionRecordingBase & { - platform: 'ios-device-runner'; - remotePath: string; - runnerStartedAtUptimeMs?: number; - targetAppReadyUptimeMs?: number; - }) - | (SessionRecordingBase & { - platform: 'macos-runner'; - remotePath?: string; - }) - | (SessionRecordingBase & { - platform: 'web'; - }); /** * Neutral session-owned app-log resource. Durable coordinates are persisted * independently; the in-memory handle is never serialized or reconstructed @@ -532,6 +424,11 @@ export type SessionState = { handle: AppLogLiveHandle; envelope: DurableResourceEnvelope<'app-log'>; }; + /** Native recording mechanics stay behind the adopted runtime handle. */ + screenRecording?: { + handle: ScreenRecordingLiveHandle; + envelope: DurableResourceEnvelope<'screen-recording'>; + }; appLogFailure?: AppLogFailure; }; diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index 75517a392b..ddd91387b0 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -266,5 +266,8 @@ function unavailableFacts() { appLogReattach: unavailable, appLogCleanup: unavailable, networkDump: unavailable, + screenRecordingStart: unavailable, + screenRecordingReattach: unavailable, + screenRecordingCleanup: unavailable, }; } diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index acbe534b70..a323406fdd 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -11,6 +11,7 @@ import { runCmd, whichCmd } from './utils/exec.ts'; import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; import { createManagedAppLogProcesses } from './platform-runtime-app-log-process.ts'; import { createNetworkRuntimeHost } from './platform-runtime-network-host.ts'; +import { createScreenRecordingRuntimeHost } from './platform-runtime-screen-recording-host.ts'; export function createPlatformRuntimeHost(options: { sessionsDir: string; @@ -65,6 +66,7 @@ export function createPlatformRuntimeHost(options: { }, }), ...network, + screenRecording: createScreenRecordingRuntimeHost(), clock: Object.freeze({ now: () => Date.now(), sleep: async (milliseconds: number, signal?: AbortSignal) => { diff --git a/src/platform-runtime-screen-recording-android-host.test.ts b/src/platform-runtime-screen-recording-android-host.test.ts new file mode 100644 index 0000000000..31a81bdd0a --- /dev/null +++ b/src/platform-runtime-screen-recording-android-host.test.ts @@ -0,0 +1,247 @@ +import { expect, test, vi } from 'vitest'; +import { withAndroidAdbProvider } from './platforms/android/adb-executor.ts'; +import { createAndroidScreenRecordingTransport } from './platform-runtime-screen-recording-android-host.ts'; + +const adbExecutor = vi.hoisted(() => ({ + override: undefined as + | ((args: string[]) => Promise<{ stdout: string; stderr: string; exitCode: number | null }>) + | undefined, +})); + +vi.mock('./platforms/android/adb-executor.ts', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + resolveAndroidAdbExecutor: (...args: Parameters) => + adbExecutor.override ?? original.resolveAndroidAdbExecutor(...args), + }; +}); + +const android = { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, +}; + +test('uses the request-scoped Android ADB executor rather than a host fallback', async () => { + const calls: string[][] = []; + await withAndroidAdbProvider( + { + exec: async (args: string[]) => { + calls.push(args); + const command = args[1] ?? ''; + if (command.includes('screenrecord --bit-rate')) return result('42\n'); + if (command === 'cat /proc/42/stat') return result(procStat(42, '42')); + if (command === 'cat /proc/42/cmdline') { + return result( + ['/system/bin/screenrecord', '--bit-rate', '8000000', '/sdcard/capture.mp4', ''].join( + '\0', + ), + ); + } + if (command.startsWith('stat -c')) return result('42\n'); + return result(''); + }, + }, + { serial: android.id }, + async () => { + const transport = await createAndroidScreenRecordingTransport(android); + expect(transport.mode).toBe('transport-composed'); + await expect(transport.start({ remotePath: '/sdcard/capture.mp4' })).resolves.toEqual({ + process: { pid: '42', remotePath: '/sdcard/capture.mp4', startTime: '42' }, + }); + await expect(transport.size('/sdcard/capture.mp4')).resolves.toBe(42); + }, + ); + expect(calls).toEqual([ + ['shell', expect.stringContaining('screenrecord --bit-rate 8000000')], + ['shell', 'test -d /proc/42'], + ['shell', 'cat /proc/42/stat'], + ['shell', 'cat /proc/42/cmdline'], + ['shell', "test -e '/sdcard/capture.mp4'"], + ['shell', "stat -c %s '/sdcard/capture.mp4'"], + ]); +}); + +test('finds only exact screenrecord processes for the canonical remote path', async () => { + const remotePath = '/sdcard/agent-device-recording-123.mp4'; + await withAndroidAdbProvider( + { + exec: async (args) => { + const command = args[1] ?? ''; + if (command === 'ps -A -o pid=') return result('41\n42\n43\n44\n'); + if (/^test -d \/proc\/\d+$/.test(command)) return result(); + const pid = /\/proc\/(\d+)\//.exec(command)?.[1]; + if (!pid) return result('', 'unexpected command', 1); + if (command.endsWith('/stat')) return result(procStat(Number(pid), pid)); + const commandLines: Record = { + '41': ['/system/bin/screenrecord', '--bit-rate', '8000000', remotePath, ''].join('\0'), + '42': [ + '/system/bin/screenrecord', + '--bit-rate', + '8000000', + '/sdcard/agent-device-recording-456.mp4', + '', + ].join('\0'), + '43': ['/system/bin/sh', '-c', 'screenrecord', remotePath, ''].join('\0'), + '44': ['/system/bin/screenrecord', '--bit-rate', '8000000', remotePath, ''].join('\0'), + }; + return result(commandLines[pid] ?? ''); + }, + }, + { serial: android.id }, + async () => { + const transport = await createAndroidScreenRecordingTransport(android); + await expect(transport.findRunning(remotePath)).resolves.toEqual([ + { pid: '41', remotePath, startTime: '41' }, + { pid: '44', remotePath, startTime: '44' }, + ]); + }, + ); +}); + +test('revalidates start-time and exact argv before SIGINT', async () => { + const commands: string[] = []; + let currentStart = '52'; + await withAndroidAdbProvider( + { + exec: async (args) => { + const command = args[1] ?? ''; + commands.push(command); + if (command.endsWith('/stat')) return result(procStat(51, currentStart)); + if (command.endsWith('/cmdline')) { + return result(['/system/bin/screenrecord', '/sdcard/capture.mp4', ''].join('\0')); + } + return result(''); + }, + }, + { serial: android.id }, + async () => { + const transport = await createAndroidScreenRecordingTransport(android); + const process = { + pid: '51', + remotePath: '/sdcard/capture.mp4', + startTime: '51', + }; + await expect(transport.stop(process)).resolves.toBe('ownership-lost'); + expect(commands.some((command) => command.startsWith('kill '))).toBe(false); + currentStart = '51'; + await expect(transport.stop(process)).resolves.toBe('stopped'); + expect(commands.at(-1)).toBe('kill -2 51'); + }, + ); +}); + +test('distinguishes a missing proc directory from an unavailable identity probe', async () => { + await withAndroidAdbProvider( + { + exec: async (args) => { + const command = args[1] ?? ''; + if (command === 'test -d /proc/42') return result('', '', 1); + if (command === 'test -d /proc/43') return result('', 'transport unavailable', 1); + if (command === 'cat /proc/42/stat') { + return result('', 'cat: /proc/42/stat: No such file or directory', 1); + } + if (command === 'cat /proc/43/stat') return result('', 'transport unavailable', 1); + return result('', 'unexpected command', 1); + }, + }, + { serial: android.id }, + async () => { + const transport = await createAndroidScreenRecordingTransport(android); + const expected = { remotePath: '/sdcard/capture.mp4', startTime: '42' }; + await expect(transport.inspect({ ...expected, pid: '42' })).resolves.toBe('missing'); + await expect(transport.inspect({ ...expected, pid: '43' })).resolves.toBe('uncertain'); + }, + ); +}); + +test('retains an interrupted empty-stderr presence probe as uncertain', async () => { + adbExecutor.override = async (args) => { + expect(args).toEqual(['shell', 'test -d /proc/44']); + return result('', '', null); + }; + try { + const transport = await createAndroidScreenRecordingTransport(android); + await expect( + transport.inspect({ + pid: '44', + remotePath: '/sdcard/capture.mp4', + startTime: '44', + }), + ).resolves.toBe('uncertain'); + } finally { + adbExecutor.override = undefined; + } +}); + +test('retains an interrupted empty-stderr manifest probe as unavailable', async () => { + adbExecutor.override = async (args) => { + expect(args).toEqual(['shell', "test -e '/sdcard/interrupted.json'"]); + return result('', '', null); + }; + try { + const transport = await createAndroidScreenRecordingTransport(android); + await expect(transport.readManifest('/sdcard/interrupted.json')).resolves.toEqual({ + status: 'unavailable', + message: 'Android recording manifest probe failed', + }); + } finally { + adbExecutor.override = undefined; + } +}); + +function procStat(pid: number, startTime: string): string { + return `${pid} (screenrecord) S ${Array.from({ length: 18 }, () => '0').join(' ')} ${startTime}`; +} + +function result( + stdout?: string, + stderr?: string, + exitCode?: number, +): { + stdout: string; + stderr: string; + exitCode: number; +}; +function result( + stdout: string, + stderr: string, + exitCode: null, +): { + stdout: string; + stderr: string; + exitCode: null; +}; +function result(stdout = '', stderr = '', exitCode: number | null = 0) { + return { stdout, stderr, exitCode }; +} + +test('retains unavailable manifest reads and confirms manifest deletion', async () => { + const commands: string[] = []; + await withAndroidAdbProvider( + { + exec: async (args: string[]) => { + const command = args[1] ?? ''; + commands.push(command); + if (command.startsWith('test -e')) { + return { stdout: '', stderr: 'transport unavailable', exitCode: 1 }; + } + return { stdout: '', stderr: 'permission denied', exitCode: 1 }; + }, + }, + { serial: android.id }, + async () => { + const transport = await createAndroidScreenRecordingTransport(android); + await expect(transport.readManifest('/sdcard/manifest.json')).resolves.toEqual({ + status: 'unavailable', + message: 'transport unavailable', + }); + await expect(transport.removeManifest('/sdcard/manifest.json')).resolves.toBe(false); + }, + ); + expect(commands).toHaveLength(2); +}); diff --git a/src/platform-runtime-screen-recording-android-host.ts b/src/platform-runtime-screen-recording-android-host.ts new file mode 100644 index 0000000000..237f3a4586 --- /dev/null +++ b/src/platform-runtime-screen-recording-android-host.ts @@ -0,0 +1,195 @@ +import path from 'node:path'; +import type { + AndroidScreenRecordingProcessIdentity, + AndroidScreenRecordingTransport, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { shellQuote } from './utils/shell-quote.ts'; +import { isPlayableVideo } from './utils/video.ts'; + +const ANDROID_MANIFEST_NAME = 'agent-device-recording-active.json'; +const ADB_TIMEOUT_MS = 5_000; +const BIT_RATE = { medium: 8_000_000, high: 20_000_000 } as const; + +export async function createAndroidScreenRecordingTransport( + device: DeviceInfo, +): Promise { + const { resolveAndroidAdbExecutor, resolveScopedAndroidAdbBackgroundTransport } = + await import('./platforms/android/adb-executor.ts'); + const adb = resolveAndroidAdbExecutor(device); + const scoped = resolveScopedAndroidAdbBackgroundTransport(device); + const shell = async (command: string, signal?: AbortSignal) => + await adb(['shell', command], { + allowFailure: true, + timeoutMs: ADB_TIMEOUT_MS, + signal, + }); + return Object.freeze({ + mode: scoped.mode, + start: async ({ remotePath, quality = 'medium' }, signal) => { + const result = await shell( + `screenrecord --bit-rate ${BIT_RATE[quality]} ${shellQuote(remotePath)} >/dev/null 2>&1 & echo $!`, + signal, + ); + const remotePid = result.stdout.split(/\s+/).find((value) => /^\d+$/.test(value)); + if (!remotePid) throw new Error('Android screenrecord did not return a process id'); + const inspected = await inspectAndroidScreenRecordingProcess( + shell, + { pid: remotePid, remotePath, startTime: '' }, + signal, + ); + if (inspected.status !== 'owned-alive' || !inspected.process) { + throw new Error('Android screenrecord did not expose a complete process identity'); + } + return Object.freeze({ process: inspected.process }); + }, + inspect: async (process, signal) => + (await inspectAndroidScreenRecordingProcess(shell, process, signal)).status, + stop: async (process, options, signal) => { + const inspected = await inspectAndroidScreenRecordingProcess(shell, process, signal); + if (inspected.status === 'missing') return 'already-missing'; + if (inspected.status !== 'owned-alive') return inspected.status; + const stopped = await shell(`kill ${options?.force ? '-9 ' : '-2 '}${process.pid}`, signal); + return stopped.exitCode === 0 ? 'stopped' : 'uncertain'; + }, + exists: async (remotePath, signal) => + (await shell(`test -e ${shellQuote(remotePath)}`, signal)).exitCode === 0, + size: async (remotePath, signal) => { + const exists = await shell(`test -e ${shellQuote(remotePath)}`, signal); + if (exists.exitCode !== 0) { + return exists.stderr.trim() === '' ? undefined : 'uncertain'; + } + const result = await shell(`stat -c %s ${shellQuote(remotePath)}`, signal); + if (result.exitCode !== 0) return 'uncertain'; + const size = Number(result.stdout.trim()); + return Number.isSafeInteger(size) && size >= 0 ? size : 'uncertain'; + }, + findRunning: async (remotePath, signal) => { + const result = await shell('ps -A -o pid=', signal); + if (result.exitCode !== 0) { + throw new Error('failed to enumerate Android screenrecord processes'); + } + const pids = result.stdout.split(/\s+/).filter((pid) => /^\d+$/.test(pid)); + const inspected = await Promise.all( + pids.map( + async (pid) => + await inspectAndroidScreenRecordingProcess( + shell, + { pid, remotePath, startTime: '' }, + signal, + ), + ), + ); + return inspected.flatMap((outcome) => + outcome.status === 'owned-alive' && outcome.process ? [outcome.process] : [], + ); + }, + pullPlayable: async ({ remotePath, outputPath }, signal) => { + const result = await adb(['pull', remotePath, outputPath], { + allowFailure: true, + signal, + }); + return { + ...result, + playable: result.exitCode === 0 && (await isPlayableVideo(outputPath)), + }; + }, + remove: async (remotePath, signal) => + (await shell(`rm -f ${shellQuote(remotePath)}`, signal)).exitCode === 0, + manifestPathFor: (remotePath) => `${path.posix.dirname(remotePath)}/${ANDROID_MANIFEST_NAME}`, + readManifest: async (manifestPath, signal) => { + const exists = await shell(`test -e ${shellQuote(manifestPath)}`, signal); + if (exists.exitCode !== 0) { + return exists.exitCode === 1 && exists.stderr.trim() === '' + ? { status: 'missing' as const } + : { + status: 'unavailable' as const, + message: exists.stderr.trim() || 'Android recording manifest probe failed', + }; + } + const result = await shell(`cat ${shellQuote(manifestPath)}`, signal); + return result.exitCode === 0 + ? { status: 'read' as const, contents: result.stdout } + : { + status: 'unavailable' as const, + message: result.stderr.trim() || 'Android recording manifest could not be read', + }; + }, + writeManifest: async ({ manifestPath, contents }, signal) => { + const temporary = `${manifestPath}.tmp`; + const result = await shell( + `printf %s ${shellQuote(contents)} > ${shellQuote(temporary)} && mv -f ${shellQuote(temporary)} ${shellQuote(manifestPath)}`, + signal, + ); + if (result.exitCode !== 0) throw new Error('failed to write Android recording manifest'); + }, + removeManifest: async (manifestPath, signal) => + (await shell(`rm -f ${shellQuote(manifestPath)}`, signal)).exitCode === 0, + }); +} + +type AndroidShell = ( + command: string, + signal?: AbortSignal, +) => Promise>; + +async function inspectAndroidScreenRecordingProcess( + shell: AndroidShell, + expected: AndroidScreenRecordingProcessIdentity, + signal?: AbortSignal, +): Promise< + Readonly<{ + status: 'missing' | 'owned-alive' | 'ownership-lost' | 'uncertain'; + process?: AndroidScreenRecordingProcessIdentity; + }> +> { + const presence = await probeAndroidProcessPresence(shell, expected.pid, signal); + if (presence !== 'present') return { status: presence }; + const stat = await shell(`cat /proc/${expected.pid}/stat`, signal); + if (stat.exitCode !== 0) return { status: 'uncertain' }; + const startTime = parseProcStartTime(stat.stdout); + if (!startTime) return { status: 'uncertain' }; + const command = await shell(`cat /proc/${expected.pid}/cmdline`, signal); + if (command.exitCode !== 0) return { status: 'uncertain' }; + if (!matchesScreenRecordingCommand(command.stdout, expected.remotePath)) { + return { status: 'ownership-lost' }; + } + if (expected.startTime.length > 0 && expected.startTime !== startTime) { + return { status: 'ownership-lost' }; + } + return { + status: 'owned-alive', + process: Object.freeze({ pid: expected.pid, remotePath: expected.remotePath, startTime }), + }; +} + +async function probeAndroidProcessPresence( + shell: AndroidShell, + pid: string, + signal?: AbortSignal, +): Promise<'present' | 'missing' | 'uncertain'> { + const result = await shell(`test -d /proc/${pid}`, signal); + if (result.exitCode === 0) return 'present'; + return result.exitCode === 1 && result.stderr.trim() === '' ? 'missing' : 'uncertain'; +} + +function matchesScreenRecordingCommand(commandLine: string, remotePath: string): boolean { + const args = commandLine.split('\0').filter((value) => value.length > 0); + const executable = args[0]; + return ( + executable !== undefined && + path.posix.basename(executable) === 'screenrecord' && + args.at(-1) === remotePath + ); +} + +function parseProcStartTime(stat: string): string | undefined { + const close = stat.lastIndexOf(')'); + if (close < 0) return undefined; + const fieldsAfterCommand = stat + .slice(close + 1) + .trim() + .split(/\s+/); + const startTime = fieldsAfterCommand[19]; + return startTime && /^\d+$/.test(startTime) ? startTime : undefined; +} diff --git a/src/platform-runtime-screen-recording-apple-host.test.ts b/src/platform-runtime-screen-recording-apple-host.test.ts new file mode 100644 index 0000000000..4ddd71c315 --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-host.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'vitest'; +import { createAppleScreenRecordingHost } from './platform-runtime-screen-recording-apple-host.ts'; +import { withAppleRunnerScreenRecordingTransport } from './platform-runtime-screen-recording-apple-runner-transport.ts'; +import { withAppleSimulatorScreenRecordingTransport } from './platform-runtime-screen-recording-apple-transport.ts'; + +const simulator = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'sim', + name: 'Simulator', + kind: 'simulator' as const, + target: 'mobile' as const, + booted: true, +}; +const device = { ...simulator, id: 'device', kind: 'device' as const }; + +test('reports unavailable facts for generic scoped Apple providers', async () => { + const host = createAppleScreenRecordingHost(); + await withAppleRunnerScreenRecordingTransport(undefined, async () => { + await expect(host.availability(device)).resolves.toMatchObject({ available: false }); + }); + await withAppleSimulatorScreenRecordingTransport(undefined, async () => { + await expect(host.availability(simulator)).resolves.toMatchObject({ available: false }); + }); +}); + +test('reports a focused simulator transport as available', async () => { + const host = createAppleScreenRecordingHost(); + await withAppleSimulatorScreenRecordingTransport( + { + available: true, + mode: 'transport-composed', + start: async () => { + throw new Error('unused'); + }, + }, + async () => { + await expect(host.availability(simulator)).resolves.toEqual({ available: true }); + }, + ); +}); diff --git a/src/platform-runtime-screen-recording-apple-host.ts b/src/platform-runtime-screen-recording-apple-host.ts new file mode 100644 index 0000000000..4cc64f6878 --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-host.ts @@ -0,0 +1,69 @@ +import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +export function createAppleScreenRecordingHost(): ScreenRecordingRuntimeHost['apple'] { + return Object.freeze({ + availability: appleScreenRecordingAvailability, + runRunner: async (device, request, signal) => { + const { runAppleRecordingRunner } = + await import('./platform-runtime-screen-recording-apple-runner-host.ts'); + return await runAppleRecordingRunner(device, request, signal); + }, + startSimulator: async (device, outputPath, signal) => { + const { startAppleSimulatorRecording } = + await import('./platform-runtime-screen-recording-apple-simulator-host.ts'); + return await startAppleSimulatorRecording(device, outputPath, signal); + }, + inspectProcess: async (marker) => { + const { inspectAppleSimulatorRecordingProcess } = + await import('./platform-runtime-screen-recording-apple-simulator-host.ts'); + return inspectAppleSimulatorRecordingProcess(marker); + }, + terminateProcess: async (marker) => { + const { terminateAppleSimulatorRecordingProcess } = + await import('./platform-runtime-screen-recording-apple-simulator-host.ts'); + return await terminateAppleSimulatorRecordingProcess(marker); + }, + inspectRunner: async (device, runnerSessionId, runnerAuthority) => { + const { inspectAppleRunnerOwnership } = + await import('./platform-runtime-screen-recording-apple-runner-host.ts'); + return await inspectAppleRunnerOwnership(device, runnerSessionId, runnerAuthority); + }, + retrieveRunnerRecording: async (device, remotePath, outputPath, signal) => { + const { retrieveAppleRunnerRecording } = + await import('./platform-runtime-screen-recording-apple-runner-host.ts'); + await retrieveAppleRunnerRecording(device, remotePath, outputPath, signal); + }, + captureClockAnchor: async (device, appBundleId, signal) => { + const { captureAppleClockAnchor } = + await import('./platform-runtime-screen-recording-apple-runner-host.ts'); + return await captureAppleClockAnchor(device, appBundleId, signal); + }, + isRunnerBundleId: async (bundleId) => { + const { isAppleRunnerBundleId } = + await import('./platform-runtime-screen-recording-apple-runner-host.ts'); + return await isAppleRunnerBundleId(bundleId); + }, + }); +} + +async function appleScreenRecordingAvailability(device: DeviceInfo) { + if (device.kind === 'simulator') { + const { resolveAppleSimulatorScreenRecordingTransport } = + await import('./platform-runtime-screen-recording-apple-transport.ts'); + return resolveAppleSimulatorScreenRecordingTransport().available + ? ({ available: true } as const) + : ({ + available: false, + hint: 'Configure a focused Apple simulator recording transport for this provider.', + } as const); + } + const { resolveAppleRunnerScreenRecordingTransport } = + await import('./platform-runtime-screen-recording-apple-runner-transport.ts'); + return resolveAppleRunnerScreenRecordingTransport().available + ? ({ available: true } as const) + : ({ + available: false, + hint: 'This scoped Apple runner provider does not expose durable recording authority.', + } as const); +} diff --git a/src/platform-runtime-screen-recording-apple-runner-host.test.ts b/src/platform-runtime-screen-recording-apple-runner-host.test.ts new file mode 100644 index 0000000000..4f902e28a8 --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-runner-host.test.ts @@ -0,0 +1,54 @@ +import { expect, test, vi } from 'vitest'; +import { + captureAppleClockAnchor, + runAppleRecordingRunner, +} from './platform-runtime-screen-recording-apple-runner-host.ts'; +import { withAppleRunnerScreenRecordingTransport } from './platform-runtime-screen-recording-apple-runner-transport.ts'; +import { withAppleSimulatorScreenRecordingTransport } from './platform-runtime-screen-recording-apple-transport.ts'; + +const runnerClient = vi.hoisted(() => ({ run: vi.fn() })); +vi.mock('./platforms/apple/core/runner/runner-client.ts', () => ({ + runAppleRunnerCommand: runnerClient.run, + IOS_RUNNER_CONTAINER_BUNDLE_IDS: ['com.callstack.agentdevice.runner'], +})); + +const simulator = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'sim', + name: 'Simulator', + kind: 'simulator' as const, + target: 'mobile' as const, + booted: true, +}; + +test('generic scoped runner recording fails closed without invoking local runner mechanics', async () => { + runnerClient.run.mockReset(); + await withAppleRunnerScreenRecordingTransport(undefined, async () => { + await expect( + runAppleRecordingRunner(simulator, { + kind: 'start', + appBundleId: 'com.example.app', + outputPath: '/tmp/capture.mp4', + }), + ).rejects.toThrow('does not expose durable recording authority'); + }); + expect(runnerClient.run).not.toHaveBeenCalled(); +}); + +test('focused simulator-only transport never warms a local Apple runner', async () => { + runnerClient.run.mockReset(); + await withAppleSimulatorScreenRecordingTransport( + { + available: true, + mode: 'transport-composed', + start: async () => { + throw new Error('unused'); + }, + }, + async () => { + await expect(captureAppleClockAnchor(simulator, 'com.example.app')).resolves.toBeUndefined(); + }, + ); + expect(runnerClient.run).not.toHaveBeenCalled(); +}); diff --git a/src/platform-runtime-screen-recording-apple-runner-host.ts b/src/platform-runtime-screen-recording-apple-runner-host.ts new file mode 100644 index 0000000000..832cfaba8c --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-runner-host.ts @@ -0,0 +1,116 @@ +import type { + AppleScreenRecordingRunnerRequest, + ManagedProcessOwnership, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +export async function runAppleRecordingRunner( + device: DeviceInfo, + request: AppleScreenRecordingRunnerRequest, + signal?: AbortSignal, +) { + const { resolveAppleRunnerScreenRecordingTransport } = + await import('./platform-runtime-screen-recording-apple-runner-transport.ts'); + const transport = resolveAppleRunnerScreenRecordingTransport(); + if (request.kind === 'stop') { + if (transport.authority !== request.runnerAuthority) { + throw new Error('Apple runner recording ownership changed before cleanup'); + } + await transport.stop({ + device, + runnerSessionId: request.runnerSessionId, + appBundleId: request.appBundleId, + signal, + }); + return {}; + } + const result = await transport.start({ + device, + appBundleId: request.appBundleId, + outputPath: request.outputPath, + fps: request.fps, + signal, + }); + return Object.freeze({ + runnerSessionId: result.runnerSessionId, + runnerAuthority: transport.authority, + ...(result.remotePath === undefined ? {} : { remotePath: result.remotePath }), + ...(typeof result.recorderStartUptimeMs === 'number' + ? { recorderStartUptimeMs: result.recorderStartUptimeMs } + : {}), + ...(typeof result.targetAppReadyUptimeMs === 'number' + ? { targetAppReadyUptimeMs: result.targetAppReadyUptimeMs } + : {}), + }); +} + +export async function inspectAppleRunnerOwnership( + device: DeviceInfo, + runnerSessionId: string, + runnerAuthority: 'local-lease' | 'scoped-provider', +): Promise { + const { resolveAppleRunnerScreenRecordingTransport } = + await import('./platform-runtime-screen-recording-apple-runner-transport.ts'); + const transport = resolveAppleRunnerScreenRecordingTransport(); + return transport.authority === runnerAuthority + ? await transport.inspect(device, runnerSessionId) + : 'ownership-lost'; +} + +export async function retrieveAppleRunnerRecording( + device: DeviceInfo, + remotePath: string, + outputPath: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const { resolveIosPhysicalDeviceControl } = + await import('./platforms/apple/core/physical-device-control.ts'); + await resolveIosPhysicalDeviceControl(device).copyRunnerFile(device, remotePath, outputPath); + signal?.throwIfAborted(); +} + +export async function captureAppleClockAnchor( + device: DeviceInfo, + appBundleId: string, + signal?: AbortSignal, +) { + try { + const [simulatorTransport, runnerTransport] = await Promise.all([ + import('./platform-runtime-screen-recording-apple-transport.ts').then((module) => + module.resolveAppleSimulatorScreenRecordingTransport(), + ), + import('./platform-runtime-screen-recording-apple-runner-transport.ts').then((module) => + module.resolveAppleRunnerScreenRecordingTransport(), + ), + ]); + if ( + simulatorTransport.mode === 'transport-composed' && + runnerTransport.authority !== 'scoped-provider' + ) { + return undefined; + } + const { runAppleRunnerCommand } = + await import('./platforms/apple/core/runner/runner-client.ts'); + const result = await runAppleRunnerCommand( + device, + { command: 'snapshot', appBundleId, interactiveOnly: true, depth: 1 }, + { signal }, + ); + const wallClockAtMs = Date.now(); + return typeof result.currentUptimeMs === 'number' && + Number.isFinite(result.currentUptimeMs) && + result.currentUptimeMs > 0 + ? { wallClockAtMs, uptimeMs: result.currentUptimeMs } + : undefined; + } catch (_error) { + signal?.throwIfAborted(); + return undefined; + } +} + +export async function isAppleRunnerBundleId(bundleId: string): Promise { + const { IOS_RUNNER_CONTAINER_BUNDLE_IDS } = + await import('./platforms/apple/core/runner/runner-client.ts'); + return IOS_RUNNER_CONTAINER_BUNDLE_IDS.includes(bundleId); +} diff --git a/src/platform-runtime-screen-recording-apple-runner-transport.test.ts b/src/platform-runtime-screen-recording-apple-runner-transport.test.ts new file mode 100644 index 0000000000..4e840231f5 --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-runner-transport.test.ts @@ -0,0 +1,53 @@ +import { expect, test, vi } from 'vitest'; +import { + resolveAppleRunnerScreenRecordingTransport, + withAppleRunnerScreenRecordingTransport, +} from './platform-runtime-screen-recording-apple-runner-transport.ts'; + +const runner = vi.hoisted(() => ({ + run: vi.fn(), + snapshot: vi.fn(), +})); + +vi.mock('./platforms/apple/core/runner/runner-client.ts', () => ({ + runAppleRunnerCommand: runner.run, + getRunnerSessionSnapshot: runner.snapshot, +})); + +const device = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'device', + name: 'iPhone', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, +}; + +test('scopes an unavailable runner authority instead of falling back to a local lease', async () => { + await withAppleRunnerScreenRecordingTransport(undefined, async () => { + const transport = resolveAppleRunnerScreenRecordingTransport(); + expect(transport).toMatchObject({ available: false, authority: 'scoped-provider' }); + await expect( + transport.start({ + device, + appBundleId: 'com.example.app', + outputPath: '/tmp/capture.mp4', + }), + ).rejects.toThrow('does not expose durable recording authority'); + }); +}); + +test('passes the recorded session identity into the runner stop dispatch boundary', async () => { + runner.snapshot.mockReturnValue({ sessionId: 'runner-session-1', alive: true }); + runner.run.mockResolvedValue({}); + const transport = resolveAppleRunnerScreenRecordingTransport(); + + await transport.stop({ device, runnerSessionId: 'runner-session-1' }); + + expect(runner.run).toHaveBeenCalledWith( + device, + { command: 'recordStop', appBundleId: undefined }, + { signal: undefined, expectedRunnerSessionId: 'runner-session-1' }, + ); +}); diff --git a/src/platform-runtime-screen-recording-apple-runner-transport.ts b/src/platform-runtime-screen-recording-apple-runner-transport.ts new file mode 100644 index 0000000000..46321cbfd2 --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-runner-transport.ts @@ -0,0 +1,166 @@ +import path from 'node:path'; +import type { ManagedProcessOwnership } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createScopedProvider } from './utils/scoped-provider.ts'; +import { isProcessAlive } from './utils/host-process.ts'; + +type AppleRunnerScreenRecordingStartRequest = Readonly<{ + device: DeviceInfo; + appBundleId: string; + outputPath: string; + fps?: number; + signal?: AbortSignal; +}>; + +type AppleRunnerScreenRecordingStartResult = Readonly<{ + runnerSessionId: string; + remotePath?: string; + recorderStartUptimeMs?: number; + targetAppReadyUptimeMs?: number; +}>; + +export type AppleRunnerScreenRecordingTransport = Readonly<{ + authority: 'local-lease' | 'scoped-provider'; + available: boolean; + start( + request: AppleRunnerScreenRecordingStartRequest, + ): Promise; + inspect(device: DeviceInfo, runnerSessionId: string): Promise; + stop( + request: Readonly<{ + device: DeviceInfo; + runnerSessionId: string; + appBundleId?: string; + signal?: AbortSignal; + }>, + ): Promise; +}>; + +const localTransport: AppleRunnerScreenRecordingTransport = Object.freeze({ + authority: 'local-lease', + available: true, + start: startLocalAppleRunnerRecording, + inspect: async (device, runnerSessionId) => await inspectLocalRunner(device, runnerSessionId), + stop: async ({ device, runnerSessionId, appBundleId, signal }) => { + const { runAppleRunnerCommand } = + await import('./platforms/apple/core/runner/runner-client.ts'); + await runAppleRunnerCommand( + device, + { command: 'recordStop', appBundleId }, + { signal, expectedRunnerSessionId: runnerSessionId }, + ); + }, +}); + +async function startLocalAppleRunnerRecording({ + device, + appBundleId, + outputPath, + fps, + signal, +}: AppleRunnerScreenRecordingStartRequest): Promise { + const { getRunnerSessionSnapshot, runAppleRunnerCommand } = + await import('./platforms/apple/core/runner/runner-client.ts'); + const recordingFileName = `agent-device-recording-${Date.now()}.mp4`; + const remotePath = device.kind === 'device' ? `tmp/${recordingFileName}` : undefined; + const result = await runAppleRunnerCommand( + device, + { + command: 'recordStart', + outPath: runnerOutputPath(device, outputPath, recordingFileName, remotePath), + ...(fps === undefined ? {} : { fps }), + appBundleId, + }, + { signal }, + ); + const session = getRunnerSessionSnapshot(device.id); + if (!session?.alive) { + await stopAcquiredRunner(device, appBundleId); + throw new Error('Apple runner recording did not expose a durable runner session identity'); + } + try { + signal?.throwIfAborted(); + } catch (error) { + await stopAcquiredRunner(device, appBundleId); + throw error; + } + return freezeRunnerStartResult(session.sessionId, remotePath, result); +} + +function runnerOutputPath( + device: DeviceInfo, + outputPath: string, + recordingFileName: string, + remotePath: string | undefined, +): string { + if (device.appleOs === 'macos') return outputPath; + return remotePath === undefined ? path.basename(outputPath) : recordingFileName; +} + +function freezeRunnerStartResult( + runnerSessionId: string, + remotePath: string | undefined, + timing: Readonly<{ recorderStartUptimeMs?: unknown; targetAppReadyUptimeMs?: unknown }>, +): AppleRunnerScreenRecordingStartResult { + return Object.freeze({ + runnerSessionId, + ...(remotePath === undefined ? {} : { remotePath }), + ...(typeof timing.recorderStartUptimeMs === 'number' + ? { recorderStartUptimeMs: timing.recorderStartUptimeMs } + : {}), + ...(typeof timing.targetAppReadyUptimeMs === 'number' + ? { targetAppReadyUptimeMs: timing.targetAppReadyUptimeMs } + : {}), + }); +} + +async function stopAcquiredRunner(device: DeviceInfo, appBundleId: string): Promise { + const { runAppleRunnerCommand } = await import('./platforms/apple/core/runner/runner-client.ts'); + await runAppleRunnerCommand(device, { command: 'recordStop', appBundleId }, {}).catch(() => {}); +} + +const unavailableScopedTransport: AppleRunnerScreenRecordingTransport = Object.freeze({ + authority: 'scoped-provider', + available: false, + start: async () => { + throw new Error('Scoped Apple runner provider does not expose durable recording authority'); + }, + inspect: async () => 'ownership-lost' as const, + stop: async () => { + throw new Error('Scoped Apple runner recording authority is unavailable'); + }, +}); + +const transportScope = createScopedProvider(localTransport); + +export function resolveAppleRunnerScreenRecordingTransport(): AppleRunnerScreenRecordingTransport { + return transportScope.resolve(); +} + +export async function withAppleRunnerScreenRecordingTransport( + transport: AppleRunnerScreenRecordingTransport | undefined, + task: () => Promise, +): Promise { + return await transportScope.run(transport ?? unavailableScopedTransport, task); +} + +async function inspectLocalRunner( + device: DeviceInfo, + runnerSessionId: string, +): Promise { + const [{ getRunnerSessionSnapshot }, { readStaleRunnerLease, verifyLeaseRunnerPidIdentity }] = + await Promise.all([ + import('./platforms/apple/core/runner/runner-client.ts'), + import('./platforms/apple/core/runner/runner-lease.ts'), + ]); + const active = getRunnerSessionSnapshot(device.id); + if (active) { + if (!active.alive) return 'missing'; + return active.sessionId === runnerSessionId ? 'owned-alive' : 'ownership-lost'; + } + const lease = readStaleRunnerLease(device.id); + if (!lease) return 'missing'; + if (lease.sessionId !== runnerSessionId) return 'ownership-lost'; + if (lease.runnerPid === null || !isProcessAlive(lease.runnerPid)) return 'missing'; + return verifyLeaseRunnerPidIdentity(lease, lease.runnerPid) ? 'owned-alive' : 'ownership-lost'; +} diff --git a/src/platform-runtime-screen-recording-apple-simulator-host.test.ts b/src/platform-runtime-screen-recording-apple-simulator-host.test.ts new file mode 100644 index 0000000000..955c6057fb --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-simulator-host.test.ts @@ -0,0 +1,262 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { mkdtempForTestSync } from './__tests__/test-utils/tmp-dir.ts'; +import { createAppleScreenRecordingHost } from './platform-runtime-screen-recording-apple-host.ts'; +import { startAppleSimulatorRecording } from './platform-runtime-screen-recording-apple-simulator-host.ts'; +import { withAppleSimulatorScreenRecordingTransport } from './platform-runtime-screen-recording-apple-transport.ts'; + +const processes = vi.hoisted(() => ({ + alive: new Map(), + starts: new Map(), + commands: new Map(), +})); + +vi.mock('./utils/host-process.ts', async (importOriginal) => ({ + ...(await importOriginal()), + isProcessAlive: (pid: number) => processes.alive.get(pid) ?? false, + isProcessZombie: () => false, + readProcessStartTime: (pid: number) => processes.starts.get(pid) ?? null, + readProcessCommand: (pid: number) => processes.commands.get(pid) ?? null, + listHostProcesses: async () => + [...processes.alive.keys()].map((pid) => ({ + pid, + command: processes.commands.get(pid) ?? '', + })), + signalPidsBestEffort: (pids: readonly number[]) => { + for (const pid of pids) processes.alive.set(pid, false); + return pids.length; + }, + waitForProcessExit: async (pid: number) => !(processes.alive.get(pid) ?? false), +})); + +const simulator = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'simulator-id', + name: 'Simulator', + kind: 'simulator' as const, + target: 'mobile' as const, + booted: true, +}; + +beforeEach(() => { + processes.alive.clear(); + processes.starts.clear(); + processes.commands.clear(); +}); + +test('waits for delayed output and rejects an early nonzero exit', async () => { + const root = mkdtempForTestSync('agent-device-recording-ready-'); + const outputPath = path.join(root, 'capture.mp4'); + const running = background(42); + const starting = withTransport( + running.process, + async () => await startAppleSimulatorRecording(simulator, outputPath), + ); + setTimeout(() => fs.writeFileSync(outputPath, 'recording'), 25); + await expect(starting).resolves.toMatchObject({ markers: [{ pid: 42 }] }); + + const failed = background(43); + failed.resolveWait({ stdout: '', stderr: 'failed', exitCode: 1 }); + await expect( + withTransport( + failed.process, + async () => await startAppleSimulatorRecording(simulator, path.join(root, 'failed.mp4')), + ), + ).rejects.toThrow('simctl recordVideo exited with code 1'); + running.resolveWait({ stdout: '', stderr: '', exitCode: 0 }); +}); + +test('cancellation during readiness kills and settles with the exact reason', async () => { + const root = mkdtempForTestSync('agent-device-recording-cancel-'); + const controller = new AbortController(); + const reason = new Error('cancel simulator readiness'); + const running = background(44); + const start = vi.fn(() => running.process); + const starting = withAppleSimulatorScreenRecordingTransport( + { available: true, mode: 'transport-composed', start }, + async () => + await startAppleSimulatorRecording( + simulator, + path.join(root, 'capture.mp4'), + controller.signal, + ), + ); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + controller.abort(reason); + + await expect(starting).rejects.toBe(reason); + expect(running.kill).toHaveBeenCalledWith('SIGINT'); +}); + +test('late provider acquisition after abort is rolled back exactly once', async () => { + const controller = new AbortController(); + const reason = new Error('cancel ignored provider'); + let resolveStart: ((value: ReturnType['process']) => void) | undefined; + const late = background(45); + const transportStart = new Promise['process']>((resolve) => { + resolveStart = resolve; + }); + const start = vi.fn(async () => await transportStart); + const starting = withAppleSimulatorScreenRecordingTransport( + { available: true, mode: 'transport-composed', start }, + async () => + await startAppleSimulatorRecording(simulator, '/tmp/late-provider.mp4', controller.signal), + ); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + controller.abort(reason); + await expect(starting).rejects.toBe(reason); + resolveStart?.(late.process); + await vi.waitFor(() => expect(late.kill).toHaveBeenCalledTimes(1)); + expect(late.kill).toHaveBeenCalledWith('SIGKILL'); +}); + +test('resolved provider acquisition aborted before publication removes partial output and settles', async () => { + const root = mkdtempForTestSync('agent-device-recording-acquired-abort-'); + const outputPath = path.join(root, 'capture.mp4'); + fs.writeFileSync(outputPath, 'partial recording'); + const controller = new AbortController(); + const reason = new Error('cancel after provider acquisition'); + const acquired = background(47); + + const starting = withAppleSimulatorScreenRecordingTransport( + { + available: true, + mode: 'transport-composed', + start: () => { + controller.abort(reason); + return acquired.process; + }, + }, + async () => await startAppleSimulatorRecording(simulator, outputPath, controller.signal), + ); + + await expect(starting).rejects.toBe(reason); + expect(acquired.kill).toHaveBeenCalledTimes(1); + expect(acquired.kill).toHaveBeenCalledWith('SIGKILL'); + expect(fs.existsSync(outputPath)).toBe(false); +}); + +test('post-publication abort does not kill the adopted process', async () => { + const root = mkdtempForTestSync('agent-device-recording-adopted-'); + const outputPath = path.join(root, 'capture.mp4'); + fs.writeFileSync(outputPath, 'recording'); + const controller = new AbortController(); + const running = background(46); + const process = await withTransport( + running.process, + async () => await startAppleSimulatorRecording(simulator, outputPath, controller.signal), + ); + controller.abort(new Error('after publication')); + expect(running.kill).not.toHaveBeenCalled(); + await process.terminate(); + expect(running.kill).toHaveBeenCalledTimes(1); + await expect(process.wait).resolves.toMatchObject({ exitCode: 0 }); +}); + +test('stops a locally launched simulator recorder after xcrun execs the simctl binary', async () => { + const root = mkdtempForTestSync('agent-device-recording-xcrun-exec-'); + const outputPath = path.join(root, 'capture.mp4'); + fs.writeFileSync(outputPath, 'recording'); + const running = background(48, `xcrun simctl io ${simulator.id} recordVideo ${outputPath}`); + const process = await withTransport( + running.process, + async () => await startAppleSimulatorRecording(simulator, outputPath), + ); + processes.commands.set( + 48, + `/Library/Developer/PrivateFrameworks/CoreSimulator.framework/Versions/A/Resources/bin/simctl io ${simulator.id} recordVideo ${outputPath}`, + ); + + const marker = process.markers?.[0]; + if (!marker) throw new Error('missing simulator recording marker'); + await expect(createAppleScreenRecordingHost().inspectProcess(marker)).resolves.toBe( + 'owned-alive', + ); + await expect(process.terminate()).resolves.toBeUndefined(); + expect(running.kill).toHaveBeenCalledWith('SIGINT'); +}); + +test.each([ + [ + 'device', + (outputPath: string) => + `/Library/Developer/PrivateFrameworks/CoreSimulator.framework/Versions/A/Resources/bin/simctl io another-simulator recordVideo ${outputPath}`, + ], + [ + 'output path', + () => + `/Library/Developer/PrivateFrameworks/CoreSimulator.framework/Versions/A/Resources/bin/simctl io ${simulator.id} recordVideo /tmp/another.mp4`, + ], + [ + 'verb', + (outputPath: string) => + `/Library/Developer/PrivateFrameworks/CoreSimulator.framework/Versions/A/Resources/bin/simctl io ${simulator.id} screenshot ${outputPath}`, + ], +])('does not stop a simctl process whose %s changed after capture', async (_name, observed) => { + const root = mkdtempForTestSync('agent-device-recording-xcrun-mismatch-'); + const outputPath = path.join(root, 'capture.mp4'); + fs.writeFileSync(outputPath, 'recording'); + const running = background(49, `xcrun simctl io ${simulator.id} recordVideo ${outputPath}`); + const process = await withTransport( + running.process, + async () => await startAppleSimulatorRecording(simulator, outputPath), + ); + processes.commands.set(49, observed(outputPath)); + + const marker = process.markers?.[0]; + if (!marker) throw new Error('missing simulator recording marker'); + await expect(createAppleScreenRecordingHost().inspectProcess(marker)).resolves.toBe( + 'ownership-lost', + ); + await expect(process.terminate()).rejects.toThrow('process ownership changed'); + expect(running.kill).not.toHaveBeenCalled(); + running.resolveWait({ stdout: '', stderr: '', exitCode: 0 }); +}); + +test('pidless provider process is killed and settled before start fails', async () => { + const running = background(undefined); + await expect( + withTransport( + running.process, + async () => await startAppleSimulatorRecording(simulator, '/tmp/pidless.mp4'), + ), + ).rejects.toThrow('complete process identity'); + expect(running.kill).toHaveBeenCalledWith('SIGKILL'); +}); + +function background(pid: number | undefined, command?: string) { + let settle: ((result: { stdout: string; stderr: string; exitCode: number }) => void) | undefined; + const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + settle = resolve; + }); + if (pid !== undefined) { + processes.alive.set(pid, true); + processes.starts.set(pid, `start-${pid}`); + processes.commands.set(pid, command ?? `xcrun simctl io ${simulator.id} recordVideo`); + } + const kill = vi.fn((_signal: NodeJS.Signals) => { + if (pid !== undefined) processes.alive.set(pid, false); + settle?.({ stdout: '', stderr: '', exitCode: 1 }); + return true; + }); + return { + process: { child: { pid, kill }, wait }, + kill, + resolveWait: (result: { stdout: string; stderr: string; exitCode: number }) => { + if (pid !== undefined) processes.alive.set(pid, false); + settle?.(result); + }, + }; +} + +async function withTransport( + process: ReturnType['process'], + task: () => Promise, +): Promise { + return await withAppleSimulatorScreenRecordingTransport( + { available: true, mode: 'transport-composed', start: () => process }, + task, + ); +} diff --git a/src/platform-runtime-screen-recording-apple-simulator-host.ts b/src/platform-runtime-screen-recording-apple-simulator-host.ts new file mode 100644 index 0000000000..ea3c96c05e --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-simulator-host.ts @@ -0,0 +1,255 @@ +import fs from 'node:fs'; +import type { + HostCommandResult, + ManagedProcessIdentity, + ScreenRecordingBackgroundProcess, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppleSimulatorScreenRecordingProcess } from './platform-runtime-screen-recording-apple-transport.ts'; +import { + inspectManagedProcess, + resolveManagedProcessIdentity, + resolveManagedProcessTree, + terminateManagedProcessSet, + type ManagedProcessCommandMatcher, +} from './platform-runtime-screen-recording-process-host.ts'; + +const READY_POLL_MS = 250; +const LIVENESS_GRACE_MS = 50; +const READY_TIMEOUT_MS = 15_000; +const IDENTITY_POLL_MS = 25; +const IDENTITY_TIMEOUT_MS = 2_000; + +const appleSimulatorRecordingCommandMatches: ManagedProcessCommandMatcher = ( + persisted, + observed, +) => { + if (persisted === observed) return true; + const xcrunArgs = /^(?:\S*\/)?xcrun simctl (.+)$/.exec(persisted)?.[1]; + const simctlArgs = /^(?:\S*\/)?simctl (.+)$/.exec(observed)?.[1]; + return ( + xcrunArgs !== undefined && + xcrunArgs === simctlArgs && + /^(?:--set .+ )?io \S+ recordVideo .+$/.test(xcrunArgs) + ); +}; + +type AppleSimulatorExit = + | Readonly<{ kind: 'exited'; result: HostCommandResult }> + | Readonly<{ kind: 'failed'; error: unknown }>; + +export async function startAppleSimulatorRecording( + device: DeviceInfo, + outputPath: string, + signal?: AbortSignal, +): Promise { + const { resolveAppleSimulatorScreenRecordingTransport } = + await import('./platform-runtime-screen-recording-apple-transport.ts'); + signal?.throwIfAborted(); + let background: AppleSimulatorScreenRecordingProcess | undefined; + try { + background = await acquireSimulatorProcess( + resolveAppleSimulatorScreenRecordingTransport().start({ device, outputPath, signal }), + signal, + ); + signal?.throwIfAborted(); + } catch (error) { + if (background) await rollbackAcquiredSimulatorProcess(background); + fs.rmSync(outputPath, { force: true }); + signal?.throwIfAborted(); + throw error; + } + if (!background) throw new Error('simctl recordVideo acquisition did not return a process'); + let rootMarker: ManagedProcessIdentity | undefined; + try { + rootMarker = await waitForManagedProcessIdentity(background.child.pid, signal); + if (!rootMarker) { + throw new Error('simctl recordVideo did not expose a complete process identity'); + } + } catch (error) { + background.child.kill('SIGKILL'); + await background.wait.catch(() => undefined); + signal?.throwIfAborted(); + throw error; + } + try { + await waitForReadiness(outputPath, background.wait, signal); + const markers = await resolveManagedProcessTree(rootMarker); + return createAppleSimulatorProcess(background, markers); + } catch (error) { + await terminateManagedProcessSet( + [rootMarker], + background, + appleSimulatorRecordingCommandMatches, + ).catch(() => {}); + await background.wait.catch(() => undefined); + fs.rmSync(outputPath, { force: true }); + signal?.throwIfAborted(); + throw error; + } +} + +async function waitForManagedProcessIdentity( + pid: number | undefined, + signal?: AbortSignal, +): Promise { + if (pid === undefined) return undefined; + const attempts = Math.ceil(IDENTITY_TIMEOUT_MS / IDENTITY_POLL_MS); + for (let attempt = 0; attempt <= attempts; attempt += 1) { + signal?.throwIfAborted(); + const marker = await resolveManagedProcessIdentity(pid); + if (marker) return marker; + if (attempt < attempts) await delay(IDENTITY_POLL_MS, signal); + } + return undefined; +} + +async function acquireSimulatorProcess( + acquisition: AppleSimulatorScreenRecordingProcess | Promise, + signal?: AbortSignal, +): Promise { + const started = Promise.resolve(acquisition); + if (!signal) return await started; + if (signal.aborted) { + if (isSimulatorProcess(acquisition)) { + await rollbackAcquiredSimulatorProcess(acquisition); + } else { + void started.then(rollbackAcquiredSimulatorProcess); + } + throw signal.reason; + } + let removeAbort = () => {}; + const aborted = new Promise((_resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + removeAbort = () => signal.removeEventListener('abort', onAbort); + if (signal.aborted) onAbort(); + }); + try { + return await Promise.race([started, aborted]); + } catch (error) { + if (!signal.aborted) throw error; + void started.then(rollbackAcquiredSimulatorProcess); + throw signal.reason; + } finally { + removeAbort(); + } +} + +function isSimulatorProcess( + value: AppleSimulatorScreenRecordingProcess | Promise, +): value is AppleSimulatorScreenRecordingProcess { + return 'child' in value; +} + +async function rollbackAcquiredSimulatorProcess( + process: AppleSimulatorScreenRecordingProcess, +): Promise { + process.child.kill('SIGKILL'); + await process.wait.catch(() => undefined); +} + +function createAppleSimulatorProcess( + background: AppleSimulatorScreenRecordingProcess, + markers: readonly ManagedProcessIdentity[], +): ScreenRecordingBackgroundProcess { + let termination: Promise | undefined; + let terminatedByOwner = false; + const terminate = () => + (termination ??= terminateManagedProcessSet( + markers, + background, + appleSimulatorRecordingCommandMatches, + ).then((outcome) => { + if (outcome === 'ownership-lost') { + throw new Error('simctl recordVideo process ownership changed before cleanup'); + } + if (outcome !== 'terminated' && outcome !== 'already-missing') { + throw new Error('simctl recordVideo cleanup was not confirmed'); + } + terminatedByOwner = outcome === 'terminated'; + })); + const wait = background.wait.then(async (result) => { + const ownerTermination = termination; + if (ownerTermination) await ownerTermination.catch(() => undefined); + return terminatedByOwner && result.exitCode !== 0 ? { ...result, exitCode: 0 } : result; + }); + return Object.freeze({ + markers: Object.freeze([...markers]), + wait, + terminate, + }); +} + +export function inspectAppleSimulatorRecordingProcess(marker: ManagedProcessIdentity) { + return inspectManagedProcess(marker, appleSimulatorRecordingCommandMatches); +} + +export async function terminateAppleSimulatorRecordingProcess(marker: ManagedProcessIdentity) { + return await terminateManagedProcessSet( + [marker], + undefined, + appleSimulatorRecordingCommandMatches, + ); +} + +async function waitForReadiness( + outputPath: string, + wait: Promise, + signal?: AbortSignal, +): Promise { + const processExit = wait.then( + (result) => ({ kind: 'exited' as const, result }), + (error: unknown) => ({ kind: 'failed' as const, error }), + ); + let settled: AppleSimulatorExit | undefined; + void processExit.then((outcome) => { + settled = outcome; + }); + await Promise.resolve(); + const attempts = Math.ceil(READY_TIMEOUT_MS / READY_POLL_MS); + for (let attempt = 0; attempt <= attempts; attempt += 1) { + signal?.throwIfAborted(); + if (settled) throw startError(settled); + if (fs.existsSync(outputPath)) { + const exit = await Promise.race([ + processExit, + delay(LIVENESS_GRACE_MS, signal).then(() => undefined), + ]); + if (exit) throw startError(exit); + return; + } + if (attempt === attempts) { + throw new Error(`simctl recordVideo did not create its output within ${READY_TIMEOUT_MS}ms`); + } + const exit = await Promise.race([ + processExit, + delay(READY_POLL_MS, signal).then(() => undefined), + ]); + if (exit) throw startError(exit); + } +} + +function startError(outcome: AppleSimulatorExit): Error { + if (outcome.kind === 'failed') { + return outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error)); + } + return new Error(`simctl recordVideo exited with code ${outcome.result.exitCode}`); +} + +function delay(milliseconds: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + const finish = (error?: unknown) => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + if (error === undefined) resolve(); + else reject(error); + }; + const timer = setTimeout(() => finish(), milliseconds); + if (!signal) return; + const onAbort = () => finish(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); +} diff --git a/src/platform-runtime-screen-recording-apple-transport.test.ts b/src/platform-runtime-screen-recording-apple-transport.test.ts new file mode 100644 index 0000000000..713fa2f57d --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-transport.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest'; +import { + resolveAppleSimulatorScreenRecordingTransport, + withAppleSimulatorScreenRecordingTransport, +} from './platform-runtime-screen-recording-apple-transport.ts'; + +const simulator = { + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'sim', + name: 'Simulator', + kind: 'simulator' as const, + target: 'mobile' as const, + booted: true, +}; + +test('scopes an explicit unavailable sentinel instead of falling back to local simctl', async () => { + await withAppleSimulatorScreenRecordingTransport(undefined, async () => { + const transport = resolveAppleSimulatorScreenRecordingTransport(); + expect(transport).toMatchObject({ available: false, mode: 'transport-composed' }); + await expect( + transport.start({ device: simulator, outputPath: '/tmp/capture.mp4' }), + ).rejects.toThrow('does not expose an Apple simulator screen-recording transport'); + }); +}); diff --git a/src/platform-runtime-screen-recording-apple-transport.ts b/src/platform-runtime-screen-recording-apple-transport.ts new file mode 100644 index 0000000000..391adad738 --- /dev/null +++ b/src/platform-runtime-screen-recording-apple-transport.ts @@ -0,0 +1,62 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { ExecBackgroundResult, ExecResult } from './utils/exec.ts'; +import { createScopedProvider } from './utils/scoped-provider.ts'; + +export type AppleSimulatorScreenRecordingProcess = Readonly<{ + child: Pick; + wait: Promise; +}>; + +export type AppleSimulatorScreenRecordingRequest = Readonly<{ + device: DeviceInfo; + outputPath: string; + signal?: AbortSignal; +}>; + +export type AppleSimulatorScreenRecordingTransport = Readonly<{ + available: boolean; + mode: 'local' | 'transport-composed'; + start( + request: AppleSimulatorScreenRecordingRequest, + ): AppleSimulatorScreenRecordingProcess | Promise; +}>; + +const localTransport: AppleSimulatorScreenRecordingTransport = Object.freeze({ + available: true, + mode: 'local', + async start({ device, outputPath, signal }) { + const [{ buildSimctlArgsForDevice }, { runCmdBackground }] = await Promise.all([ + import('./platforms/apple/core/simctl.ts'), + import('./utils/exec.ts'), + ]); + signal?.throwIfAborted(); + return runCmdBackground( + 'xcrun', + buildSimctlArgsForDevice(device, ['io', device.id, 'recordVideo', outputPath]), + { allowFailure: true }, + ); + }, +}); + +const unavailableScopedTransport: AppleSimulatorScreenRecordingTransport = Object.freeze({ + available: false, + mode: 'transport-composed', + start: async () => { + throw new Error( + 'Scoped Apple provider does not expose an Apple simulator screen-recording transport', + ); + }, +}); + +const transportScope = createScopedProvider(localTransport); + +export function resolveAppleSimulatorScreenRecordingTransport(): AppleSimulatorScreenRecordingTransport { + return transportScope.resolve(); +} + +export async function withAppleSimulatorScreenRecordingTransport( + transport: AppleSimulatorScreenRecordingTransport | undefined, + task: () => Promise, +): Promise { + return await transportScope.run(transport ?? unavailableScopedTransport, task); +} diff --git a/src/platform-runtime-screen-recording-finalizer-host.test.ts b/src/platform-runtime-screen-recording-finalizer-host.test.ts new file mode 100644 index 0000000000..99fb58a7f6 --- /dev/null +++ b/src/platform-runtime-screen-recording-finalizer-host.test.ts @@ -0,0 +1,33 @@ +import { expect, test, vi } from 'vitest'; +import { createScreenRecordingFinalizer } from './platform-runtime-screen-recording-finalizer-host.ts'; + +const video = vi.hoisted(() => ({ + stable: vi.fn(async () => {}), + playable: vi.fn(async () => {}), + isPlayable: vi.fn(async () => true), +})); +const telemetry = vi.hoisted(() => vi.fn(() => '/tmp/capture.telemetry.json')); +vi.mock('./utils/video.ts', () => ({ + waitForStableFile: video.stable, + waitForPlayableVideo: video.playable, + isPlayableVideo: video.isPlayable, +})); +vi.mock('./daemon/recording-telemetry.ts', () => ({ persistRecordingTelemetry: telemetry })); +vi.mock('./recording/overlay.ts', () => ({ + getRecordingOverlaySupportWarning: () => undefined, + overlayRecordingTouches: vi.fn(async () => {}), + trimRecordingStart: vi.fn(async () => {}), +})); + +test('requires stable playable media before publishing finalization telemetry', async () => { + const result = await createScreenRecordingFinalizer().complete({ + outputPath: '/tmp/capture.mp4', + showTouches: false, + gestureEvents: [], + targetLabel: 'test recording', + }); + + expect(video.stable).toHaveBeenCalledWith('/tmp/capture.mp4'); + expect(video.playable).toHaveBeenCalledWith('/tmp/capture.mp4'); + expect(result).toEqual({ telemetryPath: '/tmp/capture.telemetry.json' }); +}); diff --git a/src/platform-runtime-screen-recording-finalizer-host.ts b/src/platform-runtime-screen-recording-finalizer-host.ts new file mode 100644 index 0000000000..5a0e37c09a --- /dev/null +++ b/src/platform-runtime-screen-recording-finalizer-host.ts @@ -0,0 +1,56 @@ +import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/platform'; +import { + getRecordingOverlaySupportWarning, + overlayRecordingTouches, + trimRecordingStart, +} from './recording/overlay.ts'; +import { persistRecordingTelemetry } from './daemon/recording-telemetry.ts'; +import { isPlayableVideo, waitForPlayableVideo, waitForStableFile } from './utils/video.ts'; + +export function createScreenRecordingFinalizer(): ScreenRecordingRuntimeHost['finalize'] { + return Object.freeze({ complete: finalizeScreenRecording }); +} + +async function finalizeScreenRecording( + input: Parameters[0], +) { + await waitForStableFile(input.outputPath); + await waitForPlayableVideo(input.outputPath); + if (!(await isPlayableVideo(input.outputPath))) { + throw new Error(`recording was not finalized into a playable video: ${input.outputPath}`); + } + if (input.trimStartMs && input.trimStartMs > 0) { + await trimRecordingStart({ videoPath: input.outputPath, trimStartMs: input.trimStartMs }); + } + const telemetryPath = persistRecordingTelemetry({ + recording: { outPath: input.outputPath, gestureEvents: [...input.gestureEvents] }, + ...(input.trimStartMs === undefined ? {} : { trimStartMs: input.trimStartMs }), + }); + if (!input.showTouches || input.gestureEvents.length === 0) return { telemetryPath }; + return await overlayTouches(input, telemetryPath); +} + +async function overlayTouches( + input: Parameters[0], + telemetryPath: string, +) { + const warning = getRecordingOverlaySupportWarning(); + if (warning) return { telemetryPath, overlayWarning: warning }; + try { + await overlayRecordingTouches({ + videoPath: input.outputPath, + telemetryPath, + exportQuality: input.exportQuality, + targetLabel: input.targetLabel, + }); + if (!(await isPlayableVideo(input.outputPath))) { + throw new Error('recording post-processing produced an unplayable video'); + } + return { telemetryPath }; + } catch (error) { + return { + telemetryPath, + overlayWarning: `failed to overlay recording touches: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/src/platform-runtime-screen-recording-harmony-host.test.ts b/src/platform-runtime-screen-recording-harmony-host.test.ts new file mode 100644 index 0000000000..92e9c8fb79 --- /dev/null +++ b/src/platform-runtime-screen-recording-harmony-host.test.ts @@ -0,0 +1,37 @@ +import { expect, test, vi } from 'vitest'; +import { createHarmonyScreenRecordingHost } from './platform-runtime-screen-recording-harmony-host.ts'; + +const hdc = vi.hoisted(() => vi.fn()); +vi.mock('./platforms/harmonyos/hdc.ts', () => ({ runHarmonyHdc: hdc })); + +const device = { + platform: 'harmonyos' as const, + id: 'harmony-device', + name: 'Harmony device', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, +}; + +test('reports artifact removal only after a confirmed HDC success', async () => { + hdc.mockResolvedValueOnce({ stdout: '', stderr: 'permission denied', exitCode: 1 }); + const host = createHarmonyScreenRecordingHost(); + + await expect(host.remove(device, '/data/local/tmp/capture.mp4')).resolves.toBe(false); + expect(hdc).toHaveBeenCalledWith(device, ['shell', 'rm', '-f', '/data/local/tmp/capture.mp4'], { + allowFailure: true, + signal: undefined, + }); +}); + +test('reads the staged byte count from focused HDC stat output', async () => { + hdc.mockResolvedValueOnce({ stdout: '12345\n', stderr: '', exitCode: 0 }); + const host = createHarmonyScreenRecordingHost(); + + await expect(host.stagedFileSize(device, '/data/local/tmp/capture.mp4')).resolves.toBe(12_345); + expect(hdc).toHaveBeenCalledWith( + device, + ['shell', 'stat', '-c', '%s', '/data/local/tmp/capture.mp4'], + { allowFailure: true, signal: undefined }, + ); +}); diff --git a/src/platform-runtime-screen-recording-harmony-host.ts b/src/platform-runtime-screen-recording-harmony-host.ts new file mode 100644 index 0000000000..4516e58862 --- /dev/null +++ b/src/platform-runtime-screen-recording-harmony-host.ts @@ -0,0 +1,62 @@ +import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +export function createHarmonyScreenRecordingHost(): ScreenRecordingRuntimeHost['harmony'] { + return Object.freeze({ + start: async (device, fileName, signal) => + await hdc( + device, + [ + 'shell', + 'aa', + 'start', + '-b', + 'com.huawei.hmos.screenrecorder', + '-a', + 'com.huawei.hmos.screenrecorder.ServiceExtAbility', + '--ps', + 'CustomizedFileName', + fileName, + ], + signal, + ), + stop: async (device, signal) => + await hdc( + device, + [ + 'shell', + 'aa', + 'start', + '-b', + 'com.huawei.hmos.screenrecorder', + '-a', + 'com.huawei.hmos.screenrecorder.ServiceExtAbility', + ], + signal, + ), + findMedia: async (device, fileName, signal) => + (await hdc(device, ['shell', 'mediatool', 'query', fileName, '-u'], signal)).stdout.match( + /file:\/\/[^\s"']+/, + )?.[0], + stageMedia: async (device, input, signal) => + (await hdc(device, ['shell', 'mediatool', 'recv', input.mediaUri, input.remotePath], signal)) + .exitCode === 0, + stagedFileSize: async (device, remotePath, signal) => { + const result = await hdc(device, ['shell', 'stat', '-c', '%s', remotePath], signal); + if (result.exitCode !== 0) return undefined; + const size = Number(result.stdout.trim()); + return Number.isSafeInteger(size) && size > 0 ? size : undefined; + }, + pull: async (device, input, signal) => + await hdc(device, ['file', 'recv', input.remotePath, input.outputPath], signal), + remove: async (device, remotePath, signal) => + (await hdc(device, ['shell', 'rm', '-f', remotePath], signal)).exitCode === 0, + removeMedia: async (device, mediaUri, signal) => + (await hdc(device, ['shell', 'mediatool', 'delete', mediaUri], signal)).exitCode === 0, + }); +} + +async function hdc(device: DeviceInfo, args: string[], signal?: AbortSignal) { + const { runHarmonyHdc } = await import('./platforms/harmonyos/hdc.ts'); + return await runHarmonyHdc(device, args, { allowFailure: true, signal }); +} diff --git a/src/platform-runtime-screen-recording-host.test.ts b/src/platform-runtime-screen-recording-host.test.ts new file mode 100644 index 0000000000..297043e2e3 --- /dev/null +++ b/src/platform-runtime-screen-recording-host.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest'; +import { createScreenRecordingRuntimeHost } from './platform-runtime-screen-recording-host.ts'; + +test('composes the six focused recording host capabilities', () => { + const host = createScreenRecordingRuntimeHost(); + expect(Object.keys(host).sort()).toEqual([ + 'android', + 'apple', + 'finalize', + 'harmony', + 'outputs', + 'web', + ]); +}); diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts new file mode 100644 index 0000000000..a3259dadc3 --- /dev/null +++ b/src/platform-runtime-screen-recording-host.ts @@ -0,0 +1,88 @@ +import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/platform'; + +/** Lazy host-only composition; platform semantics stay package-owned. */ +export function createScreenRecordingRuntimeHost(): ScreenRecordingRuntimeHost { + const android: ScreenRecordingRuntimeHost['android'] = Object.freeze({ + resolve: async (device) => { + const { createAndroidScreenRecordingTransport } = + await import('./platform-runtime-screen-recording-android-host.ts'); + return await createAndroidScreenRecordingTransport(device); + }, + }); + const web: ScreenRecordingRuntimeHost['web'] = Object.freeze({ + resolve: async (device) => { + const { resolveWebScreenRecordingTransport } = + await import('./platform-runtime-screen-recording-web-host.ts'); + return await resolveWebScreenRecordingTransport(device); + }, + }); + const outputs: ScreenRecordingRuntimeHost['outputs'] = Object.freeze({ + prepare: async (outputPath) => { + const { createScreenRecordingOutputHost } = + await import('./platform-runtime-screen-recording-output-host.ts'); + await createScreenRecordingOutputHost().prepare(outputPath); + }, + }); + const finalize: ScreenRecordingRuntimeHost['finalize'] = Object.freeze({ + complete: async (input, signal) => { + const { createScreenRecordingFinalizer } = + await import('./platform-runtime-screen-recording-finalizer-host.ts'); + return await createScreenRecordingFinalizer().complete(input, signal); + }, + }); + return Object.freeze({ + apple: createLazyAppleHost(), + android, + harmony: createLazyHarmonyHost(), + web, + outputs, + finalize, + }); +} + +function createLazyAppleHost(): ScreenRecordingRuntimeHost['apple'] { + const load = async () => { + const { createAppleScreenRecordingHost } = + await import('./platform-runtime-screen-recording-apple-host.ts'); + return createAppleScreenRecordingHost(); + }; + return Object.freeze({ + availability: async (device) => (await load()).availability(device), + runRunner: async (device, request, signal) => + await (await load()).runRunner(device, request, signal), + startSimulator: async (device, outputPath, signal) => + await (await load()).startSimulator(device, outputPath, signal), + inspectProcess: async (marker) => await (await load()).inspectProcess(marker), + terminateProcess: async (marker) => await (await load()).terminateProcess(marker), + inspectRunner: async (device, sessionId, authority) => + await (await load()).inspectRunner(device, sessionId, authority), + retrieveRunnerRecording: async (device, remotePath, outputPath, signal) => + await (await load()).retrieveRunnerRecording(device, remotePath, outputPath, signal), + captureClockAnchor: async (device, appBundleId, signal) => + await (await load()).captureClockAnchor(device, appBundleId, signal), + isRunnerBundleId: async (bundleId) => await (await load()).isRunnerBundleId(bundleId), + }); +} + +function createLazyHarmonyHost(): ScreenRecordingRuntimeHost['harmony'] { + const load = async () => { + const { createHarmonyScreenRecordingHost } = + await import('./platform-runtime-screen-recording-harmony-host.ts'); + return createHarmonyScreenRecordingHost(); + }; + return Object.freeze({ + start: async (device, fileName, signal) => await (await load()).start(device, fileName, signal), + stop: async (device, signal) => await (await load()).stop(device, signal), + findMedia: async (device, fileName, signal) => + await (await load()).findMedia(device, fileName, signal), + stageMedia: async (device, input, signal) => + await (await load()).stageMedia(device, input, signal), + stagedFileSize: async (device, remotePath, signal) => + await (await load()).stagedFileSize(device, remotePath, signal), + pull: async (device, input, signal) => await (await load()).pull(device, input, signal), + remove: async (device, remotePath, signal) => + await (await load()).remove(device, remotePath, signal), + removeMedia: async (device, mediaUri, signal) => + await (await load()).removeMedia(device, mediaUri, signal), + }); +} diff --git a/src/platform-runtime-screen-recording-output-host.test.ts b/src/platform-runtime-screen-recording-output-host.test.ts new file mode 100644 index 0000000000..c71b8558b6 --- /dev/null +++ b/src/platform-runtime-screen-recording-output-host.test.ts @@ -0,0 +1,17 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTestSync } from './__tests__/test-utils/tmp-dir.ts'; +import { createScreenRecordingOutputHost } from './platform-runtime-screen-recording-output-host.ts'; + +test('prepares the closed recording output path after semantic validation', async () => { + const root = mkdtempForTestSync('agent-device-recording-output-'); + const outputPath = path.join(root, 'nested', 'capture.mp4'); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, 'stale'); + + await createScreenRecordingOutputHost().prepare(outputPath); + + expect(fs.existsSync(path.dirname(outputPath))).toBe(true); + expect(fs.existsSync(outputPath)).toBe(false); +}); diff --git a/src/platform-runtime-screen-recording-output-host.ts b/src/platform-runtime-screen-recording-output-host.ts new file mode 100644 index 0000000000..d2c4312a52 --- /dev/null +++ b/src/platform-runtime-screen-recording-output-host.ts @@ -0,0 +1,12 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/platform'; + +export function createScreenRecordingOutputHost(): ScreenRecordingRuntimeHost['outputs'] { + return Object.freeze({ + prepare: async (outputPath: string) => { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.rmSync(outputPath, { force: true }); + }, + }); +} diff --git a/src/platform-runtime-screen-recording-process-host.test.ts b/src/platform-runtime-screen-recording-process-host.test.ts new file mode 100644 index 0000000000..323bafff52 --- /dev/null +++ b/src/platform-runtime-screen-recording-process-host.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { + inspectManagedProcess, + terminateManagedProcessSet, +} from './platform-runtime-screen-recording-process-host.ts'; + +const state = vi.hoisted(() => ({ + alive: new Map(), + starts: new Map(), + commands: new Map(), + signaled: [] as Array<{ pids: readonly number[]; signal: NodeJS.Signals }>, +})); + +vi.mock('./utils/host-process.ts', async (importOriginal) => ({ + ...(await importOriginal()), + isProcessAlive: (pid: number) => state.alive.get(pid) ?? false, + isProcessZombie: () => false, + readProcessStartTime: (pid: number) => state.starts.get(pid) ?? null, + readProcessCommand: (pid: number) => state.commands.get(pid) ?? null, + listHostProcesses: async () => [ + { pid: 41, command: 'wrapper' }, + { pid: 42, ppid: 41, command: 'recorder child' }, + ], + signalPidsBestEffort: (pids: readonly number[], signal: NodeJS.Signals) => { + state.signaled.push({ pids, signal }); + for (const pid of pids) state.alive.set(pid, false); + return pids.length; + }, + waitForProcessExit: async (pid: number) => !(state.alive.get(pid) ?? false), +})); + +beforeEach(() => { + state.alive.clear(); + state.starts.clear(); + state.commands.clear(); + state.signaled.length = 0; +}); + +test('rejects PID reuse when start time or full command changes', () => { + state.alive.set(41, true); + state.starts.set(41, 'new-start'); + state.commands.set(41, 'different command'); + expect(inspectManagedProcess({ pid: 41, startTime: 'old-start', command: 'wrapper' })).toBe( + 'ownership-lost', + ); +}); + +test('terminates the exact captured process tree without path guessing', async () => { + for (const [pid, start, command] of [ + [41, 'root-start', 'wrapper'], + [42, 'child-start', 'recorder child'], + ] as const) { + state.alive.set(pid, true); + state.starts.set(pid, start); + state.commands.set(pid, command); + } + await expect( + terminateManagedProcessSet([ + { pid: 41, startTime: 'root-start', command: 'wrapper' }, + { pid: 42, startTime: 'child-start', command: 'recorder child' }, + ]), + ).resolves.toBe('terminated'); + expect(state.signaled[0]).toEqual({ pids: [41, 42], signal: 'SIGINT' }); +}); diff --git a/src/platform-runtime-screen-recording-process-host.ts b/src/platform-runtime-screen-recording-process-host.ts new file mode 100644 index 0000000000..934e80aa62 --- /dev/null +++ b/src/platform-runtime-screen-recording-process-host.ts @@ -0,0 +1,173 @@ +import type { + HostCommandResult, + ManagedProcessIdentity, + ManagedProcessOwnership, +} from '@agent-device/contracts/platform'; +import { + expandProcessTree, + isProcessAlive, + isProcessZombie, + listHostProcesses, + readProcessCommand, + readProcessStartTime, + signalPidsBestEffort, + waitForProcessExit, +} from './utils/host-process.ts'; + +const STOP_TIMEOUT_MS = 5_000; +const FORCE_STOP_TIMEOUT_MS = 2_000; +const PROCESS_LIST_TIMEOUT_MS = 1_000; + +type ManagedScreenRecordingProcess = Readonly<{ + child: Readonly<{ + pid?: number; + kill(signal?: NodeJS.Signals | number): boolean; + }>; + wait: Promise; +}>; + +export type ManagedProcessCommandMatcher = ( + persistedCommand: string, + observedCommand: string, +) => boolean; + +const exactCommandMatch: ManagedProcessCommandMatcher = (persisted, observed) => + persisted === observed; + +export async function resolveManagedProcessIdentity( + pid: number | undefined, +): Promise { + if (pid === undefined) return undefined; + const [startTime, command] = await Promise.all([ + Promise.resolve(readProcessStartTime(pid)), + Promise.resolve(readProcessCommand(pid)), + ]); + return startTime && command ? Object.freeze({ pid, startTime, command }) : undefined; +} + +export async function resolveManagedProcessTree( + root: ManagedProcessIdentity, +): Promise { + const processes = await listHostProcesses({ timeoutMs: PROCESS_LIST_TIMEOUT_MS }); + const tree = expandProcessTree([root.pid], processes); + const markers = await Promise.all( + tree.map(async (processInfo) => await resolveManagedProcessIdentity(processInfo.pid)), + ); + const complete = markers.filter( + (marker): marker is ManagedProcessIdentity => marker !== undefined, + ); + return Object.freeze([root, ...complete.filter((marker) => marker.pid !== root.pid)]); +} + +export function inspectManagedProcess( + marker: ManagedProcessIdentity, + commandMatches: ManagedProcessCommandMatcher = exactCommandMatch, +): ManagedProcessOwnership { + if (!isProcessAlive(marker.pid) || isProcessZombie(marker.pid)) return 'missing'; + return markerMatches(marker, commandMatches) ? 'owned-alive' : 'ownership-lost'; +} + +export async function terminateManagedProcessSet( + persisted: readonly ManagedProcessIdentity[], + background?: ManagedScreenRecordingProcess, + commandMatches: ManagedProcessCommandMatcher = exactCommandMatch, +): Promise<'terminated' | 'already-missing' | 'ownership-lost'> { + const inspected = persisted.map((marker) => ({ + marker, + ownership: inspectManagedProcess(marker, commandMatches), + })); + if (inspected.some(({ ownership }) => ownership === 'ownership-lost')) { + return 'ownership-lost'; + } + const live = inspected + .filter(({ ownership }) => ownership === 'owned-alive') + .map(({ marker }) => marker); + if (live.length === 0) return 'already-missing'; + const root = persisted[0]; + const expanded = + root && inspectManagedProcess(root, commandMatches) === 'owned-alive' + ? await resolveManagedProcessTree(root) + : []; + const markers = uniqueMarkers([...live, ...expanded]); + if ( + markers.some((marker) => inspectManagedProcess(marker, commandMatches) === 'ownership-lost') + ) { + return 'ownership-lost'; + } + for (const [signal, timeoutMs] of [ + ['SIGINT', STOP_TIMEOUT_MS], + ['SIGTERM', FORCE_STOP_TIMEOUT_MS], + ['SIGKILL', FORCE_STOP_TIMEOUT_MS], + ] as const) { + signalMarkerSet(markers, signal, background); + if (await markerSetExits(markers, timeoutMs, background?.wait, commandMatches)) { + return 'terminated'; + } + } + return 'ownership-lost'; +} + +function signalMarkerSet( + markers: readonly ManagedProcessIdentity[], + signal: NodeJS.Signals, + background?: ManagedScreenRecordingProcess, +): void { + const directPid = background?.child.pid; + signalPidsBestEffort( + markers.map(({ pid }) => pid).filter((pid) => pid !== directPid), + signal, + ); + if (directPid !== undefined && markers.some(({ pid }) => pid === directPid)) { + background?.child.kill(signal); + } +} + +async function markerSetExits( + markers: readonly ManagedProcessIdentity[], + timeoutMs: number, + directWait?: Promise, + commandMatches: ManagedProcessCommandMatcher = exactCommandMatch, +): Promise { + const directPid = markers[0]?.pid; + const directSettled = directWait ? await settlesWithin(directWait, timeoutMs) : false; + await Promise.all( + markers + .filter(({ pid }) => !directSettled || pid !== directPid) + .map(async ({ pid }) => await waitForProcessExit(pid, timeoutMs)), + ); + return markers.every( + (marker) => + (directSettled && marker.pid === directPid) || + inspectManagedProcess(marker, commandMatches) === 'missing', + ); +} + +function uniqueMarkers( + markers: readonly ManagedProcessIdentity[], +): readonly ManagedProcessIdentity[] { + return [...new Map(markers.map((marker) => [marker.pid, marker])).values()]; +} + +function markerMatches( + marker: ManagedProcessIdentity, + commandMatches: ManagedProcessCommandMatcher, +): boolean { + const observedCommand = readProcessCommand(marker.pid); + return ( + marker.startTime.length > 0 && + marker.command.length > 0 && + readProcessStartTime(marker.pid) === marker.startTime && + observedCommand !== null && + commandMatches(marker.command, observedCommand) + ); +} + +async function settlesWithin(wait: Promise, timeoutMs: number): Promise { + return await Promise.race([ + wait.then( + () => true, + () => true, + ), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); +} diff --git a/src/platform-runtime-screen-recording-web-host.test.ts b/src/platform-runtime-screen-recording-web-host.test.ts new file mode 100644 index 0000000000..0472674bbe --- /dev/null +++ b/src/platform-runtime-screen-recording-web-host.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'vitest'; +import { withWebProvider } from './platforms/web/provider.ts'; +import { resolveWebScreenRecordingTransport } from './platform-runtime-screen-recording-web-host.ts'; + +const web = { + platform: 'web' as const, + id: 'browser', + name: 'Browser', + kind: 'device' as const, + target: 'desktop' as const, + booted: true, +}; + +test('never falls back to an unscoped local web recorder', async () => { + expect(await resolveWebScreenRecordingTransport(web)).toBeUndefined(); + const calls: string[] = []; + await withWebProvider( + { + startRecording: async (outputPath: string) => calls.push(`start:${outputPath}`), + stopRecording: async () => calls.push('stop'), + } as never, + async () => { + const transport = await resolveWebScreenRecordingTransport(web); + if (!transport) throw new Error('missing scoped web recording transport'); + await transport.start('/tmp/capture.webm'); + await transport.stop(); + }, + ); + expect(calls).toEqual(['start:/tmp/capture.webm', 'stop']); +}); diff --git a/src/platform-runtime-screen-recording-web-host.ts b/src/platform-runtime-screen-recording-web-host.ts new file mode 100644 index 0000000000..7d19789550 --- /dev/null +++ b/src/platform-runtime-screen-recording-web-host.ts @@ -0,0 +1,16 @@ +import type { ScreenRecordingRuntimeHost } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +export async function resolveWebScreenRecordingTransport( + device: DeviceInfo, +): ReturnType { + if (device.platform !== 'web') return undefined; + const { hasScopedWebProvider, resolveWebProvider } = await import('./platforms/web/provider.ts'); + if (!hasScopedWebProvider()) return undefined; + const provider = resolveWebProvider(); + if (!provider.startRecording || !provider.stopRecording) return undefined; + return Object.freeze({ + start: async (outputPath: string) => await provider.startRecording!(outputPath), + stop: async () => await provider.stopRecording!(), + }); +} diff --git a/src/platforms/android/__tests__/ime-lifecycle.test.ts b/src/platforms/android/__tests__/ime-lifecycle.test.ts index dc32fa3311..f9eab52156 100644 --- a/src/platforms/android/__tests__/ime-lifecycle.test.ts +++ b/src/platforms/android/__tests__/ime-lifecycle.test.ts @@ -42,6 +42,7 @@ import { resetAndroidTestImeActivationCacheForTests, } from '../ime-lifecycle.ts'; import { teardownSessionResources } from '../../../daemon/session-teardown.ts'; +import { SessionStore } from '../../../daemon/session-store.ts'; import type { SessionState } from '../../../daemon/types.ts'; const LATIN_IME = 'com.google.android.inputmethod.latin/.LatinIME'; @@ -253,6 +254,7 @@ test('a failed restore keeps the recovery value AND the marker for a later retry test('session teardown fails when a real IME restore reports set-failed', async () => { const state = fakeDeviceState(LATIN_IME); const stateDir = await makeStateDir(); + const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); const session: SessionState = { name: 'ime-restore-failure', device: ANDROID_EMULATOR, @@ -270,6 +272,7 @@ test('session teardown fails when a real IME restore reports set-failed', async appLog: 'already-settled', session, sessionName: session.name, + sessionStore, stateDir, }), /android_ime: Android test IME could not be restored/, diff --git a/src/platforms/apple/core/__tests__/runner-adoption.test.ts b/src/platforms/apple/core/__tests__/runner-adoption.test.ts index aed8ef9702..f338bf066d 100644 --- a/src/platforms/apple/core/__tests__/runner-adoption.test.ts +++ b/src/platforms/apple/core/__tests__/runner-adoption.test.ts @@ -143,6 +143,7 @@ test('adoption succeeds for a live, matching, probe-healthy runner', async () => expect(session?.port).toBe(lease.port); expect(session?.ready).toBe(true); expect(session?.child.pid).toBe(424242); + expect(session?.sessionId).toBe(lease.sessionId); expect(session?.xctestrunArtifact?.reason).toBe('adopted_from_lease'); // Adoption transfers ownership: the lease on disk now belongs to us. expect(readStaleRunnerLease(simulator.id)).toBeNull(); diff --git a/src/platforms/apple/core/__tests__/runner-recovery-wiring.test.ts b/src/platforms/apple/core/__tests__/runner-recovery-wiring.test.ts index 45f41fccaa..e76394af9c 100644 --- a/src/platforms/apple/core/__tests__/runner-recovery-wiring.test.ts +++ b/src/platforms/apple/core/__tests__/runner-recovery-wiring.test.ts @@ -124,3 +124,17 @@ test('an unrecoverable lifecycle state still reaches recovery and reports the in ).rejects.toThrow(/invalidated the runner session/); assert.ok(server.requests.some((request) => request.command === 'status')); }); + +test('an exact-session command never dispatches to a replacement runner', async () => { + server = await startFakeRunnerServer({ recordStop: [{ kind: 'ok', data: {} }] }); + const replacement = seedSession(server.port); + + await expect( + runAppleRunnerCommand( + IOS_SIMULATOR, + { command: 'recordStop' }, + { expectedRunnerSessionId: `${replacement.sessionId}:replaced` }, + ), + ).rejects.toThrow('runner session ownership changed'); + assert.deepEqual(server.requests, []); +}); diff --git a/src/platforms/apple/core/runner/runner-adoption.ts b/src/platforms/apple/core/runner/runner-adoption.ts index 70a2a93b51..ca32d8698c 100644 --- a/src/platforms/apple/core/runner/runner-adoption.ts +++ b/src/platforms/apple/core/runner/runner-adoption.ts @@ -20,7 +20,6 @@ import { type RunnerXctestrunArtifact, } from './runner-xctestrun.ts'; import { - buildRunnerSessionId, normalizeRunnerStartupTimeoutMs, type RunnerProcessHandle, type RunnerSession, @@ -48,7 +47,7 @@ export function isIosRunnerDetachEnabled(env: NodeJS.ProcessEnv = process.env): // lock, like the rest of session startup. export async function tryAdoptRunnerSessionFromLease( device: DeviceInfo, - options: { startupTimeoutMs?: number }, + options: { startupTimeoutMs?: number; expectedRunnerSessionId?: string }, ): Promise { if (device.kind !== 'simulator' || !isIosRunnerDetachEnabled()) return null; // Custom simulator sets run behind the XCTestDevices redirect, whose @@ -67,6 +66,13 @@ export async function tryAdoptRunnerSessionFromLease( return null; }; + if ( + options.expectedRunnerSessionId !== undefined && + lease.sessionId !== options.expectedRunnerSessionId + ) { + return skip('session_identity_mismatch'); + } + const runnerPid = lease.runnerPid; if (!runnerPid) return skip('runner_pid_missing'); if (!isProcessAlive(runnerPid)) return skip('runner_process_dead'); @@ -141,7 +147,7 @@ function buildAdoptedRunnerSession( expectedDerived: string, options: { startupTimeoutMs?: number }, ): RunnerSession & { lease: RunnerLease } { - const sessionId = buildRunnerSessionId(device.id, lease.port); + const sessionId = lease.sessionId; const artifact: RunnerXctestrunArtifact = { xctestrunPath: lease.xctestrunPath, derived: expectedDerived, diff --git a/src/platforms/apple/core/runner/runner-lifecycle.ts b/src/platforms/apple/core/runner/runner-lifecycle.ts index bf4fbf3af8..ed168884fa 100644 --- a/src/platforms/apple/core/runner/runner-lifecycle.ts +++ b/src/platforms/apple/core/runner/runner-lifecycle.ts @@ -5,6 +5,7 @@ import { isRequestCanceledError } from '../../../../request/cancel.ts'; import { RUNNER_COMMAND_TIMEOUT_MS, RUNNER_STARTUP_TIMEOUT_MS } from './runner-transport.ts'; import { type RunnerSession, + assertExpectedRunnerSession, ensureRunnerSession, getRunnerSessionSnapshot, invalidateRunnerSession, @@ -273,6 +274,7 @@ export async function executeRunnerCommand( recycleBootBegun = true; } session = await ensureRunnerSession(device, options); + assertExpectedRunnerSession(session, options.expectedRunnerSessionId); if (recycleBootBegun) { commitRunnerRecycle(recycleKey); } @@ -289,6 +291,7 @@ export async function executeRunnerCommand( signal, ); } catch (err) { + if (options.expectedRunnerSessionId !== undefined) throw err; const appErr = asAppError(err, 'COMMAND_FAILED'); if (session && !session.ready && isRequestCanceledError(appErr)) { await invalidateRunnerSessionBestEffort(session, 'runner_startup_request_canceled'); diff --git a/src/platforms/apple/core/runner/runner-provider.ts b/src/platforms/apple/core/runner/runner-provider.ts index 0df2e6cd7e..b070affa64 100644 --- a/src/platforms/apple/core/runner/runner-provider.ts +++ b/src/platforms/apple/core/runner/runner-provider.ts @@ -18,6 +18,11 @@ export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & { startupTimeoutMs?: number; requestId?: string; runnerLeaseContext?: RunnerLogicalLeaseContext; + /** + * Restricts a command to the already-owned durable runner session. Exact + * cleanup must never start, adopt, or dispatch to a replacement session. + */ + expectedRunnerSessionId?: string; }; export type AppleRunnerLifecycleOptions = AppleRunnerCommandOptions & { diff --git a/src/platforms/apple/core/runner/runner-session.ts b/src/platforms/apple/core/runner/runner-session.ts index d35485a913..6dc5c4dc53 100644 --- a/src/platforms/apple/core/runner/runner-session.ts +++ b/src/platforms/apple/core/runner/runner-session.ts @@ -116,6 +116,7 @@ export async function ensureRunnerSession( return await withRunnerSessionLock(device.id, async () => { const existing = runnerSessions.get(device.id); if (existing) { + assertExpectedRunnerSession(existing, options.expectedRunnerSessionId); const reusable = await resolveReusableRunnerSession(device, existing); if (reusable) return reusable; } @@ -155,6 +156,7 @@ async function startRunnerSessionWithLease( async () => await tryAdoptRunnerSessionFromLease(device, { startupTimeoutMs: options.startupTimeoutMs, + expectedRunnerSessionId: options.expectedRunnerSessionId, }), ); if (adopted) { @@ -163,6 +165,7 @@ async function startRunnerSessionWithLease( runnerSessions.set(device.id, adopted); return adopted; } + assertRunnerSessionMayStart(options.expectedRunnerSessionId); await measureRunnerStartupStep(startupTimings, 'cleanup_stale_xcodebuild', async () => { await prepareRunnerLeaseForStartup(device.id, runnerLeaseCleanupAdapter, logicalLeaseContext); }); @@ -309,6 +312,27 @@ async function startRunnerSessionWithLease( return session; } +export function assertExpectedRunnerSession( + session: Pick, + expectedRunnerSessionId: string | undefined, +): void { + if (expectedRunnerSessionId !== undefined && session.sessionId !== expectedRunnerSessionId) { + throw runnerSessionOwnershipChanged(); + } +} + +function assertRunnerSessionMayStart(expectedRunnerSessionId: string | undefined): void { + if (expectedRunnerSessionId !== undefined) throw runnerSessionOwnershipChanged(); +} + +function runnerSessionOwnershipChanged(): AppError { + return new AppError( + 'COMMAND_FAILED', + 'Apple runner session ownership changed before command dispatch', + { reason: 'runner_session_ownership_changed' }, + ); +} + async function resolveReusableRunnerSession( device: DeviceInfo, existing: RunnerSession, diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 2f7a86e383..b1301a1a40 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -2,12 +2,7 @@ import { appleOsCapabilities } from './capabilities.ts'; import type { PlatformPlugin } from '@agent-device/contracts/platform'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { isAudioProbeSupportedDevice } from '@agent-device/contracts/platform'; -import { - isMacOs, - isTvOsDevice, - resolveDeviceAppleOs, - type DeviceInfo, -} from '@agent-device/kernel/device'; +import { isTvOsDevice, resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerContext } from '@agent-device/contracts/interaction'; // --------------------------------------------------------------------------- @@ -87,7 +82,6 @@ const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> [PUBLIC_COMMANDS.reinstall]: supportsAppInstallation, [PUBLIC_COMMANDS.installFromSource]: supportsAppInstallation, [PUBLIC_COMMANDS.perf]: supportsCoreDevicePhysicalOperation, - [PUBLIC_COMMANDS.record]: supportsCoreDevicePhysicalOperation, [PUBLIC_COMMANDS.push]: supportsAppAndDeviceLifecycle, [PUBLIC_COMMANDS.home]: supportsAppAndDeviceLifecycle, [PUBLIC_COMMANDS.appSwitcher]: supportsAppAndDeviceLifecycle, @@ -113,7 +107,6 @@ const APPLE_UNSUPPORTED_HINT_BY_DEFAULT: Record< [PUBLIC_COMMANDS.reinstall]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.installFromSource]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.perf]: coreDeviceOnlyPhysicalOperationHint, - [PUBLIC_COMMANDS.record]: coreDeviceOnlyPhysicalOperationHint, [PUBLIC_COMMANDS.viewport]: (device) => device.platform === 'apple' ? 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.' @@ -156,13 +149,6 @@ export const applePlugin = { // wraps the else-arm of the former `buildPerfResponseData` sampling branch: every // supported Apple device routes to the Apple `perf metrics` sampler. perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'apple' }, - // Wraps the Apple arm of `resolveRecordingBackendForDevice` verbatim: macOS -> - // 'macos'; an iOS `device` -> 'ios-device'; every other iOS kind (simulator, incl. - // tvOS/iPadOS/visionOS) -> 'ios-simulator'. - recording: { - resolveBackendTag: (device: DeviceInfo) => - isMacOs(device) ? 'macos' : device.kind === 'device' ? 'ios-device' : 'ios-simulator', - }, // Declares the platform-gated request provider resolvers the Apple family owns: the // runner + tool providers (formerly gated by `isApplePlatform(device.platform)`). providers: { platformGatedResolvers: ['appleRunnerProvider', 'appleToolProvider'] }, diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index d91012013b..17f17adc76 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -18,7 +18,11 @@ import type { import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { AsyncLocalStorage } from 'node:async_hooks'; -import type { AppleRunnerProviderResolver } from './daemon/request-platform-providers.ts'; +import type { + AppleRunnerProviderResolver, + AppleRunnerScreenRecordingTransportResolver, +} from './daemon/request-platform-providers.ts'; +import type { AppleRunnerScreenRecordingTransport } from './platform-runtime-screen-recording-apple-runner-transport.ts'; import type { AppleRunnerCommandExecutor, AppleRunnerProvider, @@ -30,6 +34,12 @@ type AppleRunnerRuntimeExtension = ProviderDeviceRuntime & { ): AppleRunnerProvider | AppleRunnerCommandExecutor | undefined; }; +type AppleRunnerScreenRecordingRuntimeExtension = ProviderDeviceRuntime & { + getAppleRunnerScreenRecordingTransport( + device: DeviceInfo, + ): AppleRunnerScreenRecordingTransport | undefined; +}; + export type ProviderDeviceRuntimeRequestProviders = { providerRuntimeIds: readonly string[]; providerRuntimeRequiredIds: readonly string[]; @@ -39,6 +49,7 @@ export type ProviderDeviceRuntimeRequestProviders = { cloudArtifactProvider?: CloudArtifactProvider; deviceInventorySource?: ProviderDeviceInventorySource; appleRunnerProvider?: AppleRunnerProviderResolver; + appleRunnerScreenRecordingTransport?: AppleRunnerScreenRecordingTransportResolver; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; }; @@ -145,11 +156,29 @@ export function createProviderDeviceRuntimeRequestProviders( cloudArtifactProvider: composeCloudArtifactProvider(runtimes), deviceInventorySource: composeDeviceInventorySource(runtimes), appleRunnerProvider: composeAppleRunnerProviderResolver(runtimes), + appleRunnerScreenRecordingTransport: + composeAppleRunnerScreenRecordingTransportResolver(runtimes), providerDeviceRuntimeScope: async (task) => await withProviderDeviceRuntimeScope(runtimes, task), }; } +function composeAppleRunnerScreenRecordingTransportResolver( + runtimes: ProviderDeviceRuntime[], +): AppleRunnerScreenRecordingTransportResolver | undefined { + if (!runtimes.some(hasAppleRunnerScreenRecordingTransport)) return undefined; + return (context) => { + for (const runtime of runtimes) { + if (!hasAppleRunnerScreenRecordingTransport(runtime) || !runtime.ownsDevice(context.device)) { + continue; + } + const transport = runtime.getAppleRunnerScreenRecordingTransport(context.device); + if (transport) return transport; + } + return undefined; + }; +} + function composeAppleRunnerProviderResolver( runtimes: ProviderDeviceRuntime[], ): AppleRunnerProviderResolver | undefined { @@ -172,6 +201,15 @@ function hasAppleRunnerProvider( ); } +function hasAppleRunnerScreenRecordingTransport( + runtime: ProviderDeviceRuntime, +): runtime is AppleRunnerScreenRecordingRuntimeExtension { + return ( + 'getAppleRunnerScreenRecordingTransport' in runtime && + typeof runtime.getAppleRunnerScreenRecordingTransport === 'function' + ); +} + function composeExpiredLeaseRecovery( runtimes: ProviderDeviceRuntime[], ): ProviderExpiredLeaseRecovery | undefined { diff --git a/src/recording/output-path.test.ts b/src/recording/output-path.test.ts new file mode 100644 index 0000000000..b3ed31398f --- /dev/null +++ b/src/recording/output-path.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest'; +import { resolveRecordingOutputPaths } from './output-path.ts'; + +test('preserves platform-specific default recording extensions', () => { + const defaults = resolveRecordingOutputPaths({ platform: 'web', cwd: '/workspace' }); + expect(defaults.requestedPath).toMatch(/^\.\/recording-\d+\.webm$/); + expect(defaults.outputPath).toMatch(/^\/workspace\/recording-\d+\.webm$/); + expect(resolveRecordingOutputPaths({ requestedPath: '/tmp/capture', platform: 'web' })).toEqual({ + requestedPath: '/tmp/capture.webm', + outputPath: '/tmp/capture.webm', + }); +}); + +test('keeps display paths distinct from expanded native output paths', () => { + expect( + resolveRecordingOutputPaths({ + requestedPath: './capture.mp4', + platform: 'android', + cwd: '/workspace/project', + }), + ).toEqual({ + requestedPath: './capture.mp4', + outputPath: '/workspace/project/capture.mp4', + }); + + const homePaths = resolveRecordingOutputPaths({ + requestedPath: '~/capture.mp4', + platform: 'android', + }); + expect(homePaths.requestedPath).toBe('~/capture.mp4'); + expect(homePaths.outputPath).not.toBe(homePaths.requestedPath); + expect(homePaths.outputPath).toMatch(/\/capture\.mp4$/); +}); diff --git a/src/recording/output-path.ts b/src/recording/output-path.ts index 17cf1fc0bd..81813d5f65 100644 --- a/src/recording/output-path.ts +++ b/src/recording/output-path.ts @@ -1,8 +1,9 @@ import path from 'node:path'; import type { Platform, PlatformSelector } from '@agent-device/kernel/device'; +import { resolveUserPath } from '../utils/path-resolution.ts'; const DEFAULT_RECORDING_EXTENSION = '.mp4'; -export const WEB_RECORDING_EXTENSION = '.webm'; +const WEB_RECORDING_EXTENSION = '.webm'; export function recordingExtensionForPlatform( platform: Platform | PlatformSelector | undefined, @@ -17,3 +18,25 @@ export function appendRecordingExtensionWhenMissing(filePath: string, extension: export function defaultRecordingPath(platform: Platform | undefined): string { return `./recording-${Date.now()}${recordingExtensionForPlatform(platform)}`; } + +export type RecordingOutputPaths = Readonly<{ + requestedPath: string; + outputPath: string; +}>; + +export function resolveRecordingOutputPaths(params: { + requestedPath?: string; + platform: Platform; + cwd?: string; +}): RecordingOutputPaths { + const requestedPath = + params.requestedPath === undefined + ? defaultRecordingPath(params.platform) + : params.platform === 'web' + ? appendRecordingExtensionWhenMissing(params.requestedPath, WEB_RECORDING_EXTENSION) + : params.requestedPath; + return Object.freeze({ + requestedPath, + outputPath: resolveUserPath(requestedPath, { cwd: params.cwd }), + }); +} diff --git a/src/utils/host-process.ts b/src/utils/host-process.ts index 9d68150d8a..812beb0abf 100644 --- a/src/utils/host-process.ts +++ b/src/utils/host-process.ts @@ -3,6 +3,7 @@ import { runCmd, runCmdSync } from './exec.ts'; import { sleep } from './timeouts.ts'; const PS_TIMEOUT_MS = 1_000; +const HOST_PS_COMMAND = process.platform === 'win32' ? 'ps' : '/bin/ps'; export type HostProcessInfo = { pid: number; @@ -95,7 +96,7 @@ export function readHostProcessIdentityObservations( function readProcessField(pid: number, field: 'lstart=' | 'command=' | 'state='): string | null { if (!Number.isInteger(pid) || pid <= 0) return null; try { - const result = runCmdSync('ps', ['-p', String(pid), '-o', field], { + const result = runCmdSync(HOST_PS_COMMAND, ['-p', String(pid), '-o', field], { allowFailure: true, timeoutMs: PS_TIMEOUT_MS, }); @@ -124,10 +125,14 @@ export function parseHostProcessList(stdout: string): HostProcessInfo[] { export async function listHostProcesses( options: ListHostProcessesOptions, ): Promise { - const result = await (options.runCommand ?? runCmd)('ps', ['-ax', '-o', 'pid=,ppid=,command='], { - allowFailure: true, - timeoutMs: options.timeoutMs, - }); + const result = await (options.runCommand ?? runCmd)( + options.runCommand ? 'ps' : HOST_PS_COMMAND, + ['-ax', '-o', 'pid=,ppid=,command='], + { + allowFailure: true, + timeoutMs: options.timeoutMs, + }, + ); if (result.exitCode !== 0) return []; return parseHostProcessList(result.stdout); } diff --git a/src/utils/video-webm.test.ts b/src/utils/video-webm.test.ts new file mode 100644 index 0000000000..2540e8e644 --- /dev/null +++ b/src/utils/video-webm.test.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { likelyPlayableWebmContainer } from '../__tests__/test-utils/index.ts'; +import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; +import { hasPlayableWebmStructure } from './video-webm.ts'; + +const directory = mkdtempForTestSync('agent-device-video-webm-structure-'); +const playableWebm = likelyPlayableWebmContainer(); + +test('recognizes a real WebM video track and complete media block', () => { + expect(hasPlayableWebmStructure(writeFixture('capture.webm', playableWebm))).toBe(true); +}); + +test('accepts a finalized WebM whose Segment retains its legal unknown size', () => { + expect( + hasPlayableWebmStructure( + writeFixture('unknown-segment-size.webm', withUnknownSegmentSize(playableWebm)), + ), + ).toBe(true); +}); + +test.each([ + ['an extension without a container', Buffer.from('webm')], + ['an empty Segment', emptyWebmContainer()], + ['a media block truncated before its declared end', truncateInsideMediaBlock(playableWebm)], + [ + 'DocType bytes nested inside an unrelated header element', + webmWithNestedFakeDocumentType(playableWebm), + ], +])('rejects %s', (name, bytes) => { + expect(hasPlayableWebmStructure(writeFixture(`${name}.webm`, bytes))).toBe(false); +}); + +function writeFixture(name: string, bytes: Buffer): string { + const filePath = path.join(directory, name); + fs.writeFileSync(filePath, bytes); + return filePath; +} + +function emptyWebmContainer(): Buffer { + return Buffer.from( + '1a45dfa39f4286810142f7810142f2810442f381084282847765626d42878104428581021853806780', + 'hex', + ); +} + +function withUnknownSegmentSize(webm: Buffer): Buffer { + const result = Buffer.from(webm); + Buffer.from('01ffffffffffffff', 'hex').copy(result, 40); + return result; +} + +function truncateInsideMediaBlock(webm: Buffer): Buffer { + const block = webm.indexOf(Buffer.from([0xa3, 0xa3])); + expect(block).toBeGreaterThan(0); + return webm.subarray(0, block + 6); +} + +function webmWithNestedFakeDocumentType(webm: Buffer): Buffer { + const segment = webm.subarray(36); + const headerWithDocumentTypeInsideVoid = Buffer.from( + '1a45dfa3a14286810142f7810142f2810442f38108ec874282847765626d4287810442858102', + 'hex', + ); + return Buffer.concat([headerWithDocumentTypeInsideVoid, segment]); +} diff --git a/src/utils/video-webm.ts b/src/utils/video-webm.ts new file mode 100644 index 0000000000..ee5b8063d7 --- /dev/null +++ b/src/utils/video-webm.ts @@ -0,0 +1,242 @@ +import fs from 'node:fs'; + +const WEBM_PROBE_BYTES = 1024 * 1024; + +const ID = { + block: 0xa1, + blockGroup: 0xa0, + cluster: 0x1f43b675, + codecId: 0x86, + documentType: 0x4282, + ebml: 0x1a45dfa3, + segment: 0x18538067, + simpleBlock: 0xa3, + trackEntry: 0xae, + trackNumber: 0xd7, + tracks: 0x1654ae6b, + trackType: 0x83, +} as const; + +type EbmlElement = Readonly<{ + id: number; + dataStart: number; + dataEnd: number; + nextOffset: number; + complete: boolean; + unknownSize: boolean; +}>; + +type EbmlVariableInteger = Readonly<{ + length: number; + value: number; + unknown: boolean; +}>; + +export function hasPlayableWebmStructure(filePath: string): boolean { + const source = readWebmProbe(filePath); + if (!source) return false; + const { bytes, fileSize } = source; + const header = readEbmlElement(bytes, 0, bytes.length, fileSize); + if (!header || header.id !== ID.ebml || !header.complete || header.unknownSize) return false; + if (!hasWebmDocumentType(bytes, header)) return false; + + const segment = readEbmlElement(bytes, header.nextOffset, bytes.length, fileSize); + if (!segment || segment.id !== ID.segment || !hasPlausibleExtent(segment, fileSize)) return false; + return hasVideoTrackWithMedia(bytes, segment, fileSize); +} + +function readWebmProbe(filePath: string): { bytes: Buffer; fileSize: number } | undefined { + try { + const fd = fs.openSync(filePath, 'r'); + try { + const fileSize = fs.fstatSync(fd).size; + if (fileSize <= 0) return undefined; + const bytes = Buffer.alloc(Math.min(fileSize, WEBM_PROBE_BYTES)); + return fs.readSync(fd, bytes, 0, bytes.length, 0) === bytes.length + ? { bytes, fileSize } + : undefined; + } finally { + fs.closeSync(fd); + } + } catch { + return undefined; + } +} + +function hasWebmDocumentType(bytes: Buffer, header: EbmlElement): boolean { + const children = readCompleteChildren(bytes, header); + const documentType = children?.find((child) => child.id === ID.documentType); + return ( + documentType !== undefined && + bytes.toString('ascii', documentType.dataStart, documentType.dataEnd) === 'webm' + ); +} + +function hasVideoTrackWithMedia(bytes: Buffer, segment: EbmlElement, fileSize: number): boolean { + const videoTracks = new Set(); + const mediaTracks = new Set(); + for (let offset = segment.dataStart; offset < segment.dataEnd; ) { + const child = readEbmlElement(bytes, offset, segment.dataEnd, fileSize); + if (!child || !hasPlausibleExtent(child, fileSize)) return false; + if (child.id === ID.tracks) collectVideoTracks(bytes, child, videoTracks); + if (child.id === ID.cluster) collectMediaTracks(bytes, child, fileSize, mediaTracks); + if (setsIntersect(videoTracks, mediaTracks)) return true; + if (!child.complete || child.unknownSize) return false; + offset = child.nextOffset; + } + return false; +} + +function collectVideoTracks(bytes: Buffer, tracks: EbmlElement, result: Set): void { + const children = readCompleteChildren(bytes, tracks); + if (!children) return; + for (const child of children) { + if (child.id === ID.trackEntry) { + const track = readVideoTrack(bytes, child); + if (track !== undefined) result.add(track); + } + } +} + +function readVideoTrack(bytes: Buffer, entry: EbmlElement): number | undefined { + const children = readCompleteChildren(bytes, entry); + const number = readUnsignedChild(bytes, children, ID.trackNumber); + const type = readUnsignedChild(bytes, children, ID.trackType); + const codecElement = children?.find((child) => child.id === ID.codecId); + const codec = codecElement + ? bytes.toString('ascii', codecElement.dataStart, codecElement.dataEnd) + : undefined; + return type === 1 && codec?.startsWith('V_') ? number : undefined; +} + +function collectMediaTracks( + bytes: Buffer, + cluster: EbmlElement, + fileSize: number, + result: Set, +): void { + for (let offset = cluster.dataStart; offset < cluster.dataEnd; ) { + const child = readEbmlElement(bytes, offset, cluster.dataEnd, fileSize); + if (!child || !hasPlausibleExtent(child, fileSize)) return; + if (child.id === ID.simpleBlock) addMediaTrack(bytes, child, result); + if (child.id === ID.blockGroup) collectBlockGroupMedia(bytes, child, result); + if (!child.complete || child.unknownSize) return; + offset = child.nextOffset; + } +} + +function collectBlockGroupMedia(bytes: Buffer, group: EbmlElement, result: Set): void { + const block = readCompleteChildren(bytes, group)?.find((child) => child.id === ID.block); + if (block) addMediaTrack(bytes, block, result); +} + +function addMediaTrack(bytes: Buffer, block: EbmlElement, result: Set): void { + if (!block.complete || block.unknownSize) return; + const track = readEbmlVariableInteger(bytes, block.dataStart); + if (!track || track.unknown || block.dataEnd - block.dataStart < track.length + 4) return; + result.add(track.value); +} + +function readUnsignedInteger(bytes: Buffer, element: EbmlElement): number | undefined { + const length = element.dataEnd - element.dataStart; + if (length < 1 || length > 6) return undefined; + let value = 0; + for (let offset = element.dataStart; offset < element.dataEnd; offset += 1) { + value = value * 256 + bytes[offset]!; + } + return Number.isSafeInteger(value) ? value : undefined; +} + +function readUnsignedChild( + bytes: Buffer, + children: readonly EbmlElement[] | undefined, + id: number, +): number | undefined { + const element = children?.find((child) => child.id === id); + return element ? readUnsignedInteger(bytes, element) : undefined; +} + +function readCompleteChildren(bytes: Buffer, parent: EbmlElement): EbmlElement[] | undefined { + if (!parent.complete || parent.unknownSize) return undefined; + const children: EbmlElement[] = []; + for (let offset = parent.dataStart; offset < parent.dataEnd; ) { + const child = readEbmlElement(bytes, offset, parent.dataEnd, bytes.length); + if (!child || !child.complete || child.unknownSize) return undefined; + children.push(child); + offset = child.nextOffset; + } + return children; +} + +function readEbmlElement( + bytes: Buffer, + offset: number, + parentEnd: number, + fileSize: number, +): EbmlElement | undefined { + const id = readEbmlId(bytes, offset); + if (!id) return undefined; + const size = readEbmlVariableInteger(bytes, offset + id.length); + if (!size) return undefined; + const dataStart = offset + id.length + size.length; + if (dataStart > parentEnd) return undefined; + const declaredEnd = size.unknown ? fileSize : dataStart + size.value; + if (!Number.isSafeInteger(declaredEnd) || declaredEnd < dataStart) return undefined; + return { + id: id.value, + dataStart, + dataEnd: Math.min(declaredEnd, parentEnd), + nextOffset: declaredEnd, + complete: declaredEnd <= parentEnd, + unknownSize: size.unknown, + }; +} + +function readEbmlId(bytes: Buffer, offset: number): { length: number; value: number } | undefined { + const length = readVariableIntegerLength(bytes, offset, 4); + if (!length) return undefined; + let value = 0; + for (let index = 0; index < length; index += 1) value = value * 256 + bytes[offset + index]!; + return { length, value }; +} + +function readEbmlVariableInteger(bytes: Buffer, offset: number): EbmlVariableInteger | undefined { + const length = readVariableIntegerLength(bytes, offset, 8); + if (!length) return undefined; + const first = bytes[offset]!; + const mask = 0x80 >> (length - 1); + const unknown = + (first & (mask - 1)) === mask - 1 && + bytes.subarray(offset + 1, offset + length).every((byte) => byte === 0xff); + if (unknown) return { length, value: 0, unknown: true }; + let value = first & (mask - 1); + for (let index = 1; index < length; index += 1) { + value = value * 256 + bytes[offset + index]!; + } + return Number.isSafeInteger(value) ? { length, value, unknown: false } : undefined; +} + +function readVariableIntegerLength( + bytes: Buffer, + offset: number, + maximum: number, +): number | undefined { + const first = bytes[offset]; + if (first === undefined || first === 0) return undefined; + let mask = 0x80; + let length = 1; + while ((first & mask) === 0) { + mask >>= 1; + length += 1; + } + return length <= maximum && offset + length <= bytes.length ? length : undefined; +} + +function hasPlausibleExtent(element: EbmlElement, fileSize: number): boolean { + return element.unknownSize || element.nextOffset <= fileSize; +} + +function setsIntersect(left: ReadonlySet, right: ReadonlySet): boolean { + for (const value of left) if (right.has(value)) return true; + return false; +} diff --git a/src/utils/video.test.ts b/src/utils/video.test.ts new file mode 100644 index 0000000000..1e8a8470ad --- /dev/null +++ b/src/utils/video.test.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { likelyPlayableWebmContainer } from '../__tests__/test-utils/index.ts'; +import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; +import { isPlayableVideo } from './video.ts'; + +const directory = mkdtempForTestSync('agent-device-video-webm-'); +const playableWebm = likelyPlayableWebmContainer(); + +test('accepts a real WebM video track and complete media block without AVFoundation', async () => { + await expect(isPlayableVideo(writeFixture('capture.webm', playableWebm))).resolves.toBe(true); +}); + +test('does not infer WebM from bytes when the requested container is different', async () => { + await expect(isPlayableVideo(writeFixture('capture.bin', playableWebm))).resolves.toBe(false); +}); + +function writeFixture(name: string, bytes: Buffer): string { + const filePath = path.join(directory, name); + fs.writeFileSync(filePath, bytes); + return filePath; +} diff --git a/src/utils/video.ts b/src/utils/video.ts index 16c919991d..afd7c76344 100644 --- a/src/utils/video.ts +++ b/src/utils/video.ts @@ -3,6 +3,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from './exec.ts'; import { buildSwiftToolEnv, compileSwiftSourceText } from './swift-cache.ts'; import { sleep } from './timeouts.ts'; +import { hasPlayableWebmStructure } from './video-webm.ts'; // Duration zero must pass: a recording of a fully static screen legitimately contains a single // frame (screenrecord only encodes on screen updates), and AVFoundation reports its duration as 0. @@ -66,12 +67,11 @@ export async function waitForStableFile( } export async function isPlayableVideo(filePath: string): Promise { - // The moov sniff is the finalization oracle: screen recorders reserve moov space up front - // and patch it in place on stop, so a capture pulled too early has a `free` placeholder where - // the moov belongs — moov presence, not AVFoundation parseability, tells finalized apart. - if (!hasLikelyPlayableVideoContainer(filePath)) { - return false; - } + const container = likelyPlayableVideoContainer(filePath); + if (!container) return false; + // AVFoundation is the MP4 semantic validator. It does not reliably load WebM on supported + // macOS hosts, so WebM completion is established by its EBML document type + Segment marker. + if (container === 'webm') return true; try { const validatorPath = await getVideoValidatorExecutablePath(); const result = await runCmd(validatorPath, [filePath], { @@ -140,18 +140,21 @@ function isSwiftVideoValidatorUnavailable(stderr: string, stdout: string): boole ); } -function hasLikelyPlayableVideoContainer(filePath: string): boolean { +function likelyPlayableVideoContainer(filePath: string): 'mp4' | 'webm' | undefined { try { const stats = fs.statSync(filePath); if (!stats.isFile() || stats.size <= 0) { - return false; + return undefined; } } catch { - return false; + return undefined; } + if (filePath.toLowerCase().endsWith('.webm')) { + return hasPlayableWebmStructure(filePath) ? 'webm' : undefined; + } const atoms = inspectTopLevelAtoms(filePath); - return atoms.includes('ftyp') && atoms.includes('moov'); + return atoms.includes('ftyp') && atoms.includes('moov') ? 'mp4' : undefined; } function inspectTopLevelAtoms(filePath: string): string[] { diff --git a/test/integration/provider-scenarios/android-recording-fixtures.ts b/test/integration/provider-scenarios/android-recording-fixtures.ts index a486d43e02..a8ea3d899c 100644 --- a/test/integration/provider-scenarios/android-recording-fixtures.ts +++ b/test/integration/provider-scenarios/android-recording-fixtures.ts @@ -1,148 +1,84 @@ -import fs from 'node:fs'; import path from 'node:path'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import type { JsonObject } from '@agent-device/contracts/client'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { deviceIdentity } from '@agent-device/kernel/device'; +import { screenRecordingResourceStore } from '../../../src/daemon/screen-recording-resource-store.ts'; +import type { AndroidRecordingManifestFixture } from './android-recording-manifest-fixtures.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; import { - restoreEnv, createProviderScenarioHarness, - likelyPlayableMp4Container, + restoreEnv, + withProviderScenarioTempDir, } from './harness.ts'; export type ProviderScenarioDaemon = Awaited>; export type ProviderScenarioRpcResult = Awaited>; -export type PullCall = { remotePath: string; localPath: string }; export async function stopAndroidRecording( daemon: ProviderScenarioDaemon, - outPath?: string, + outputPath?: string, ): Promise { - return await daemon.callCommand('record', outPath ? ['stop', outPath] : ['stop'], { + return await daemon.callCommand('record', outputPath ? ['stop', outputPath] : ['stop'], { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id, }); } -// Strips PATH so isPlayableVideo cannot reach swiftc and deterministically validates pulled -// files via the container sniff. -export async function withAndroidProviderScenarioEnv( - tmpDir: string, - runScenario: () => Promise, -): Promise { - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - try { - await runScenario(); - } finally { - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); - } -} - -export type AndroidRecordingManifestFixtureOptions = { - outPath: string; - remotePath: string; - sessionName: string; - sessionScope?: { kind: 'cwd'; id: string }; - status?: 'pending' | 'live' | 'rotating'; - pendingRemotePath?: string; - pendingRemotePid?: string; - remotePid?: string; - startedAt?: number; - chunks?: Array<{ index: number; path: string; remotePath: string }>; -}; - -export function buildAndroidRecordingManifest(options: AndroidRecordingManifestFixtureOptions) { - const startedAt = options.startedAt ?? 123456789; - const status = options.status ?? 'live'; - return { - version: 1, - sessionName: options.sessionName, - sessionScope: options.sessionScope, - recordingId: `recording-${startedAt}`, - deviceId: PROVIDER_SCENARIO_ANDROID.id, - startedAt, - outPath: options.outPath, - showTouches: true, - exportQuality: 'medium', - current: buildAndroidRecordingManifestCurrent(options, startedAt, status), - pending: buildAndroidRecordingManifestPending(options, status), - pendingRemotePid: options.pendingRemotePid ?? (status === 'rotating' ? '4322' : '4321'), - chunks: buildAndroidRecordingManifestChunks(options), - }; -} - -function buildAndroidRecordingManifestCurrent( - options: AndroidRecordingManifestFixtureOptions, - startedAt: number, - status: 'pending' | 'live' | 'rotating', -) { - if (status === 'pending') return undefined; - return { - remotePath: options.remotePath, - remotePid: options.remotePid ?? '4321', - startedAt, - }; +export async function createAndroidRecordingScenarioHarness( + deps: Parameters[0], +): Promise { + return await createProviderScenarioHarness({ ...deps, platformRuntime: true }); } -function buildAndroidRecordingManifestPending( - options: AndroidRecordingManifestFixtureOptions, - status: 'pending' | 'live' | 'rotating', -) { - return status === 'pending' || status === 'rotating' - ? { remotePath: options.pendingRemotePath ?? options.remotePath } - : undefined; +export async function withAndroidRecordingScenario( + prefix: string, + run: (tmpDir: string) => Promise, +): Promise { + return await withProviderScenarioTempDir(prefix, async (tmpDir) => { + const previousPath = process.env.PATH; + const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; + process.env.PATH = tmpDir; + process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); + try { + return await run(tmpDir); + } finally { + restoreEnv('PATH', previousPath); + restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); + } + }); } -function buildAndroidRecordingManifestChunks(options: AndroidRecordingManifestFixtureOptions) { - return ( - options.chunks ?? [ - { - index: 1, - path: options.outPath, - remotePath: options.remotePath, +export function seedAndroidRecordingResource( + daemon: ProviderScenarioDaemon, + manifest: AndroidRecordingManifestFixture, + options: { descriptor?: JsonObject } = {}, +): void { + const remotePath = manifest.chunks.at(-1)?.remotePath ?? manifest.pendingRemotePath; + if (!remotePath) throw new Error('Android recording fixture needs a native remote path'); + const manifestPath = `${path.posix.dirname(remotePath)}/agent-device-recording-active.json`; + screenRecordingResourceStore.write( + screenRecordingResourceStore.resolvePath(daemon.sessionDir(manifest.sessionId)), + createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: manifest.sessionId, + device: deviceIdentity(PROVIDER_SCENARIO_ANDROID), + owner: localRuntimeOwner('android'), + fence: { token: manifest.fenceToken, generation: manifest.fenceGeneration }, + lifecycle: 'open', + descriptor: { + version: 1, + body: options.descriptor ?? { + backend: 'adb-screenrecord', + manifestPath, + outputPath: manifest.outputPath, + scope: manifest.scope, + showTouches: manifest.showTouches, + recordOnlySession: manifest.recordOnlySession, + exportQuality: manifest.exportQuality, + transportMode: manifest.transportMode, + }, }, - ] - ); -} - -export function androidAdbResult(args: string[]): { - stdout: string; - stderr: string; - exitCode: number; - stdoutBuffer?: Buffer; -} { - const command = args.join(' '); - if (command === 'shell getprop sys.boot_completed') { - return { stdout: '1\n', stderr: '', exitCode: 0 }; - } - if (isAndroidScreenrecordStartCommand(command)) { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; - } - if (args[0] === 'pull' && typeof args[2] === 'string') { - writePlayableMp4(args[2]); - return { stdout: '', stderr: '', exitCode: 0 }; - } - if (command === 'shell ps -o pid= -p 4321') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - return { stdout: '', stderr: '', exitCode: 0 }; -} - -function isAndroidScreenrecordStartCommand(command: string): boolean { - return /^shell screenrecord --bit-rate (?:8000000|20000000) \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, + }), ); } - -export function writePlayableMp4(filePath: string): void { - const fixturePath = path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'); - if (fs.existsSync(fixturePath)) { - fs.copyFileSync(fixturePath, filePath); - return; - } - fs.writeFileSync(filePath, likelyPlayableMp4Container()); -} diff --git a/test/integration/provider-scenarios/android-recording-manifest-fixtures.ts b/test/integration/provider-scenarios/android-recording-manifest-fixtures.ts new file mode 100644 index 0000000000..ccbad035da --- /dev/null +++ b/test/integration/provider-scenarios/android-recording-manifest-fixtures.ts @@ -0,0 +1,190 @@ +import path from 'node:path'; +import type { JsonObject, JsonValue } from '@agent-device/contracts/client'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; + +type NativeChunk = { + index: number; + remotePath: string; + remotePid: string; + remoteStartTime: string; +}; + +export type AndroidRecordingManifestFixture = { + version: 1; + resourceKind: 'screen-recording'; + fenceToken: string; + fenceGeneration: number; + sessionId: string; + deviceId: string; + startedAt: number; + outputPath: string; + scope: 'device'; + showTouches: boolean; + recordOnlySession: boolean; + exportQuality: 'medium' | 'high'; + transportMode: 'transport-composed'; + chunks: NativeChunk[]; + pendingRemotePath?: string; + completion?: JsonObject; +}; + +export function buildAndroidRecordingManifest(options: { + outPath: string; + remotePath: string; + sessionName: string; + startedAt?: number; + chunks?: Array<{ + index: number; + remotePath: string; + remotePid?: string; + remoteStartTime?: string; + }>; + pendingRemotePath?: string; + completion?: JsonObject; +}): AndroidRecordingManifestFixture { + const chunks = options.chunks ?? [{ index: 1, remotePath: options.remotePath }]; + return { + version: 1, + resourceKind: 'screen-recording', + fenceToken: 'provider-fixture-fence', + fenceGeneration: 1, + sessionId: options.sessionName, + deviceId: PROVIDER_SCENARIO_ANDROID.id, + startedAt: options.startedAt ?? 123456789, + outputPath: options.outPath, + scope: 'device', + showTouches: true, + recordOnlySession: false, + exportQuality: 'medium', + transportMode: 'transport-composed', + chunks: chunks.map(toNativeChunk), + ...(options.pendingRemotePath === undefined + ? {} + : { pendingRemotePath: options.pendingRemotePath }), + ...(options.completion === undefined ? {} : { completion: options.completion }), + }; +} + +export function manifestPath(manifest: AndroidRecordingManifestFixture): string { + const remotePath = manifest.chunks.at(-1)?.remotePath ?? manifest.pendingRemotePath; + if (!remotePath) throw new Error('Android recording fixture needs a native remote path'); + return `${path.posix.dirname(remotePath)}/agent-device-recording-active.json`; +} + +export function parseManifestWrite( + command: string, +): { path: string; manifest: AndroidRecordingManifestFixture } | undefined { + const match = /^printf %s '(.+)' > '(.+)\.tmp' && mv -f '.+\.tmp' '(.+)'$/.exec(command); + const [, serializedManifest, temporaryPath, manifestPath] = match ?? []; + if (!serializedManifest || !temporaryPath || !manifestPath || temporaryPath !== manifestPath) + return undefined; + try { + const parsed: unknown = JSON.parse(serializedManifest.replace(/'\\''/g, "'")); + return isAndroidRecordingManifestFixture(parsed) + ? { path: manifestPath, manifest: parsed } + : undefined; + } catch { + return undefined; + } +} + +function toNativeChunk(chunk: { + index: number; + remotePath: string; + remotePid?: string; + remoteStartTime?: string; +}): NativeChunk { + return { + index: chunk.index, + remotePath: chunk.remotePath, + remotePid: chunk.remotePid ?? String(4320 + chunk.index), + remoteStartTime: chunk.remoteStartTime ?? String(100 + chunk.index), + }; +} + +function isAndroidRecordingManifestFixture( + value: unknown, +): value is AndroidRecordingManifestFixture { + return isJsonObject(value) && hasManifestIdentity(value) && hasCaptureSettings(value); +} + +function hasManifestIdentity(value: JsonObject): boolean { + return ( + value.version === 1 && + value.resourceKind === 'screen-recording' && + isStringProperty(value, 'fenceToken') && + isNumberProperty(value, 'fenceGeneration') && + isStringProperty(value, 'sessionId') && + isStringProperty(value, 'deviceId') && + isNumberProperty(value, 'startedAt') && + isStringProperty(value, 'outputPath') + ); +} + +function hasCaptureSettings(value: JsonObject): boolean { + return ( + value.scope === 'device' && + isBooleanProperty(value, 'showTouches') && + isBooleanProperty(value, 'recordOnlySession') && + isExportQuality(value.exportQuality) && + value.transportMode === 'transport-composed' && + hasChunkList(value.chunks) && + hasOptionalString(value, 'pendingRemotePath') && + hasOptionalJsonObject(value, 'completion') + ); +} + +function hasChunkList(value: JsonValue | undefined): boolean { + return Array.isArray(value) && value.every(isNativeChunk); +} + +function isNativeChunk(value: unknown): value is NativeChunk { + return ( + isJsonObject(value) && + isNumberProperty(value, 'index') && + isStringProperty(value, 'remotePath') && + isStringProperty(value, 'remotePid') && + isStringProperty(value, 'remoteStartTime') + ); +} + +function hasOptionalString(value: JsonObject, key: string): boolean { + const candidate = value[key]; + return candidate === undefined || typeof candidate === 'string'; +} + +function hasOptionalJsonObject(value: JsonObject, key: string): boolean { + const candidate = value[key]; + return candidate === undefined || isJsonObject(candidate); +} + +function isStringProperty(value: JsonObject, key: string): boolean { + return typeof value[key] === 'string'; +} + +function isNumberProperty(value: JsonObject, key: string): boolean { + return typeof value[key] === 'number'; +} + +function isBooleanProperty(value: JsonObject, key: string): boolean { + return typeof value[key] === 'boolean'; +} + +function isExportQuality(value: JsonValue | undefined): boolean { + return value === 'medium' || value === 'high'; +} + +function isJsonObject(value: unknown): value is JsonObject { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) && isJsonRecord(value) + ); +} + +function isJsonRecord(value: object): boolean { + return Object.values(value).every(isJsonValue); +} + +function isJsonValue(value: unknown): value is JsonValue { + if (value === null || ['boolean', 'number', 'string'].includes(typeof value)) return true; + return Array.isArray(value) ? value.every(isJsonValue) : isJsonObject(value); +} diff --git a/test/integration/provider-scenarios/android-recording-provider-fixtures.ts b/test/integration/provider-scenarios/android-recording-provider-fixtures.ts new file mode 100644 index 0000000000..31d4e39316 --- /dev/null +++ b/test/integration/provider-scenarios/android-recording-provider-fixtures.ts @@ -0,0 +1,199 @@ +import fs from 'node:fs'; +import { likelyPlayableMp4Container } from './harness.ts'; +import { + manifestPath, + parseManifestWrite, + type AndroidRecordingManifestFixture, +} from './android-recording-manifest-fixtures.ts'; +import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; + +export type PullCall = { remotePath: string; localPath: string }; + +type NativeProcess = { remotePath: string; startTime: string; alive: boolean }; +type ProviderState = { + manifests: Map; + processes: Map; + pulls: number; +}; + +export function createAndroidRecordingProvider(params: { + manifests?: readonly AndroidRecordingManifestFixture[]; + calls: string[][]; + pulls?: PullCall[]; + onPull?: (remotePath: string, localPath: string, count: number) => void; + deadPids?: readonly string[]; +}): AndroidAdbProvider { + const state = createProviderState(params.manifests, params.deadPids); + return { + exec: async (args) => respondToCommand(args, params, state), + }; +} + +function createProviderState( + initialManifests: readonly AndroidRecordingManifestFixture[] = [], + deadPids: readonly string[] = [], +): ProviderState { + const state: ProviderState = { manifests: new Map(), processes: new Map(), pulls: 0 }; + for (const manifest of initialManifests) { + state.manifests.set(manifestPath(manifest), manifest); + addManifestProcesses(manifest, state.processes, deadPids); + } + return state; +} + +function respondToCommand( + args: readonly string[], + params: Parameters[0], + state: ProviderState, +) { + params.calls.push([...args]); + const pull = respondToPull(args, params, state); + if (pull) return pull; + if (args.join(' ') === 'shell getprop sys.boot_completed') return ok('1\n'); + return respondToShellCommand(args[1] ?? '', state); +} + +function respondToPull( + args: readonly string[], + params: Parameters[0], + state: ProviderState, +) { + const [operation, remotePath, localPath] = args; + if (operation !== 'pull' || !remotePath || !localPath) return undefined; + state.pulls += 1; + params.pulls?.push({ remotePath, localPath }); + if (params.onPull) params.onPull(remotePath, localPath, state.pulls); + else fs.writeFileSync(localPath, likelyPlayableMp4Container()); + return ok(); +} + +function respondToShellCommand(command: string, state: ProviderState) { + return ( + respondToManifestCommand(command, state) ?? + respondToProcessCommand(command, state.processes) ?? + respondToScreenrecordCommand(command, state.processes) ?? + respondToArtifactCommand(command) + ); +} + +function respondToManifestCommand(command: string, state: ProviderState) { + const target = findManifestTarget(command); + if (target) return respondToManifestTarget(command, target, state.manifests); + const written = command.startsWith('printf %s ') ? parseManifestWrite(command) : undefined; + if (!written) return undefined; + state.manifests.set(written.path, written.manifest); + addManifestProcesses(written.manifest, state.processes); + return ok(); +} + +function findManifestTarget(command: string): string | undefined { + for (const prefix of ['test -e ', 'cat ', 'rm -f ']) { + const target = shellPath(command, prefix); + if (target?.endsWith('/agent-device-recording-active.json')) return target; + } + return undefined; +} + +function respondToManifestTarget( + command: string, + target: string, + manifests: Map, +) { + if (command.startsWith('test -e ')) return manifests.has(target) ? ok() : missing(); + if (command.startsWith('cat ')) + return manifests.has(target) ? ok(JSON.stringify(manifests.get(target))) : missing(); + manifests.delete(target); + return ok(); +} + +function respondToProcessCommand(command: string, processes: Map) { + return ( + respondToProcessDirectory(command, processes) ?? + respondToProcessMetadata(command, processes) ?? + respondToProcessSignal(command, processes) + ); +} + +function respondToProcessDirectory(command: string, processes: Map) { + const directory = /^test -d \/proc\/(\d+)$/.exec(command); + const [, directoryPid] = directory ?? []; + if (directoryPid) return processes.get(directoryPid)?.alive ? ok() : missing(); + return undefined; +} + +function respondToProcessMetadata(command: string, processes: Map) { + const proc = /^cat \/proc\/(\d+)\/(stat|cmdline)$/.exec(command); + const [, pid, field] = proc ?? []; + if (pid && (field === 'stat' || field === 'cmdline')) return processResult(processes, pid, field); + return undefined; +} + +function respondToProcessSignal(command: string, processes: Map) { + const kill = /^kill -(?:2|9) (\d+)$/.exec(command); + const [, killPid] = kill ?? []; + if (!killPid) return undefined; + const nativeProcess = processes.get(killPid); + if (nativeProcess) nativeProcess.alive = false; + return nativeProcess ? ok() : missing(); +} + +function respondToScreenrecordCommand(command: string, processes: Map) { + if (!command.startsWith('screenrecord --bit-rate ')) return undefined; + const remotePath = command.match( + /(\/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4)/, + )?.[1]; + if (!remotePath) return missing(); + processes.set('4321', { remotePath, startTime: '101', alive: true }); + return ok('4321\n'); +} + +function respondToArtifactCommand(command: string) { + if (command.startsWith('test -e ')) return ok(); + return command.startsWith('stat -c %s ') ? ok('2048\n') : ok(); +} + +function addManifestProcesses( + manifest: AndroidRecordingManifestFixture, + processes: Map, + deadPids: readonly string[] = [], +): void { + for (const chunk of manifest.chunks) { + processes.set(chunk.remotePid, { + remotePath: chunk.remotePath, + startTime: chunk.remoteStartTime, + alive: !deadPids.includes(chunk.remotePid), + }); + } +} + +function processResult( + processes: Map, + pid: string, + field: 'stat' | 'cmdline', +) { + const nativeProcess = processes.get(pid); + if (!nativeProcess?.alive) return missing(); + return field === 'stat' + ? ok( + `${pid} (screenrecord) S ${Array.from({ length: 18 }, () => '0').join(' ')} ${nativeProcess.startTime}`, + ) + : ok( + ['/system/bin/screenrecord', '--bit-rate', '8000000', nativeProcess.remotePath, ''].join( + '\0', + ), + ); +} + +function shellPath(command: string, prefix: string): string | undefined { + if (!command.startsWith(prefix)) return undefined; + const value = command.slice(prefix.length).trim(); + return value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value; +} + +function ok(stdout = '') { + return { stdout, stderr: '', exitCode: 0 }; +} + +function missing() { + return { stdout: '', stderr: '', exitCode: 1 }; +} diff --git a/test/integration/provider-scenarios/android-recording.coverage.ts b/test/integration/provider-scenarios/android-recording.coverage.ts index 7d32444d93..44b595674c 100644 --- a/test/integration/provider-scenarios/android-recording.coverage.ts +++ b/test/integration/provider-scenarios/android-recording.coverage.ts @@ -4,5 +4,5 @@ import { defineAndroidContractEvidence } from '../android-emulator-e2e/contract- export const ANDROID_RECORDING_CONTRACT_EVIDENCE = defineAndroidContractEvidence( 'provider-scenarios/android-recording', [C.record], - 'Provider-backed integration Android recording flow uses scripted ADB provider pull capability', + 'Provider-backed integration Android record start and stop use the composed scoped runtime', ); diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 06f698a3d9..05b842bd20 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -1,122 +1,52 @@ import assert from 'node:assert/strict'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { withMockedAdb } from '../../../src/__tests__/test-utils/mocked-binaries.ts'; -import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; -import { - assertCommandCall, - assertRecordingStarted, - assertRecordingStopped, - assertRpcError, - assertRpcOk, -} from './assertions.ts'; -import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; -import { createProviderScenarioHarness, withProviderScenarioTempDir } from './harness.ts'; +import { assertRpcError, assertRpcOk } from './assertions.ts'; import { ANDROID_RECORDING_CONTRACT_EVIDENCE } from './android-recording.coverage.ts'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; import { - androidAdbResult, - buildAndroidRecordingManifest, + createAndroidRecordingScenarioHarness, + seedAndroidRecordingResource, stopAndroidRecording, - withAndroidProviderScenarioEnv, - writePlayableMp4, - type ProviderScenarioDaemon, - type ProviderScenarioRpcResult, - type PullCall, + withAndroidRecordingScenario, } from './android-recording-fixtures.ts'; +import { buildAndroidRecordingManifest } from './android-recording-manifest-fixtures.ts'; +import { + createAndroidRecordingProvider, + type PullCall, +} from './android-recording-provider-fixtures.ts'; test(ANDROID_RECORDING_CONTRACT_EVIDENCE.testName, async () => { - await withProviderScenarioTempDir( + await withAndroidRecordingScenario( 'agent-device-provider-scenario-android-record-', - runAndroidRecordingFlowScenario, - ); -}); - -test('Provider-backed integration Android record stop recovers missing daemon recording state from durable manifest', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-recovery-', - runAndroidManifestRecoveryScenario, - ); -}); - -test('Provider-backed integration Android record stop recovers cwd-scoped durable manifest', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-scoped-recovery-', - runAndroidScopedManifestRecoveryScenario, - ); -}); - -test('Provider-backed integration Android record stop gives cwd retry hint for scoped owner mismatch', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-scoped-owner-mismatch-', - runAndroidScopedOwnerMismatchHintScenario, - ); -}); - -test('Provider-backed integration Android record stop recovers opened cwd-scoped recording after daemon state loss', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-open-scoped-recovery-', - runAndroidOpenScopedRecordingRecoveryScenario, - ); -}); - -test('Provider-backed integration Android record stop does not recover ownerless live screenrecord', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-ownerless-recovery-', - runAndroidOwnerlessRecordingRecoveryScenario, - ); -}); - -test('Provider-backed integration Android record stop refuses another session durable manifest', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-wrong-session-', async (tmpDir) => { - const adbCalls: string[][] = []; - const pullCalls: Array<{ remotePath: string; localPath: string }> = []; - const remotePath = '/sdcard/agent-device-recording-223456789.mp4'; - const manifest = buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'other-session.mp4'), - remotePath, - sessionName: 'checkout', - }); - const adbProvider: AndroidAdbProvider = { - exec: async (args) => { - adbCalls.push([...args]); - if (args.join(' ') === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (args.join(' ') === 'shell ps -o pid=,args= -p 4321') { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - return androidAdbResult(args); - }, - pull: async (from, to) => { - pullCalls.push({ remotePath: from, localPath: to }); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => adbProvider, + const calls: string[][] = []; + const pulls: PullCall[] = []; + const outputPath = path.join(tmpDir, 'sessionless.mp4'); + const daemon = await createAndroidRecordingScenarioHarness({ + androidAdbProvider: () => createAndroidRecordingProvider({ calls, pulls }), deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], }); - try { - const recordStop = await daemon.callCommand('record', ['stop'], { + const started = await daemon.callCommand('record', ['start', outputPath], { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id, + recordingScope: 'device', + quality: 'high', }); - - assertRpcError(recordStop, 'INVALID_ARGS', /belongs to session "checkout"/); + assert.equal(assertRpcOk<{ recording?: unknown }>(started).recording, 'started'); + const stopped = await stopAndroidRecording(daemon, outputPath); assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), - false, + assertRpcOk<{ recording?: unknown; outPath?: unknown }>(stopped).recording, + 'stopped', ); - assert.equal(pullCalls.length, 0); + assert.equal(assertRpcOk<{ outPath?: unknown }>(stopped).outPath, outputPath); + assert.ok(calls.some((args) => args[1]?.startsWith('screenrecord --bit-rate 20000000 '))); + assert.ok(calls.some((args) => args.join(' ') === 'shell kill -2 4321')); + assert.equal(pulls.length, 1); + assert.equal(pulls[0]?.localPath, outputPath); + assert.equal(fs.existsSync(outputPath), true); } finally { await daemon.close(); } @@ -124,76 +54,77 @@ test('Provider-backed integration Android record stop refuses another session du ); }); -test('Provider-backed integration Android record stop ignores manifest host output paths', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-host-path-', - runAndroidManifestHostPathScenario, - ); -}); - -test('Provider-backed integration Android record stop refuses another session uncertain manifest', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-wrong-session-uncertain-', - runAndroidOtherSessionUncertainManifestScenario, - ); -}); - -test('Provider-backed integration Android record stop refuses ambiguous durable manifests', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-ambiguous-', - runAndroidAmbiguousManifestRecoveryScenario, +test('Provider-backed integration Android record stop reattaches a matching durable descriptor', async () => { + await withAndroidRecordingScenario( + 'agent-device-provider-scenario-android-recovery-', + async (tmpDir) => { + const calls: string[][] = []; + const pulls: PullCall[] = []; + const outputPath = path.join(tmpDir, 'recovered.mp4'); + const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: outputPath, + remotePath, + sessionName: 'default', + }); + const daemon = await createAndroidRecordingScenarioHarness({ + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, pulls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + seedAndroidRecordingResource(daemon, manifest); + try { + const stopped = await stopAndroidRecording(daemon, outputPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(stopped); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, outputPath); + assert.ok( + calls.some((args) => args[1]?.includes('/sdcard/agent-device-recording-active.json')), + ); + assert.ok(calls.some((args) => args.join(' ') === 'shell kill -2 4321')); + assert.deepEqual(pulls, [{ remotePath, localPath: outputPath }]); + } finally { + await daemon.close(); + } + }, ); }); -test('Provider-backed integration Android record stop cleans stale durable manifest', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-stale-manifest-', +test('Provider-backed integration Android record stop returns fenced completed native evidence', async () => { + await withAndroidRecordingScenario( + 'agent-device-provider-scenario-android-completed-', async (tmpDir) => { - const adbCalls: string[][] = []; - const execOptions: Array<{ command: string; timeoutMs?: number }> = []; - const remotePath = '/sdcard/agent-device-recording-423456789.mp4'; + const calls: string[][] = []; + const outputPath = path.join(tmpDir, 'completed.mp4'); const manifest = buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'stale.mp4'), - remotePath, + outPath: outputPath, + remotePath: '/sdcard/agent-device-recording-223456789.mp4', sessionName: 'default', - }); - const adbProvider: AndroidAdbProvider = { - exec: async (args, options) => { - adbCalls.push([...args]); - execOptions.push({ command: args.join(' '), timeoutMs: options?.timeoutMs }); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell ps -o pid=,args= -p 4321') { - return { stdout: '', stderr: '', exitCode: 0 }; - } - if (command === `shell stat -c %s ${remotePath}`) { - return { stdout: '', stderr: '', exitCode: 1 }; - } - return androidAdbResult(args); + completion: { + backend: 'adb screenrecord', + outPath: outputPath, + startedAt: 123456789, + completedAt: 123456999, + scope: 'device', + showTouches: true, + recordOnlySession: false, }, - }; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => adbProvider, + }); + const daemon = await createAndroidRecordingScenarioHarness({ + androidAdbProvider: () => + createAndroidRecordingProvider({ calls, manifests: [manifest], deadPids: ['4321'] }), deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], }); - + seedAndroidRecordingResource(daemon, manifest); try { - const recordStop = await daemon.callCommand('record', ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - - assertRpcError(recordStop, 'INVALID_ARGS', /no active recording/); - assertCommandCall(adbCalls, [ - 'shell', - 'rm', - '-f', - '/sdcard/agent-device-recording-active.json', - ]); + const stopped = await stopAndroidRecording(daemon, outputPath); + assert.equal( + assertRpcOk<{ recording?: unknown; outPath?: unknown }>(stopped).recording, + 'stopped', + ); + assert.equal(assertRpcOk<{ outPath?: unknown }>(stopped).outPath, outputPath); assert.equal( - execOptions.some((entry) => entry.command === 'shell ps -A -o pid=,args='), + calls.some((args) => args[0] === 'pull' || args[1]?.startsWith('kill ')), false, ); } finally { @@ -203,1274 +134,74 @@ test('Provider-backed integration Android record stop cleans stale durable manif ); }); -test('Provider-backed integration Android record stop cleans stale manifest when pid is reused by another process', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-pid-reuse-', - runAndroidPidReuseManifestScenario, - ); -}); - -test('Provider-backed integration Android record stop keeps mismatched device manifest', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-device-mismatch-', - runAndroidDeviceMismatchManifestScenario, - ); -}); - -test('Provider-backed integration Android record stop recovers manifest chunks after daemon state loss', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-chunk-recovery-', - runAndroidManifestChunkRecoveryScenario, - ); -}, 15_000); - -test('Provider-backed integration Android record stop recovers pending manifest after daemon state loss', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-pending-recovery-', - runAndroidPendingManifestRecoveryScenario, - ); -}); - -test('Provider-backed integration Android record stop recovers finished pending manifest after process exit', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-pending-finished-', - runAndroidPendingFinishedManifestRecoveryScenario, - ); -}); - -test('Provider-backed integration Android record stop recovers rotating manifest after daemon state loss', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-rotating-recovery-', - runAndroidRotatingManifestRecoveryScenario, - ); -}, 15_000); - -test('Provider-backed integration Android record stop recovers while another device is recording', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-cross-device-recovery-', - runAndroidCrossDeviceRecordingRecoveryScenario, - ); -}); - -test('Provider-backed integration Android record stop keeps valid metadata on uncertain liveness probe', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-uncertain-metadata-', - runAndroidUncertainMetadataScenario, - ); -}); - -test('Provider-backed integration Android record stop cleans corrupt recovery metadata', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-corrupt-metadata-', - runAndroidCorruptMetadataScenario, - ); -}); - -test('Provider-backed integration Android record start without a session scopes default-device providers', async () => { - await withMockedAdb( - 'agent-device-provider-scenario-android-sessionless-record-', - runAndroidSessionlessRecordingWithMockedAdb, - ); -}); - -async function runAndroidRecordingFlowScenario(tmpDir: string): Promise { - const context = await createAndroidRecordingFlowContext(tmpDir); - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - await exerciseAndroidRecordingFlow(context); - assertAndroidRecordingFlow(context); - } finally { - await context.daemon.close(); - } - }); -} - -async function runAndroidManifestRecoveryScenario(tmpDir: string): Promise { - const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; - const recordingPath = path.join(tmpDir, 'recovered-recording.mp4'); - const context = await createAndroidSingleManifestRecoveryContext({ - outPath: recordingPath, - remotePath, - sessionName: 'default', - }); - - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - const recordStop = await stopAndroidRecording(context.daemon, recordingPath); - assertAndroidManifestRecovery(recordStop, { ...context, recordingPath, remotePath }); - } finally { - await context.daemon.close(); - } - }); -} - -async function runAndroidManifestHostPathScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const remotePath = '/sdcard/agent-device-recording-823456789.mp4'; - const requestedPath = path.join(tmpDir, 'requested.mp4'); - const manifestPath = path.join(tmpDir, 'manifest-controlled.mp4'); - const manifest = buildAndroidRecordingManifest({ - outPath: manifestPath, - remotePath, - sessionName: 'default', - chunks: [{ index: 1, path: manifestPath, remotePath }], - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await stopAndroidRecording(daemon, requestedPath); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, requestedPath); - assert.deepEqual(pullCalls, [{ remotePath, localPath: requestedPath }]); - assert.equal(fs.existsSync(requestedPath), true); - assert.equal(fs.existsSync(manifestPath), false); - } finally { - await daemon.close(); - } -} - -async function runAndroidScopedManifestRecoveryScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const remotePath = '/sdcard/agent-device-recording-923456123.mp4'; - const recordingPath = path.join(tmpDir, 'scoped-recovered-recording.mp4'); - const scopeRoot = path.join(tmpDir, 'worktree'); - fs.mkdirSync(path.join(scopeRoot, '.git'), { recursive: true }); - const scopeId = hashScopeRoot(fs.realpathSync.native(scopeRoot)); - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: `cwd:${scopeId}:default`, - sessionScope: { kind: 'cwd', id: scopeId }, - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await daemon.callCommand( - 'record', - ['stop', recordingPath], - { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }, - { meta: { cwd: scopeRoot } }, - ); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); - } finally { - await daemon.close(); - } -} - -async function runAndroidScopedOwnerMismatchHintScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const remotePath = '/sdcard/agent-device-recording-923456124.mp4'; - const recordingPath = path.join(tmpDir, 'scoped-owner-mismatch.mp4'); - const scopeRoot = path.join(tmpDir, 'worktree'); - fs.mkdirSync(path.join(scopeRoot, '.git'), { recursive: true }); - const scopeId = hashScopeRoot(fs.realpathSync.native(scopeRoot)); - const effectiveSessionName = `cwd:${scopeId}:default`; - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: effectiveSessionName, - sessionScope: { kind: 'cwd', id: scopeId }, - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await daemon.callCommand('record', ['stop', recordingPath], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - session: effectiveSessionName, - }); - assertRpcError( - recordStop, - 'INVALID_ARGS', - /retry record stop from the original working directory without --session/, - ); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), - false, - ); - assert.equal(pullCalls.length, 0); - } finally { - await daemon.close(); - } -} - -async function runAndroidOpenScopedRecordingRecoveryScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const scopeRoot = path.join(tmpDir, 'worktree'); - const recordingPath = path.join(tmpDir, 'opened-scoped-recovered.mp4'); - fs.mkdirSync(path.join(scopeRoot, '.git'), { recursive: true }); - const scopeId = hashScopeRoot(fs.realpathSync.native(scopeRoot)); - const provider = createStatefulAndroidRecordingProvider({ adbCalls, pullCalls }); - const createDaemon = async () => - await createProviderScenarioHarness({ - androidAdbProvider: () => provider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - const firstDaemon = await createDaemon(); - try { - const open = await firstDaemon.callCommand( - 'open', - ['settings'], - { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }, - { meta: { cwd: scopeRoot } }, - ); - assertRpcOk(open); - const recordStart = await firstDaemon.callCommand( - 'record', - ['start', recordingPath], - { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }, - { meta: { cwd: scopeRoot } }, - ); - assertRecordingStarted(recordStart, { showTouches: true }); - assert.equal(provider.manifest?.sessionName, `cwd:${scopeId}:default`); - assert.deepEqual(provider.manifest?.sessionScope, { kind: 'cwd', id: scopeId }); - } finally { - await firstDaemon.close(); - } - - const secondDaemon = await createDaemon(); - try { - const recordStop = await secondDaemon.callCommand( - 'record', - ['stop', recordingPath], - { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }, - { meta: { cwd: scopeRoot } }, - ); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.equal(fs.existsSync(recordingPath), true); - assert.equal(pullCalls.length, 1); - } finally { - await secondDaemon.close(); - } -} - -async function runAndroidAmbiguousManifestRecoveryScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const firstRemotePath = '/sdcard/agent-device-recording-323456789.mp4'; - const secondRemotePath = '/data/local/tmp/agent-device-recording-323456790.mp4'; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createAndroidManifestProvider({ - adbCalls, - manifests: [ - buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'first.mp4'), - remotePath: firstRemotePath, - sessionName: 'default', - }), - buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'second.mp4'), - remotePath: secondRemotePath, - sessionName: 'default', - remotePid: '9876', - }), - ], - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await stopAndroidRecording(daemon); - assertRpcError(recordStop, 'INVALID_ARGS', /multiple active Android recording manifests/); - assert.equal( - adbCalls.some((args) => args.join(' ').startsWith('shell kill -2')), - false, - ); - } finally { - await daemon.close(); - } -} - -async function runAndroidOtherSessionUncertainManifestScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const remotePath = '/sdcard/agent-device-recording-723456789.mp4'; - const manifest = buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'other-session-uncertain.mp4'), - remotePath, - sessionName: 'checkout', - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => ({ - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - if (command === 'shell ps -o pid=,args= -p 4321') { - return { stdout: '', stderr: 'transient ps failure', exitCode: 1 }; - } - if (command === 'shell ps -A -o pid=,args=') { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - return androidAdbResult(args); - }, - pull: async (from, to) => { - pullCalls.push({ remotePath: from, localPath: to }); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await daemon.callCommand('record', ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - assertRpcError(recordStop, 'INVALID_ARGS', /belongs to session "checkout"/); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), - false, - ); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), - false, - ); - assert.equal( - adbCalls.some( - (args) => args.join(' ') === 'shell rm -f /sdcard/agent-device-recording-active.json', - ), - false, - ); - assert.equal(pullCalls.length, 0); - } finally { - await daemon.close(); - } -} - -async function runAndroidManifestChunkRecoveryScenario(tmpDir: string): Promise { - const firstRemotePath = '/sdcard/agent-device-recording-523456789.mp4'; - const secondRemotePath = '/sdcard/agent-device-recording-523456790.mp4'; - const firstLocalPath = path.join(tmpDir, 'chunked.mp4'); - const secondLocalPath = path.join(tmpDir, 'chunked.part-002.mp4'); - const context = await createAndroidSingleManifestRecoveryContext({ - outPath: firstLocalPath, - remotePath: secondRemotePath, - sessionName: 'default', - startedAt: 523456789, - chunks: [ - { index: 1, path: firstLocalPath, remotePath: firstRemotePath }, - { index: 2, path: secondLocalPath, remotePath: secondRemotePath }, - ], - }); - - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - const recordStop = await stopAndroidRecording(context.daemon, firstLocalPath); - assertAndroidManifestChunkRecovery(recordStop, { - ...context, - firstLocalPath, - firstRemotePath, - secondLocalPath, - secondRemotePath, +test('Provider-backed integration Android corrupt descriptor is retained without native cleanup', async () => { + await withAndroidRecordingScenario( + 'agent-device-provider-scenario-android-corrupt-', + async (tmpDir) => { + const calls: string[][] = []; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'corrupt.mp4'), + remotePath: '/sdcard/agent-device-recording-323456789.mp4', + sessionName: 'default', }); - } finally { - await context.daemon.close(); - } - }); -} - -async function runAndroidPendingManifestRecoveryScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const remotePath = '/sdcard/agent-device-recording-623456789.mp4'; - const recordingPath = path.join(tmpDir, 'pending-recovered.mp4'); - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: 'default', - status: 'pending', - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await stopAndroidRecording(daemon, recordingPath); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( - recordStop, - ); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.match(String(data.warning), /durable device manifest/); - assertCommandCall(adbCalls, ['shell', 'ps', '-A', '-o', 'pid=,args=']); - assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); - assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); - } finally { - await daemon.close(); - } -} - -async function runAndroidPendingFinishedManifestRecoveryScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const remotePath = '/sdcard/agent-device-recording-624000001.mp4'; - const recordingPath = path.join(tmpDir, 'pending-finished-recovered.mp4'); - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: 'default', - status: 'pending', - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => ({ - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - // The pending screenrecord process already exited: the full process scan finds no - // match, but the on-device file still exists (default stat returns a non-zero size). - if (command === 'shell ps -A -o pid=,args=') { - return { stdout: '', stderr: '', exitCode: 0 }; - } - return androidAdbResult(args); - }, - pull: async (from, to) => { - pullCalls.push({ remotePath: from, localPath: to }); - writePlayableMp4(to); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await stopAndroidRecording(daemon, recordingPath); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( - recordStop, - ); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.match(String(data.warning), /no longer running/); - // A pending manifest never recorded a pid and the process is confirmed gone, so stop - // sends no signal — it just pulls the completed file instead of discarding it. - assert.equal( - adbCalls.some((args) => args.join(' ').startsWith('shell kill -2')), - false, - ); - assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); - assert.equal(fs.existsSync(recordingPath), true); - } finally { - await daemon.close(); - } -} - -async function runAndroidRotatingManifestRecoveryScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const firstRemotePath = '/sdcard/agent-device-recording-723456789.mp4'; - const secondRemotePath = '/sdcard/agent-device-recording-723456790.mp4'; - const firstLocalPath = path.join(tmpDir, 'rotating.mp4'); - const secondLocalPath = path.join(tmpDir, 'rotating.part-002.mp4'); - const manifest = buildAndroidRecordingManifest({ - outPath: firstLocalPath, - remotePath: firstRemotePath, - sessionName: 'default', - status: 'rotating', - pendingRemotePath: secondRemotePath, - chunks: [ - { index: 1, path: firstLocalPath, remotePath: firstRemotePath }, - { index: 2, path: secondLocalPath, remotePath: secondRemotePath }, - ], - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await stopAndroidRecording(daemon, firstLocalPath); - const data = assertRpcOk<{ - recording?: unknown; - warning?: unknown; - chunks?: Array<{ index?: unknown; path?: unknown }>; - }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.match(String(data.warning), /interrupted chunk rotation/); - assert.deepEqual(data.chunks, [ - { index: 1, path: firstLocalPath }, - { index: 2, path: secondLocalPath }, - ]); - assertCommandCall(adbCalls, ['shell', 'ps', '-A', '-o', 'pid=,args=']); - assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4322']); - assert.deepEqual(pullCalls, [ - { remotePath: firstRemotePath, localPath: firstLocalPath }, - { remotePath: secondRemotePath, localPath: secondLocalPath }, - ]); - } finally { - await daemon.close(); - } -} - -async function createAndroidSingleManifestRecoveryContext(options: { - outPath: string; - remotePath: string; - sessionName: string; - startedAt?: number; - chunks?: Array<{ index: number; path: string; remotePath: string }>; -}): Promise<{ - adbCalls: string[][]; - pullCalls: PullCall[]; - daemon: ProviderScenarioDaemon; -}> { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const manifest = buildAndroidRecordingManifest(options); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - return { adbCalls, pullCalls, daemon }; -} - -function assertAndroidManifestRecovery( - recordStop: ProviderScenarioRpcResult, - context: { - adbCalls: string[][]; - pullCalls: PullCall[]; - recordingPath: string; - remotePath: string; - }, -): void { - const data = assertRpcOk<{ - recording?: unknown; - outPath?: unknown; - warning?: unknown; - overlayWarning?: unknown; - }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, context.recordingPath); - assert.match(String(data.warning), /durable device manifest/); - assert.match(String(data.overlayWarning), /gesture telemetry/); - assert.equal(fs.existsSync(context.recordingPath), true); - assertAndroidManifestRecoveryCommands(context); -} - -function assertAndroidManifestRecoveryCommands(context: { - adbCalls: string[][]; - pullCalls: PullCall[]; - recordingPath: string; - remotePath: string; -}): void { - assertCommandCall(context.adbCalls, [ - 'shell', - 'cat', - '/sdcard/agent-device-recording-active.json', - ]); - assertCommandCall(context.adbCalls, ['shell', 'ps', '-o', 'pid=,args=', '-p', '4321']); - assert.equal( - context.adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), - false, - ); - assertCommandCall(context.adbCalls, ['shell', 'kill', '-2', '4321']); - assert.equal(context.pullCalls.length, 1); - assert.deepEqual(context.pullCalls[0], { - remotePath: context.remotePath, - localPath: context.recordingPath, - }); - assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.remotePath]); - assertCommandCall(context.adbCalls, [ - 'shell', - 'rm', - '-f', - '/sdcard/agent-device-recording-active.json', - ]); -} - -function assertAndroidManifestChunkRecovery( - recordStop: ProviderScenarioRpcResult, - context: { - adbCalls: string[][]; - pullCalls: PullCall[]; - firstLocalPath: string; - firstRemotePath: string; - secondLocalPath: string; - secondRemotePath: string; - }, -): void { - const data = assertRpcOk<{ - recording?: unknown; - chunks?: Array<{ index?: unknown; path?: unknown }>; - }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.deepEqual(data.chunks, [ - { index: 1, path: context.firstLocalPath }, - { index: 2, path: context.secondLocalPath }, - ]); - assert.deepEqual(context.pullCalls, [ - { remotePath: context.firstRemotePath, localPath: context.firstLocalPath }, - { remotePath: context.secondRemotePath, localPath: context.secondLocalPath }, - ]); - assertCommandCall(context.adbCalls, ['shell', 'kill', '-2', '4321']); - assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.firstRemotePath]); - assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.secondRemotePath]); -} - -async function createAndroidRecordingFlowContext(tmpDir: string): Promise<{ - recordingPath: string; - adbCalls: string[][]; - pullCalls: PullCall[]; - daemon: ProviderScenarioDaemon; -}> { - const recordingPath = path.join(tmpDir, 'recording.mp4'); - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => createPullingAndroidProvider({ adbCalls, pullCalls }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - return { recordingPath, adbCalls, pullCalls, daemon }; -} - -async function exerciseAndroidRecordingFlow(context: { - recordingPath: string; - daemon: ProviderScenarioDaemon; -}): Promise { - const open = await context.daemon.callCommand('open', ['settings'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - assertRpcOk(open); - - const recordStart = await context.daemon.callCommand('record', ['start', context.recordingPath], { - hideTouches: true, - quality: 'high', - }); - assertRecordingStarted(recordStart, { showTouches: false }); - - const recordStop = await context.daemon.callCommand('record', ['stop']); - assertRecordingStopped(recordStop, context.recordingPath, { showTouches: false }); -} - -function assertAndroidRecordingFlow(context: { - recordingPath: string; - adbCalls: string[][]; - pullCalls: PullCall[]; -}): void { - const { recordingPath, adbCalls, pullCalls } = context; - assert.equal(fs.existsSync(recordingPath), true); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell wm size'), - false, - ); - assert.ok(adbCalls.some((args) => isAndroidHighQualityScreenrecordStartCommand(args.join(' ')))); - assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); - assert.equal(pullCalls.length, 1); - assert.match(pullCalls[0]?.remotePath ?? '', /^\/sdcard\/agent-device-recording-\d+\.mp4$/); - assert.equal(pullCalls[0]?.localPath, recordingPath); - assert.ok(adbCalls.some((args) => args[0] === 'shell' && args[1] === 'rm')); - assert.equal( - adbCalls.some((args) => args[0] === 'pull'), - false, - ); -} - -async function runAndroidCrossDeviceRecordingRecoveryScenario(tmpDir: string): Promise { - const otherAndroid = { ...PROVIDER_SCENARIO_ANDROID, id: 'emulator-5556', name: 'Pixel 8 B' }; - const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const recoveredPath = path.join(tmpDir, 'recovered-cross-device.mp4'); - const manifest = buildAndroidRecordingManifest({ - outPath: recoveredPath, - remotePath, - sessionName: 'default', - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createAndroidManifestProvider({ - adbCalls, - pullCalls, - manifests: [manifest], - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID, otherAndroid], - }); - - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - seedAndroidSession(daemon, 'default', PROVIDER_SCENARIO_ANDROID); - const busyRecordingPath = path.join(tmpDir, 'busy-recording.mp4'); - const busyRemotePath = '/sdcard/agent-device-recording-623456789.mp4'; - daemon.setSession('busy', { - name: 'busy', - device: otherAndroid, - createdAt: Date.now(), - actions: [], - recording: { - platform: 'android', - recordingId: 'busy-recording', - remotePath: busyRemotePath, - remotePid: '6789', - remoteStartedAt: 623456789, - chunks: [{ index: 1, path: busyRecordingPath, remotePath: busyRemotePath }], - outPath: busyRecordingPath, - startedAt: 623456789, - showTouches: true, - gestureEvents: [], - }, + const daemon = await createAndroidRecordingScenarioHarness({ + androidAdbProvider: () => createAndroidRecordingProvider({ calls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], }); - const recordStop = await daemon.callCommand( - 'record', - ['stop', recoveredPath], - {}, - { session: 'default' }, - ); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recoveredPath); - assert.equal(daemon.session('busy')?.recording !== undefined, true); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), - false, - ); - assert.equal(pullCalls.length, 1); - } finally { - await daemon.close(); - } - }); -} - -async function runAndroidOwnerlessRecordingRecoveryScenario(tmpDir: string): Promise { - const context = await createAndroidRecordingRecoveryContext(); - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - const outPath = path.join(tmpDir, 'recovered-live.mp4'); - seedAndroidSession(context.daemon, 'default', PROVIDER_SCENARIO_ANDROID); - const recordStop = await context.daemon.callCommand( - 'record', - ['stop', outPath], - {}, - { session: 'default' }, - ); - assertRpcError(recordStop, 'INVALID_ARGS', /no active recording/); - assertAndroidOwnerlessRecordingNotRecovered(context); - } finally { - await context.daemon.close(); - } - }); -} - -async function createAndroidRecordingRecoveryContext(): Promise<{ - remotePath: string; - adbCalls: string[][]; - pullCalls: PullCall[]; - daemon: ProviderScenarioDaemon; -}> { - const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => - createPullingAndroidProvider({ - adbCalls, - pullCalls, - exec: (args) => androidRecoveryAdbResult(args, remotePath), - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - return { remotePath, adbCalls, pullCalls, daemon }; -} - -function assertAndroidOwnerlessRecordingNotRecovered(context: { - remotePath: string; - adbCalls: string[][]; - pullCalls: PullCall[]; -}): void { - const { adbCalls, pullCalls } = context; - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), - false, - ); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), - false, - ); - assert.equal(pullCalls.length, 0); -} - -function seedAndroidSession( - daemon: ProviderScenarioDaemon, - name: string, - device: typeof PROVIDER_SCENARIO_ANDROID, -): void { - daemon.setSession(name, { - name, - device, - createdAt: Date.now(), - actions: [], - }); -} - -async function runAndroidPidReuseManifestScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const remotePath = '/sdcard/agent-device-recording-923456789.mp4'; - const manifest = buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'pid-reuse.mp4'), - remotePath, - sessionName: 'default', - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => ({ - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - if (command === 'shell ps -o pid=,args= -p 4321') { - return { stdout: '4321 sh -c sleep 999\n', stderr: '', exitCode: 0 }; - } - if (command === 'shell ps -A -o pid=,args=') { - return { stdout: '', stderr: '', exitCode: 0 }; - } - return androidAdbResult(args); - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await daemon.callCommand('record', ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - assertRpcError(recordStop, 'INVALID_ARGS', /no active recording/); - assertCommandCall(adbCalls, [ - 'shell', - 'rm', - '-f', - '/sdcard/agent-device-recording-active.json', - ]); - } finally { - await daemon.close(); - } -} - -async function runAndroidDeviceMismatchManifestScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const remotePath = '/sdcard/agent-device-recording-933456789.mp4'; - const manifest = { - ...buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'device-mismatch.mp4'), - remotePath, - sessionName: 'default', - }), - deviceId: 'wifi-emulator-5554', - }; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => ({ - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - return androidAdbResult(args); - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await daemon.callCommand('record', ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - assertRpcError(recordStop, 'INVALID_ARGS', /manifest could not be validated/); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), - false, - ); - assert.equal( - adbCalls.some( - (args) => args.join(' ') === 'shell rm -f /sdcard/agent-device-recording-active.json', - ), - false, - ); - } finally { - await daemon.close(); - } -} - -async function runAndroidUncertainMetadataScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; - const manifest = buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'uncertain.mp4'), - remotePath, - sessionName: 'default', - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => ({ - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - if (command === 'shell ps -o pid=,args= -p 4321') { - return { stdout: '', stderr: 'transient ps failure', exitCode: 1 }; - } - if (command === 'shell ps -A -o pid=,args=') { - return { stdout: '', stderr: '', exitCode: 0 }; - } - return androidAdbResult(args); - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await daemon.callCommand('record', ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - assertRpcError(recordStop, 'INVALID_ARGS', /could not be verified/); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), - false, - ); - assert.equal( - adbCalls.some( - (args) => args.join(' ') === 'shell rm -f /sdcard/agent-device-recording-active.json', - ), - false, - ); - } finally { - await daemon.close(); - } -} - -async function runAndroidCorruptMetadataScenario(_tmpDir: string): Promise { - const adbCalls: string[][] = []; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => ({ - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: '{', stderr: '', exitCode: 0 }; - } - if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - if (command === 'shell ps -A -o pid=,args=') { - return { stdout: '', stderr: '', exitCode: 0 }; - } - return androidAdbResult(args); - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStop = await daemon.callCommand('record', ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - assertRpcError(recordStop, 'INVALID_ARGS', /no active recording/); - assertCommandCall(adbCalls, [ - 'shell', - 'rm', - '-f', - '/sdcard/agent-device-recording-active.json', - ]); - } finally { - await daemon.close(); - } -} - -async function runAndroidSessionlessRecordingWithMockedAdb(logPath: string): Promise { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-sessionless-record-', - async (tmpDir) => await runAndroidSessionlessRecordingScenario(tmpDir, logPath), - ); -} - -async function runAndroidSessionlessRecordingScenario( - tmpDir: string, - logPath: string, -): Promise { - const recordingPath = path.join(tmpDir, 'sessionless-recording.mp4'); - const adbCalls: string[][] = []; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => createRecordingOnlyAndroidProvider(adbCalls), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - try { - const recordStart = await daemon.callCommand('record', ['start', recordingPath], { - recordingScope: 'device', - }); - assertRecordingStarted(recordStart, { showTouches: true }); - assertAndroidSessionlessRecording(adbCalls, logPath); - } finally { - await daemon.close(); - } -} - -function assertAndroidSessionlessRecording(adbCalls: string[][], logPath: string): void { - assert.ok( - adbCalls.some((args) => isAndroidDefaultScreenrecordStartCommand(args.join(' '))), - JSON.stringify(adbCalls), - ); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell wm size'), - false, - ); - assert.deepEqual(readLoggedArgs(logPath), []); -} - -function createPullingAndroidProvider(params: { - adbCalls: string[][]; - pullCalls: PullCall[]; - exec?: (args: string[]) => ReturnType; -}): AndroidAdbProvider { - const { adbCalls, pullCalls, exec = androidAdbResult } = params; - return { - exec: async (args) => { - adbCalls.push([...args]); - return exec(args); - }, - pull: async (remotePath, localPath) => { - pullCalls.push({ remotePath, localPath }); - writePlayableMp4(localPath); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }; -} - -function createStatefulAndroidRecordingProvider(params: { - adbCalls: string[][]; - pullCalls: PullCall[]; -}): AndroidAdbProvider & { manifest?: ReturnType } { - const { adbCalls, pullCalls } = params; - const provider: AndroidAdbProvider & { - manifest?: ReturnType; - } = { - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - const manifestPayload = extractManifestWritePayload(command); - if (manifestPayload) { - provider.manifest = JSON.parse(manifestPayload); - return { stdout: '', stderr: '', exitCode: 0 }; - } - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return provider.manifest - ? { stdout: JSON.stringify(provider.manifest), stderr: '', exitCode: 0 } - : { stdout: '', stderr: 'missing manifest', exitCode: 1 }; - } - if (command === 'shell rm -f /sdcard/agent-device-recording-active.json') { - delete provider.manifest; - return { stdout: '', stderr: '', exitCode: 0 }; - } - if (command === 'shell ps -o pid=,args= -p 4321' && provider.manifest?.current) { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${provider.manifest.current.remotePath}\n`, - stderr: '', - exitCode: 0, - }; + seedAndroidRecordingResource(daemon, manifest, { descriptor: { malformed: true } }); + try { + const stopped = await stopAndroidRecording(daemon); + assertRpcError(stopped, 'COMMAND_FAILED', /cannot be reattached/); + assert.equal( + calls.some((args) => args[1]?.includes('agent-device-recording-active.json')), + false, + ); + assert.equal( + fs.existsSync(path.join(daemon.sessionDir(), 'screen-recording.resource.json')), + true, + ); + } finally { + await daemon.close(); } - return androidAdbResult(args); - }, - pull: async (remotePath, localPath) => { - pullCalls.push({ remotePath, localPath }); - writePlayableMp4(localPath); - return { stdout: '', stderr: '', exitCode: 0 }; }, - }; - return provider; -} - -function extractManifestWritePayload(command: string): string | undefined { - const prefix = "shell printf %s '"; - if (!command.startsWith(prefix)) return undefined; - if (!command.includes('agent-device-recording-active.json.tmp')) return undefined; - const payloadEnd = command.indexOf("' >", prefix.length); - if (payloadEnd === -1) return undefined; - return command.slice(prefix.length, payloadEnd).replace(/'\\''/g, "'"); -} + ); +}); -function createAndroidManifestProvider(params: { - adbCalls: string[][]; - manifests: Array>; - pullCalls?: PullCall[]; -}): AndroidAdbProvider { - const { adbCalls, manifests, pullCalls } = params; - return { - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - const manifest = findManifestForAdbCommand(manifests, command); - if (manifest) { - return manifest; +test('Provider-backed integration Android cleanup-only recovery terminalizes before a second start', async () => { + await withAndroidRecordingScenario( + 'agent-device-provider-scenario-android-cleanup-', + async (tmpDir) => { + const calls: string[][] = []; + const outputPath = path.join(tmpDir, 'retry.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: outputPath, + remotePath: '/sdcard/agent-device-recording-423456789.mp4', + sessionName: 'default', + chunks: [], + pendingRemotePath: '/sdcard/agent-device-recording-423456789.mp4', + }); + const provider = createAndroidRecordingProvider({ calls, manifests: [manifest] }); + const daemon = await createAndroidRecordingScenarioHarness({ + androidAdbProvider: () => provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + seedAndroidRecordingResource(daemon, manifest); + try { + assertRpcError( + await stopAndroidRecording(daemon), + 'COMMAND_FAILED', + /launch was interrupted/, + ); + const retried = await daemon.callCommand('record', ['start', outputPath], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + recordingScope: 'device', + }); + assert.equal(assertRpcOk<{ recording?: unknown }>(retried).recording, 'started'); + assert.ok(calls.some((args) => args[1]?.startsWith('screenrecord --bit-rate '))); + } finally { + await daemon.close(); } - return androidAdbResult(args); }, - pull: async (remotePath, localPath) => { - pullCalls?.push({ remotePath, localPath }); - writePlayableMp4(localPath); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }; -} - -function findManifestForAdbCommand( - manifests: Array>, - command: string, -) { - for (const manifest of manifests) { - if (command === manifestCatCommand(manifest)) { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === manifestPendingProcessCommand(manifest)) { - return { - stdout: `${manifest.pendingRemotePid} screenrecord --bit-rate 8000000 ${manifest.pending?.remotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - if (command === manifestProcessCommand(manifest)) { - assert.ok(manifest.current); - return { - stdout: `${manifest.current.remotePid} screenrecord --bit-rate 8000000 ${manifest.current.remotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - } - return undefined; -} - -function manifestCatCommand(manifest: ReturnType): string { - const remotePath = manifest.current?.remotePath ?? manifest.pending?.remotePath; - assert.ok(remotePath); - const metadataPath = `${path.posix.dirname(remotePath)}/agent-device-recording-active.json`; - return `shell cat ${metadataPath}`; -} - -function manifestPendingProcessCommand( - manifest: ReturnType, -): string { - return manifest.pending ? 'shell ps -A -o pid=,args=' : ''; -} - -function manifestProcessCommand( - manifest: ReturnType, -): string { - if (!manifest.current) return ''; - return `shell ps -o pid=,args= -p ${manifest.current.remotePid}`; -} - -function createRecordingOnlyAndroidProvider(adbCalls: string[][]): AndroidAdbProvider { - return { - exec: async (args) => { - adbCalls.push([...args]); - return androidAdbResult(args); - }, - }; -} - -function androidRecoveryAdbResult( - args: string[], - remotePath: string, -): ReturnType { - if (args.join(' ') === 'shell ps -A -o pid=,args=') { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - return androidAdbResult(args); -} - -function hashScopeRoot(scopeRoot: string): string { - return crypto.createHash('sha256').update(scopeRoot).digest('hex').slice(0, 16); -} - -function isAndroidHighQualityScreenrecordStartCommand(command: string): boolean { - return /^shell screenrecord --bit-rate 20000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, - ); -} - -function isAndroidDefaultScreenrecordStartCommand(command: string): boolean { - return /^shell screenrecord --bit-rate 8000000 \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( - command, ); -} - -function readLoggedArgs(logPath: string): string[] { - if (!fs.existsSync(logPath)) return []; - return fs - .readFileSync(logPath, 'utf8') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); -} - -// A screenrecord capture pulled mid-finalization: the moov space is still a `free` placeholder. -// The same capture after screenrecord patched the reserved slot into a real moov atom. +}); diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index ecb277707e..b7c82c2578 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { test } from 'vitest'; import type { AppleRunnerProvider } from '../../../src/platforms/apple/core/runner/runner-provider.ts'; -import type { RecordingProvider } from '../../../src/daemon/recording-provider.ts'; +import type { AppleSimulatorScreenRecordingTransport } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR, PROVIDER_SCENARIO_MACOS } from './fixtures.ts'; import { @@ -331,10 +331,11 @@ function permissiveTool(world: World) { }); } -function permissiveRecording(): RecordingProvider { +function permissiveRecording(): AppleSimulatorScreenRecordingTransport { return { - startIosSimulatorRecording: ({ outPath }) => - createProviderIosSimulatorRecordingProcess(outPath), + available: true, + mode: 'transport-composed', + start: ({ outputPath }) => createProviderIosSimulatorRecordingProcess(outputPath), }; } @@ -343,7 +344,7 @@ async function createWorldDaemon(world: World): Promise return await createProviderScenarioHarness({ appleRunnerProvider: () => permissiveRunner(), appleToolProvider: () => permissiveTool(world).provider, - recordingProvider: () => permissiveRecording(), + appleSimulatorScreenRecordingTransport: () => permissiveRecording(), deviceInventoryProvider: async () => [device], }); } diff --git a/test/integration/provider-scenarios/daemon-command-policy.test.ts b/test/integration/provider-scenarios/daemon-command-policy.test.ts index ef678a076d..e8af64ce21 100644 --- a/test/integration/provider-scenarios/daemon-command-policy.test.ts +++ b/test/integration/provider-scenarios/daemon-command-policy.test.ts @@ -7,6 +7,7 @@ import { createProviderScenarioHarness, withProviderScenarioResource } from './h test('Provider-backed integration daemon command policies gate admission and provider scoping', async () => { const adbCalls: string[][] = []; + let activeRecordingPath: string | undefined; const inactiveLeaseMeta = { tenantId: 'tenant-a', runId: 'run-a', @@ -16,13 +17,17 @@ test('Provider-backed integration daemon command policies gate admission and pro const adbProvider: AndroidAdbProvider = { exec: async (args) => { adbCalls.push([...args]); - return androidAdbResult(args); + const command = args.join(' '); + const startedPath = screenRecordingPath(command); + if (startedPath) activeRecordingPath = startedPath; + return androidAdbResult(args, activeRecordingPath); }, }; await withProviderScenarioResource( async () => await createProviderScenarioHarness({ + platformRuntime: true, androidAdbProvider: () => adbProvider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], }), @@ -72,30 +77,94 @@ test('Provider-backed integration daemon command policies gate admission and pro ); }); -function androidAdbResult(args: string[]): { +function androidAdbResult(args: string[], activeRecordingPath: string | undefined): AdbResult { + const command = args.join(' '); + return ( + deviceStatusResponse(command) ?? + recordingManifestResponse(command) ?? + screenRecordingProcessResponse(command, activeRecordingPath) ?? + recordingArtifactResponse(command, activeRecordingPath) ?? + adbResult() + ); +} + +type AdbResult = { stdout: string; stderr: string; exitCode: number; -} { - const command = args.join(' '); +}; + +function adbResult(stdout = '', exitCode = 0): AdbResult { + return { stdout, stderr: '', exitCode }; +} + +function deviceStatusResponse(command: string): AdbResult | undefined { if (command === 'shell getprop sys.boot_completed') { - return { stdout: '1\n', stderr: '', exitCode: 0 }; + return adbResult('1\n'); } if (command === 'shell wm size') { - return { stdout: 'Physical size: 1080x1920\n', stderr: '', exitCode: 0 }; + return adbResult('Physical size: 1080x1920\n'); + } + return undefined; +} + +function recordingManifestResponse(command: string): AdbResult | undefined { + if ( + command.startsWith('shell test -e ') && + command.includes('agent-device-recording-active.json') + ) { + return adbResult('', 1); + } + if (command.startsWith('shell printf %s ') && command.includes('agent-device-recording-active')) { + return adbResult(); } + return undefined; +} + +function screenRecordingProcessResponse( + command: string, + activeRecordingPath: string | undefined, +): AdbResult | undefined { if (isAndroidScreenrecordStartCommand(command)) { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; + return adbResult('4321\n'); } - if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)) { - return { stdout: '2048\n', stderr: '', exitCode: 0 }; + if (command === 'shell cat /proc/4321/stat') { + return adbResult(processStat(4321, '424242')); + } + if (command === 'shell cat /proc/4321/cmdline' && activeRecordingPath) { + return adbResult(`screenrecord\u0000--bit-rate\u00008000000\u0000${activeRecordingPath}\u0000`); } if (command === 'shell ps -o pid= -p 4321') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; + return adbResult('4321\n'); } - return { stdout: '', stderr: '', exitCode: 0 }; + return undefined; +} + +function recordingArtifactResponse( + command: string, + activeRecordingPath: string | undefined, +): AdbResult | undefined { + if (!activeRecordingPath) return undefined; + if (command.startsWith('shell test -e ') && command.includes(activeRecordingPath)) { + return adbResult(); + } + if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)) { + return adbResult('2048\n'); + } + return undefined; } function isAndroidScreenrecordStartCommand(command: string): boolean { return command.startsWith('shell screenrecord ') && command.endsWith(' & echo $!'); } + +function screenRecordingPath(command: string): string | undefined { + const match = command.match( + /^shell screenrecord .* (?:'([^']+)'|(\/\S+)) >\/dev\/null 2>&1 & echo \$!$/, + ); + return match?.[1] ?? match?.[2]; +} + +function processStat(pid: number, startTime: string): string { + return `${pid} (screenrecord) S ${Array.from({ length: 18 }, () => '0').join(' ')} ${startTime}\n`; +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 182be00c93..f3dd70cb0b 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -9,12 +9,12 @@ import { createRequestHandler, type RequestRouterDeps, } from '../../../src/daemon/request-router.ts'; -import type { RecordingProcess } from '../../../src/daemon/recording-provider.ts'; +import type { AppleSimulatorScreenRecordingProcess } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; import { trackDownloadableArtifact } from '../../../src/daemon/artifact-tracking.ts'; import { LeaseRegistry } from '../../../src/daemon/lease-registry.ts'; import { SessionStore } from '../../../src/daemon/session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../../src/daemon/types.ts'; -import type { ExecResult } from '../../../src/utils/exec.ts'; +import { runCmdBackground } from '../../../src/utils/exec.ts'; import type { DeviceInventoryProvider, ProviderDeviceInventorySource, @@ -50,6 +50,7 @@ export type ProviderScenarioHarness = { ) => Promise; client: () => AgentDeviceClient; session: (name?: string) => SessionState | undefined; + sessionDir: (name?: string) => string; setSession: (name: string, session: SessionState) => void; close: () => Promise; }; @@ -125,6 +126,7 @@ export async function createProviderScenarioHarness( ), client: () => createAgentDeviceClient({}, { transport }), session: (name = 'default') => sessionStore.get(name), + sessionDir: (name = 'default') => sessionStore.resolveSessionDir(name), setSession: (name, session) => sessionStore.set(name, session), close: async () => { await deviceRuntimeGateway.shutdown(); @@ -182,22 +184,23 @@ export function likelyPlayableMp4Container(): Buffer { export function createProviderIosSimulatorRecordingProcess( outPath: string, onSignal?: (signal: NodeJS.Signals | number | undefined) => void, -): RecordingProcess { +): AppleSimulatorScreenRecordingProcess { fs.writeFileSync(outPath, Buffer.alloc(0)); - let resolveWait: ((result: ExecResult) => void) | undefined; - const wait = new Promise((resolve) => { - resolveWait = resolve; - }); + const background = runCmdBackground( + process.execPath, + ['-e', 'setInterval(() => {}, 1000)', 'provider-screen-recording'], + { allowFailure: true, captureOutput: false }, + ); return { child: { + pid: background.child.pid, kill: (signal) => { onSignal?.(signal); fs.writeFileSync(outPath, likelyPlayableMp4Container()); - resolveWait?.({ stdout: '', stderr: '', exitCode: 0 }); - return true; + return background.child.kill(signal); }, }, - wait, + wait: background.wait, }; } diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index 21596c184e..173c113833 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -225,6 +225,9 @@ function createRecordingPlatformRuntimeGateway(params: { available: false, reason: 'unsupported-provider-mode', }, + screenRecordingStart: unavailableRecording, + screenRecordingReattach: unavailableRecording, + screenRecordingCleanup: unavailableRecording, }, }, operations: { @@ -300,3 +303,8 @@ function createRecordingPlatformRuntimeGateway(params: { shutdown: async () => {}, }; } + +const unavailableRecording = Object.freeze({ + available: false as const, + reason: 'unsupported-provider-mode' as const, +}); diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index d62018d648..51851ebd8e 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import type { RecordingProvider } from '../../../src/daemon/recording-provider.ts'; +import type { AppleSimulatorScreenRecordingTransport } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; import { assertFlatToolCallStartsWith, assertRecordingStarted, @@ -12,7 +12,6 @@ import { PROVIDER_SCENARIO_IOS_DEVICE, PROVIDER_SCENARIO_IOS_SIMULATOR } from '. import { createProviderIosSimulatorRecordingProcess, createProviderScenarioHarness, - likelyPlayableMp4Container, restoreEnv, withProviderScenarioTempDir, } from './harness.ts'; @@ -23,188 +22,45 @@ import { } from './providers.ts'; import { createProviderTranscript } from './transcript.ts'; -test('Provider-backed integration iOS physical recording flow uses runner and devicectl providers', async () => { - await withProviderScenarioTempDir('agent-device-provider-scenario-ios-record-', (tmpDir) => - runPhysicalRecordingScenario(tmpDir), - ); -}); - -type ScenarioDaemon = Awaited>; - -async function runPhysicalRecordingScenario(tmpDir: string): Promise { - const tracePath = path.join(tmpDir, 'trace.adtrace'); - const finalTracePath = path.join(tmpDir, 'trace-final.adtrace'); - const recordingPath = path.join(tmpDir, 'recording.mp4'); - const invalidRecordingPath = path.join(tmpDir, 'invalid-recording.mp4'); - const runnerFailurePath = path.join(tmpDir, 'runner-failure.mp4'); - const harness = await createPhysicalRecordingHarness(); - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - - try { - await openPhysicalSettings(harness.daemon); - const traceStart = await harness.daemon.callCommand('trace', ['start', tracePath]); - assert.equal(traceStart.json?.result?.data?.trace, 'started'); - await recordPhysicalHappyPath(harness.daemon, recordingPath); - harness.setCopiedRecording(Buffer.from('unfinalized-recording')); - await recordAndExpectFailure(harness.daemon, invalidRecordingPath, /not finalized/); - harness.setCopiedRecording(likelyPlayableMp4Container()); - await recordAndExpectFailure(harness.daemon, runnerFailurePath, /runner reported recordStop/); - const traceStop = await harness.daemon.callCommand('trace', ['stop', finalTracePath]); - assert.equal(traceStop.json?.result?.data?.trace, 'stopped'); - assertPhysicalRecordingEvidence(harness, recordingPath, finalTracePath); - } finally { - await harness.daemon.close(); - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); - } -} - -async function createPhysicalRecordingHarness() { - let copiedRecording = likelyPlayableMp4Container(); - const runnerStartEntry = { - command: 'ios.runner.recordStart', - deviceId: PROVIDER_SCENARIO_IOS_DEVICE.id, - platform: 'apple' as const, - result: {}, - }; - const runnerStopEntry = { - command: 'ios.runner.recordStop', - deviceId: PROVIDER_SCENARIO_IOS_DEVICE.id, - platform: 'apple' as const, - request: { command: 'recordStop', appBundleId: 'com.apple.Preferences' }, - result: {}, - }; - const runnerTranscript = createProviderTranscript([ - runnerStartEntry, - runnerStopEntry, - runnerStartEntry, - runnerStopEntry, - runnerStartEntry, - { ...runnerStopEntry, result: undefined, error: 'runner reported recordStop ok:0' }, - ]); - const appleRunnerProvider = createAppleRunnerProviderFromTranscript( - runnerTranscript, - 'ios.runner', - ); - const appleTool = createRecordingAppleToolProvider({ - devicectl: async (args) => { - writeJsonOutputIfRequested(args); - writeCopiedRecordingIfRequested(args, copiedRecording); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }); - const daemon = await createProviderScenarioHarness({ - appleRunnerProvider: () => appleRunnerProvider, - appleToolProvider: () => appleTool.provider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_DEVICE], - }); - return { - daemon, - runnerTranscript, - appleTool, - setCopiedRecording(contents: Buffer) { - copiedRecording = contents; - }, - }; -} - -async function openPhysicalSettings(daemon: ScenarioDaemon): Promise { - const open = await daemon.callCommand('open', ['com.apple.Preferences'], { - platform: 'ios', - udid: PROVIDER_SCENARIO_IOS_DEVICE.id, - }); - assert.equal(open.statusCode, 200, JSON.stringify(open.json)); - assert.equal(open.json?.result?.data?.device_udid, PROVIDER_SCENARIO_IOS_DEVICE.id); -} - -async function recordPhysicalHappyPath(daemon: ScenarioDaemon, outPath: string): Promise { - const start = await daemon.callCommand( - 'record', - ['start', outPath], - { fps: 30, quality: 'high', hideTouches: true }, - { meta: { requestId: 'ios-physical-record-start' } }, - ); - assertRecordingStarted(start, { showTouches: false }); - const stop = await daemon.callCommand( - 'record', - ['stop'], - {}, - { meta: { requestId: 'ios-physical-record-stop' } }, - ); - assertRecordingStopped(stop, outPath, { showTouches: false }); -} - -async function recordAndExpectFailure( - daemon: ScenarioDaemon, - outPath: string, - message: RegExp, -): Promise { - const start = await daemon.callCommand('record', ['start', outPath], { hideTouches: true }); - assertRecordingStarted(start, { showTouches: false }); - const stop = await daemon.callCommand('record', ['stop']); - assert.equal(stop.statusCode, 200, JSON.stringify(stop.json)); - assert.equal(stop.json?.result, undefined); - assert.equal(stop.json?.error?.data?.code, 'COMMAND_FAILED'); - assert.match(String(stop.json?.error?.data?.message), message); - assert.equal(fs.existsSync(outPath), true); -} - -function assertPhysicalRecordingEvidence( - harness: Awaited>, - recordingPath: string, - finalTracePath: string, -): void { - harness.runnerTranscript.assertComplete(); - const recordStartCall = harness.runnerTranscript.calls.find( - (call) => call.command === 'ios.runner.recordStart', - ); - const request = recordStartCall?.request as Record | undefined; - assert.deepEqual( - { - command: request?.command, - fps: request?.fps, - appBundleId: request?.appBundleId, - }, - { - command: 'recordStart', - fps: 30, - appBundleId: 'com.apple.Preferences', +test('generic scoped iOS physical runner recording fails closed without local fallback', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-ios-record-', + async (tmpDir) => { + const runnerTranscript = createProviderTranscript([]); + const appleTool = createRecordingAppleToolProvider({ + devicectl: async (args) => { + writeJsonOutputIfRequested(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }); + const daemon = await createProviderScenarioHarness({ + platformRuntime: true, + appleRunnerProvider: () => + createAppleRunnerProviderFromTranscript(runnerTranscript, 'ios.runner'), + appleToolProvider: () => appleTool.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_DEVICE], + }); + try { + const open = await daemon.callCommand('open', ['com.apple.Preferences'], { + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_DEVICE.id, + }); + assert.equal(open.json?.error, undefined, JSON.stringify(open.json)); + const start = await daemon.callCommand('record', [ + 'start', + path.join(tmpDir, 'recording.mp4'), + ]); + assert.equal(start.json?.error?.data?.code, 'UNSUPPORTED_OPERATION'); + assert.equal(start.json?.error?.data?.details?.reason, 'unsupported-provider-mode'); + runnerTranscript.assertComplete(); + } finally { + await daemon.close(); + } }, ); - assert.match(String(request?.outPath), /^agent-device-recording-\d+\.mp4$/); - assert.equal(fs.existsSync(recordingPath), true); - assert.equal(fs.existsSync(finalTracePath), true); - assertFlatToolCallStartsWith(harness.appleTool.calls, [ - 'devicectl', - 'device', - 'info', - 'details', - '--device', - PROVIDER_SCENARIO_IOS_DEVICE.id, - ]); - assertFlatToolCallStartsWith(harness.appleTool.calls, [ - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - PROVIDER_SCENARIO_IOS_DEVICE.id, - 'com.apple.Preferences', - ]); - assertFlatToolCallStartsWith(harness.appleTool.calls, [ - 'devicectl', - 'device', - 'copy', - 'from', - '--device', - PROVIDER_SCENARIO_IOS_DEVICE.id, - ]); -} +}); -test('Provider-backed integration iOS simulator recording flow uses semantic recording provider', async () => { +test('Provider-backed integration iOS simulator recording flow uses the focused Apple transport', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-ios-sim-record-', async (tmpDir) => { @@ -220,21 +76,23 @@ test('Provider-backed integration iOS simulator recording flow uses semantic rec ]), }); const recordingStarts: string[] = []; - let stopped = false; - const recordingProvider: RecordingProvider = { - startIosSimulatorRecording: ({ device, outPath }) => { + const recordingSignals: Array = []; + const recordingTransport: AppleSimulatorScreenRecordingTransport = { + available: true, + mode: 'transport-composed', + start: ({ device, outputPath }) => { assert.equal(device.id, PROVIDER_SCENARIO_IOS_SIMULATOR.id); - recordingStarts.push(outPath); - return createProviderIosSimulatorRecordingProcess(outPath, (signal) => { - assert.equal(signal, 'SIGINT'); - stopped = true; + recordingStarts.push(outputPath); + return createProviderIosSimulatorRecordingProcess(outputPath, (signal) => { + recordingSignals.push(signal); }); }, }; const daemon = await createProviderScenarioHarness({ + platformRuntime: true, appleRunnerProvider: () => appleRunnerProvider, appleToolProvider: () => appleTool.provider, - recordingProvider: () => recordingProvider, + appleSimulatorScreenRecordingTransport: () => recordingTransport, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); const previousPath = process.env.PATH; @@ -270,7 +128,7 @@ test('Provider-backed integration iOS simulator recording flow uses semantic rec runnerTranscript.assertComplete(); assert.deepEqual(recordingStarts, [recordingPath]); - assert.equal(stopped, true); + assert.deepEqual(recordingSignals, ['SIGINT']); assertFlatToolCallStartsWith(appleTool.calls, [ 'simctl', 'launch', @@ -304,10 +162,3 @@ function writeJsonOutputIfRequested(args: string[]): void { 'utf8', ); } - -function writeCopiedRecordingIfRequested(args: string[], contents: Buffer): void { - const destinationIndex = args.indexOf('--destination'); - const destination = destinationIndex >= 0 ? args[destinationIndex + 1] : undefined; - if (!destination) return; - fs.writeFileSync(destination, contents); -} diff --git a/test/integration/provider-scenarios/macos-recording.test.ts b/test/integration/provider-scenarios/macos-recording.test.ts index ac129a95f1..32a86ec7f2 100644 --- a/test/integration/provider-scenarios/macos-recording.test.ts +++ b/test/integration/provider-scenarios/macos-recording.test.ts @@ -1,13 +1,18 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { test } from 'vitest'; import { assertRecordingStarted, assertRecordingStopped, assertRpcOk } from './assertions.ts'; import { PROVIDER_SCENARIO_MACOS } from './fixtures.ts'; import { createProviderScenarioTempPath, withProviderScenarioResource } from './harness.ts'; import { createMacOsDesktopWorld } from './macos-world.ts'; -import { createAppleRunnerProviderFromTranscript } from './providers.ts'; +import { + createAppleRunnerProviderFromTranscript, + createAppleRunnerScreenRecordingTransportFromTranscript, +} from './providers.ts'; import { createProviderTranscript } from './transcript.ts'; -test('Provider-backed integration macOS recording flow uses runner provider through daemon path', async () => { +test('Provider-backed integration macOS recording uses focused exact runner authority', async () => { const recordingPath = createProviderScenarioTempPath( 'agent-device-provider-scenario-macos-record', 'mp4', @@ -23,7 +28,7 @@ test('Provider-backed integration macOS recording flow uses runner provider thro fps: 30, appBundleId: 'com.apple.systempreferences', }, - result: {}, + result: { runnerSessionId: 'macos-runner-recording-1' }, }, { command: 'macos.runner.recordStop', @@ -37,8 +42,22 @@ test('Provider-backed integration macOS recording flow uses runner provider thro runnerTranscript, 'macos.runner', ); + const appleRunnerScreenRecordingTransport = + createAppleRunnerScreenRecordingTransportFromTranscript( + runnerTranscript, + 'macos.runner', + (outputPath) => + fs.copyFileSync( + path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'), + outputPath, + ), + ); await withProviderScenarioResource( - async () => await createMacOsDesktopWorld({ appleRunnerProvider }), + async () => + await createMacOsDesktopWorld({ + appleRunnerProvider, + appleRunnerScreenRecordingTransport, + }), async ({ daemon }) => { const open = await daemon.callCommand('open', ['settings'], { platform: 'macos' }); assert.equal(assertRpcOk(open).appBundleId, 'com.apple.systempreferences'); diff --git a/test/integration/provider-scenarios/macos-world.ts b/test/integration/provider-scenarios/macos-world.ts index 89c178ff73..56e151965c 100644 --- a/test/integration/provider-scenarios/macos-world.ts +++ b/test/integration/provider-scenarios/macos-world.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import type { AppleRunnerProvider } from '../../../src/platforms/apple/core/runner/runner-provider.ts'; +import type { AppleRunnerScreenRecordingTransport } from '../../../src/platform-runtime-screen-recording-apple-runner-transport.ts'; import { PROVIDER_SCENARIO_MACOS } from './fixtures.ts'; import { createProviderScenarioHarness, type ProviderScenarioHarness } from './harness.ts'; import { createRecordingAppleToolProvider, type FlatToolCall } from './providers.ts'; @@ -16,6 +17,7 @@ export type MacOsDesktopWorld = { export async function createMacOsDesktopWorld( options: { appleRunnerProvider?: AppleRunnerProvider; + appleRunnerScreenRecordingTransport?: AppleRunnerScreenRecordingTransport; } = {}, ): Promise { let clipboardText = ''; @@ -51,6 +53,9 @@ export async function createMacOsDesktopWorld( appleRunnerProvider: options.appleRunnerProvider ? () => options.appleRunnerProvider : undefined, + appleRunnerScreenRecordingTransport: options.appleRunnerScreenRecordingTransport + ? () => options.appleRunnerScreenRecordingTransport + : undefined, appleToolProvider: () => appleTool.provider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_MACOS], }); diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index 30a80f5701..92d682fb19 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/apple/core/runner/runner-provider.ts'; +import type { AppleRunnerScreenRecordingTransport } from '../../../src/platform-runtime-screen-recording-apple-runner-transport.ts'; import type { RunnerCommand } from '../../../src/platforms/apple/core/runner/runner-contract.ts'; import type { AppleMacOsHostProvider, @@ -32,6 +33,61 @@ export function createAppleRunnerProviderFromTranscript( }; } +export function createAppleRunnerScreenRecordingTransportFromTranscript( + transcript: ProviderScenarioTranscript, + commandPrefix: 'ios.runner' | 'macos.runner', + onStopped?: (outputPath: string) => void, +): AppleRunnerScreenRecordingTransport { + const active = new Map< + string, + Readonly<{ deviceId: string; appBundleId: string; outputPath: string }> + >(); + const knownSessions = new Map(); + return Object.freeze({ + authority: 'scoped-provider', + available: true, + start: async ({ device, appBundleId, outputPath, fps }) => { + const result = transcript.next( + `${commandPrefix}.recordStart`, + { + command: 'recordStart', + outPath: outputPath, + ...(fps === undefined ? {} : { fps }), + appBundleId, + }, + { deviceId: device.id, platform: device.platform }, + ) as Readonly<{ runnerSessionId?: unknown }>; + if (typeof result.runnerSessionId !== 'string' || result.runnerSessionId.length === 0) { + throw new Error('scripted runner recording did not return an exact session identity'); + } + active.set(result.runnerSessionId, { deviceId: device.id, appBundleId, outputPath }); + knownSessions.set(result.runnerSessionId, device.id); + return Object.freeze({ runnerSessionId: result.runnerSessionId }); + }, + inspect: async (device, runnerSessionId) => { + const recording = active.get(runnerSessionId); + if (recording?.deviceId === device.id) return 'owned-alive'; + return knownSessions.get(runnerSessionId) === device.id ? 'missing' : 'ownership-lost'; + }, + stop: async ({ device, runnerSessionId, appBundleId }) => { + const recording = active.get(runnerSessionId); + if ( + recording?.deviceId !== device.id || + (appBundleId !== undefined && recording.appBundleId !== appBundleId) + ) { + throw new Error('scripted runner recording ownership changed before stop'); + } + transcript.next( + `${commandPrefix}.recordStop`, + { command: 'recordStop', appBundleId }, + { deviceId: device.id, platform: device.platform }, + ); + onStopped?.(recording.outputPath); + active.delete(runnerSessionId); + }, + }); +} + function stripRunnerCommandId(command: RunnerCommand): RunnerCommand { if (command.commandId === undefined) return command; const normalized = { ...command }; diff --git a/test/integration/provider-scenarios/record-trace-android-copy.test.ts b/test/integration/provider-scenarios/record-trace-android-copy.test.ts index 936fa0b557..1c15529b09 100644 --- a/test/integration/provider-scenarios/record-trace-android-copy.test.ts +++ b/test/integration/provider-scenarios/record-trace-android-copy.test.ts @@ -2,102 +2,65 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; import { assertRpcOk } from './assertions.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; -import { createProviderScenarioHarness, withProviderScenarioTempDir } from './harness.ts'; import { - androidAdbResult, - buildAndroidRecordingManifest, + createAndroidRecordingScenarioHarness, + seedAndroidRecordingResource, stopAndroidRecording, - withAndroidProviderScenarioEnv, + withAndroidRecordingScenario, } from './android-recording-fixtures.ts'; +import { buildAndroidRecordingManifest } from './android-recording-manifest-fixtures.ts'; +import { createAndroidRecordingProvider } from './android-recording-provider-fixtures.ts'; test('Provider-backed integration Android record stop retries the pull until in-place moov finalization lands', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-finalize-race-', - runAndroidRemoteFinalizeRaceScenario, + await withAndroidRecordingScenario( + 'agent-device-provider-scenario-android-finalize-', + async (tmpDir) => { + const outputPath = path.join(tmpDir, 'finalize-race.mp4'); + const remotePath = '/sdcard/agent-device-recording-523456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: outputPath, + remotePath, + sessionName: 'default', + }); + const streaming = mp4WithMoovSlot('free'); + const finalized = mp4WithMoovSlot('moov'); + let pulls = 0; + const daemon = await createAndroidRecordingScenarioHarness({ + androidAdbProvider: () => + createAndroidRecordingProvider({ + calls: [], + manifests: [manifest], + onPull: (_remotePath, localPath, count) => { + pulls = count; + fs.writeFileSync(localPath, count < 3 ? streaming : finalized); + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + seedAndroidRecordingResource(daemon, manifest); + try { + const stopped = await stopAndroidRecording(daemon, outputPath); + assert.equal(assertRpcOk<{ recording?: unknown }>(stopped).recording, 'stopped'); + assert.equal(pulls, 3); + } finally { + await daemon.close(); + } + }, ); }, 15_000); -async function runAndroidRemoteFinalizeRaceScenario(tmpDir: string): Promise { - const remotePath = '/sdcard/agent-device-recording-523456789.mp4'; - const recordingPath = path.join(tmpDir, 'finalize-race.mp4'); - const recordingSize = 3232; - const streamingBytes = unfinalizedMp4Container(recordingSize); - const finalizedBytes = finalizedMp4Container(recordingSize); - // screenrecord finalizes by patching a front-reserved moov in place: the size never changes, - // only the content does, so the copy path must detect finalization from re-pulled bytes. - assert.equal(streamingBytes.length, finalizedBytes.length); - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: 'default', - }); - let pullCount = 0; - const adbProvider: AndroidAdbProvider = { - exec: async (args) => { - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell ps -o pid=,args= -p 4321') { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - return androidAdbResult(args); - }, - pull: async (from, to) => { - pullCount += 1; - assert.equal(from, remotePath); - // Finalization lands after the second pull, past the first retry delay — inside the - // 1-3s window observed live on a loaded emulator. - fs.writeFileSync(to, pullCount <= 2 ? streamingBytes : finalizedBytes); - return { stdout: '', stderr: '', exitCode: 0 }; - }, +function mp4WithMoovSlot(slotType: 'free' | 'moov'): Buffer { + const atom = (type: string, payload: Buffer) => { + const header = Buffer.alloc(8); + header.writeUInt32BE(8 + payload.length, 0); + header.write(type, 4, 4, 'latin1'); + return Buffer.concat([header, payload]); }; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => adbProvider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - const recordStop = await stopAndroidRecording(daemon, recordingPath); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.equal(pullCount, 3); - assert.equal(fs.existsSync(recordingPath), true); - } finally { - await daemon.close(); - } - }); -} - -// A screenrecord capture pulled mid-finalization: the moov space is still a `free` placeholder. -function unfinalizedMp4Container(totalSize: number): Buffer { - return mp4WithMoovSlot('free', totalSize); -} - -// The same capture after screenrecord patched the reserved slot into a real moov atom. -function finalizedMp4Container(totalSize: number): Buffer { - return mp4WithMoovSlot('moov', totalSize); -} - -function mp4WithMoovSlot(slotType: 'free' | 'moov', totalSize: number): Buffer { - const ftyp = mp4Atom('ftyp', Buffer.from('isom0000isom', 'latin1')); - const slot = mp4Atom(slotType, Buffer.alloc(1024)); - const mdat = mp4Atom('mdat', Buffer.alloc(totalSize - ftyp.length - slot.length - 8)); - return Buffer.concat([ftyp, slot, mdat]); -} - -function mp4Atom(type: string, payload: Buffer): Buffer { - const header = Buffer.alloc(8); - header.writeUInt32BE(8 + payload.length, 0); - header.write(type, 4, 4, 'latin1'); - return Buffer.concat([header, payload]); + return Buffer.concat([ + atom('ftyp', Buffer.from('isom0000isom')), + atom(slotType, Buffer.alloc(1024)), + atom('mdat', Buffer.alloc(2048)), + ]); } diff --git a/test/integration/provider-scenarios/record-trace-android-liveness.test.ts b/test/integration/provider-scenarios/record-trace-android-liveness.test.ts index 6e5f119679..aff50c3b50 100644 --- a/test/integration/provider-scenarios/record-trace-android-liveness.test.ts +++ b/test/integration/provider-scenarios/record-trace-android-liveness.test.ts @@ -1,84 +1,53 @@ import assert from 'node:assert/strict'; -import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { assertCommandCall, assertRpcOk } from './assertions.ts'; +import { assertRpcOk } from './assertions.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; -import { createProviderScenarioHarness, withProviderScenarioTempDir } from './harness.ts'; import { - androidAdbResult, - buildAndroidRecordingManifest, + createAndroidRecordingScenarioHarness, + seedAndroidRecordingResource, stopAndroidRecording, - withAndroidProviderScenarioEnv, - writePlayableMp4, - type PullCall, + withAndroidRecordingScenario, } from './android-recording-fixtures.ts'; +import { buildAndroidRecordingManifest } from './android-recording-manifest-fixtures.ts'; +import { createAndroidRecordingProvider } from './android-recording-provider-fixtures.ts'; -test('Provider-backed integration Android record stop recovers finished recording after dead-pid probe', async () => { - await withProviderScenarioTempDir( - 'agent-device-provider-scenario-android-record-dead-pid-recovery-', - runAndroidDeadPidRecoveryScenario, +test('Provider-backed integration Android record stop finishes an artifact after a dead-pid reattach', async () => { + await withAndroidRecordingScenario( + 'agent-device-provider-scenario-android-dead-pid-', + async (tmpDir) => { + const calls: string[][] = []; + const outputPath = path.join(tmpDir, 'dead-pid-recovered.mp4'); + const remotePath = '/sdcard/agent-device-recording-623456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: outputPath, + remotePath, + sessionName: 'default', + }); + const daemon = await createAndroidRecordingScenarioHarness({ + androidAdbProvider: () => { + const provider = createAndroidRecordingProvider({ + calls, + manifests: [manifest], + deadPids: ['4321'], + }); + return provider; + }, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + seedAndroidRecordingResource(daemon, manifest); + try { + const stopped = await stopAndroidRecording(daemon, outputPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(stopped); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, outputPath); + assert.equal( + calls.some((args) => args[1]?.startsWith('kill ')), + false, + ); + } finally { + await daemon.close(); + } + }, ); }); - -async function runAndroidDeadPidRecoveryScenario(tmpDir: string): Promise { - const adbCalls: string[][] = []; - const pullCalls: PullCall[] = []; - const remotePath = '/sdcard/agent-device-recording-623456789.mp4'; - const recordingPath = path.join(tmpDir, 'dead-pid-recovered.mp4'); - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: 'default', - }); - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => ({ - exec: async (args) => { - adbCalls.push([...args]); - const command = args.join(' '); - if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; - } - // toybox signature for a pid that no longer exists: exit 1, no output at all. - if (command === 'shell ps -o pid=,args= -p 4321') { - return { stdout: '', stderr: '', exitCode: 1 }; - } - // The device is otherwise responsive: the full listing succeeds and shows no - // screenrecord, and the finalized remote MP4 is present. - if (command === 'shell ps -A -o pid=,args=') { - return { stdout: '1 init\n', stderr: '', exitCode: 0 }; - } - return androidAdbResult(args); - }, - pull: async (from, to) => { - pullCalls.push({ remotePath: from, localPath: to }); - writePlayableMp4(to); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }), - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - await withAndroidProviderScenarioEnv(tmpDir, async () => { - try { - const recordStop = await stopAndroidRecording(daemon, recordingPath); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( - recordStop, - ); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.match(String(data.warning), /durable device manifest/); - assert.match(String(data.warning), /no longer running/); - assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); - assert.equal(fs.existsSync(recordingPath), true); - assertCommandCall(adbCalls, [ - 'shell', - 'rm', - '-f', - '/sdcard/agent-device-recording-active.json', - ]); - } finally { - await daemon.close(); - } - }); -} diff --git a/test/integration/provider-scenarios/web-world.ts b/test/integration/provider-scenarios/web-world.ts index 5112ee29ff..9df49d28c8 100644 --- a/test/integration/provider-scenarios/web-world.ts +++ b/test/integration/provider-scenarios/web-world.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { likelyPlayableWebmContainer } from '../../../src/__tests__/test-utils/index.ts'; import type { WebProvider } from '../../../src/platforms/web/provider.ts'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; import { validPng } from './assertions.ts'; @@ -42,7 +43,7 @@ export async function createWebDesktopWorld(): Promise { }, startRecording: async (outPath) => { semanticCalls.push(['web', 'recordStart', outPath]); - fs.writeFileSync(outPath, 'webm'); + fs.writeFileSync(outPath, likelyPlayableWebmContainer()); }, stopRecording: async () => { semanticCalls.push(['web', 'recordStop']); @@ -118,6 +119,7 @@ export async function createWebDesktopWorld(): Promise { }; const daemon = await createProviderScenarioHarness({ + platformRuntime: true, webProvider: () => provider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_WEB], });