Skip to content

Commit b64bf33

Browse files
committed
refactor: move screen recording onto platform runtime
1 parent e3b0956 commit b64bf33

259 files changed

Lines changed: 15317 additions & 10587 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1537,7 +1537,11 @@ extension RunnerTests {
15371537
}
15381538
case .recordStop:
15391539
guard let recorder = activeRecording else {
1540-
return Response(ok: false, error: ErrorPayload(message: "no active recording"))
1540+
// The runner protocol is the durable cleanup primitive. A daemon may crash after the
1541+
// native stop succeeds but before it commits the resource transition, so exact-owner
1542+
// recovery must be able to repeat this command safely. Public `record stop` still owns
1543+
// its user-facing no-active validation through the daemon session manifest.
1544+
return Response(ok: true, data: DataPayload(message: "recording already stopped"))
15411545
}
15421546
do {
15431547
try recorder.stop()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import XCTest
2+
3+
extension RunnerTests {
4+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
5+
func testRecordStopIsIdempotentAfterNativeRecorderAlreadyStopped() throws {
6+
activeRecording = nil
7+
8+
for commandId in ["record-stop-recovery-one", "record-stop-recovery-two"] {
9+
let json = #"{"command":"recordStop","commandId":"\#(commandId)"}"#
10+
let command = try JSONDecoder().decode(Command.self, from: Data(json.utf8))
11+
let response = try execute(command: command)
12+
13+
XCTAssertTrue(response.ok)
14+
XCTAssertEqual(response.data?.message, "recording already stopped")
15+
XCTAssertNil(activeRecording)
16+
}
17+
}
18+
#endif
19+
}

docs/adr/0019-request-bound-platform-runtime.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ original baseline `44c298d7f3a0ef84bc47f34c54d88b6c9eeb0df2`, through merged `de
1010
`457fafe6399a95a4ddbfac57f02b3a7fe4157a54`. The earlier checkpoints at `99f5af1b7` and `d73bdb4ae`
1111
are superseded and were not behavior-passing: later review found correctness failures and the first
1212
budget decision still used the unrevised +3% limit. The required cleanup package, explicit budget
13-
decision, and clean rerun are now complete. The next authorized command unit is recordings onto the
14-
durable-capture substrate; this decision does not authorize an unbounded platform migration.
13+
decision, and clean rerun are now complete. The authorized recordings command unit moves onto the
14+
durable-capture substrate under its separately reviewed cumulative bound below; this decision does
15+
not authorize another command unit or an unbounded platform migration.
1516

1617
During the `devices` unit, doctor discovery, replay-test sharding, Apple simulator hints, and Android
1718
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
570571
emission; the distribution cost is the accepted reliability/cloud/substrate decision above. Future
571572
units must define and review their own cumulative budget rather than inheriting this headroom.
572573

574+
The recordings command unit has its own reviewed budget (2026-08-11). The cumulative denominator
575+
remains the original `44c298d7f` baseline; rebasing onto the completed checkpoint does not reset it.
576+
The immediate stack-base delta from durable-capture head `e3b0956b` is reported separately so the
577+
cost of this command unit stays visible. The exact #1724 head that reproduces this table is recorded
578+
in the acceptance comment before readiness. The increase pays for runtime-owned screen-recording
579+
transports, fenced artifact finalization and cross-daemon recovery, and provider parity. It is not
580+
unused checkpoint headroom and is not an allowance for a later command:
581+
582+
| Metric | Original baseline | Recording bound | Cumulative change | Recording-only change |
583+
| --- | ---: | ---: | ---: | ---: |
584+
| Raw JavaScript | 2,036,067 B | 2,166,159 B | +130,092 B (+6.389%) | +32,026 B (+1.501%) |
585+
| Gzipped JavaScript | 659,646 B | 708,776 B | +49,130 B (+7.448%) | +12,902 B (+1.854%) |
586+
| npm tarball | 797,027 B | 836,426 B | +39,399 B (+4.943%) | +8,987 B (+1.086%) |
587+
| npm unpacked | 2,781,186 B | 2,913,430 B | +132,244 B (+4.755%) | +32,494 B (+1.128%) |
588+
589+
These bounds admit only the completed recordings cutover. Every subsequent command unit must define
590+
and review both its original-baseline cumulative bound and its immediate stack-base delta.
591+
573592
The tracking issue owns command order, PR/file lists, test-only compatibility fixtures, exact
574593
benchmark commands and thresholds, raw evidence, and reviewers. Temporary fixtures never authorize
575594
a production bridge, duplicate route, or recorded package back-import. After the checkpoint, this

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
"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",
131131
"check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts",
132132
"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",
133-
"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",
133+
"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",
134134
"depgraph": "node --experimental-strip-types scripts/depgraph/build.ts",
135135
"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",
136136
"check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues",

packages/capture-kit/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export {
99
decodeAppLogProcessMarker,
1010
} from './app-log-runtime.ts';
1111
export { createAppLogLiveHandle, createAppLogLiveHandleFromFinish } from './app-log-live-handle.ts';
12+
export { createScreenRecordingLiveHandle } from './screen-recording-live-handle.ts';
13+
export { createScreenRecordingCompletion } from './screen-recording-completion.ts';
14+
export { assertScreenRecordingOptionsSupported } from './screen-recording-options.ts';
1215
export {
1316
cleanupManagedAppLogProcess,
1417
reattachCleanupOnlyAppLogProcess,

packages/capture-kit/src/platform-runtime-unavailable.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ test('builds one complete combined unavailable owner without fake operations', a
3434
'appLogReattach',
3535
'appLogStart',
3636
'networkDump',
37+
'screenRecordingCleanup',
38+
'screenRecordingReattach',
39+
'screenRecordingStart',
3740
]);
3841
assert.deepEqual(binding.operations, {});
3942
await binding[Symbol.asyncDispose]();

packages/capture-kit/src/platform-runtime-unavailable.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@ import {
1111
type RuntimeOwnerRef,
1212
} from '@agent-device/contracts/platform';
1313

14-
export type UnavailablePlatformRuntimeFacts = Readonly<{
14+
type UnavailablePlatformRuntimeFacts = Readonly<{
1515
appLog: RuntimeOperationUnavailability;
1616
network: RuntimeOperationUnavailability;
17+
screenRecording?: RuntimeOperationUnavailability;
18+
}>;
19+
20+
type FrozenUnavailablePlatformRuntimeFacts = Readonly<{
21+
appLog: RuntimeOperationUnavailability;
22+
network: RuntimeOperationUnavailability;
23+
screenRecording: RuntimeOperationUnavailability;
1724
}>;
1825

1926
/** Builds one honest combined owner for a family with no app-log or network mechanics. */
@@ -22,8 +29,7 @@ export function createUnavailablePlatformRuntimeOwner(
2229
unavailable: UnavailablePlatformRuntimeFacts,
2330
): PlatformRuntimeOwner {
2431
const owner = localRuntimeOwner(family);
25-
const appLog = Object.freeze({ ...unavailable.appLog });
26-
const network = Object.freeze({ ...unavailable.network });
32+
const facts = freezeUnavailableFacts(unavailable);
2733
return Object.freeze({
2834
owner,
2935
ownsDevice: (device) => device.platform === family,
@@ -40,10 +46,7 @@ export function createUnavailablePlatformRuntimeOwner(
4046
`${family} platform runtime cannot bind ${request.device.platform}`,
4147
);
4248
}
43-
return createUnavailablePlatformRuntimeBinding(request.device, owner, {
44-
appLog,
45-
network,
46-
});
49+
return createUnavailablePlatformRuntimeBinding(request.device, owner, facts);
4750
},
4851
shutdown: async () => undefined,
4952
});
@@ -54,8 +57,7 @@ export function createUnavailablePlatformRuntimeBinding(
5457
owner: RuntimeOwnerRef,
5558
unavailable: UnavailablePlatformRuntimeFacts,
5659
): DeviceBinding<PlatformRuntimeOperations> {
57-
const appLog = Object.freeze({ ...unavailable.appLog });
58-
const network = Object.freeze({ ...unavailable.network });
60+
const { appLog, network, screenRecording } = freezeUnavailableFacts(unavailable);
5961
const facts: RuntimeFacts<PlatformRuntimeOperations> = Object.freeze({
6062
device: {
6163
...deviceShape(device),
@@ -68,6 +70,9 @@ export function createUnavailablePlatformRuntimeBinding(
6870
appLogReattach: appLog,
6971
appLogCleanup: appLog,
7072
networkDump: network,
73+
screenRecordingStart: screenRecording,
74+
screenRecordingReattach: screenRecording,
75+
screenRecordingCleanup: screenRecording,
7176
},
7277
});
7378
return Object.freeze({
@@ -78,3 +83,15 @@ export function createUnavailablePlatformRuntimeBinding(
7883
[Symbol.asyncDispose]: async () => undefined,
7984
});
8085
}
86+
87+
function freezeUnavailableFacts(
88+
unavailable: UnavailablePlatformRuntimeFacts,
89+
): FrozenUnavailablePlatformRuntimeFacts {
90+
return Object.freeze({
91+
appLog: Object.freeze({ ...unavailable.appLog }),
92+
network: Object.freeze({ ...unavailable.network }),
93+
screenRecording: Object.freeze({
94+
...(unavailable.screenRecording ?? unavailable.network),
95+
}),
96+
});
97+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test, vi } from 'vitest';
2+
import { createScreenRecordingCompletion } from './screen-recording-completion.ts';
3+
4+
test('builds the common terminal recording result without dropping finalizer metadata', () => {
5+
vi.setSystemTime(200);
6+
expect(
7+
createScreenRecordingCompletion(
8+
{
9+
backend: 'backend',
10+
outPath: '/tmp/capture.mp4',
11+
clientOutPath: '/client/capture.mp4',
12+
startedAt: 100,
13+
scope: 'device',
14+
showTouches: true,
15+
recordOnlySession: false,
16+
gestureEvents: [],
17+
},
18+
{ telemetryPath: '/tmp/capture.telemetry.json' },
19+
false,
20+
),
21+
).toEqual({
22+
status: 'completed',
23+
result: {
24+
backend: 'backend',
25+
outPath: '/tmp/capture.mp4',
26+
clientOutPath: '/client/capture.mp4',
27+
startedAt: 100,
28+
completedAt: 200,
29+
scope: 'device',
30+
showTouches: false,
31+
recordOnlySession: false,
32+
telemetryPath: '/tmp/capture.telemetry.json',
33+
},
34+
});
35+
vi.useRealTimers();
36+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type {
2+
ScreenRecordingCompletion,
3+
ScreenRecordingFinalizer,
4+
ScreenRecordingLiveSnapshot,
5+
} from '@agent-device/contracts/platform';
6+
7+
export function createScreenRecordingCompletion(
8+
snapshot: ScreenRecordingLiveSnapshot,
9+
finalization: Awaited<ReturnType<ScreenRecordingFinalizer['complete']>>,
10+
showTouches = snapshot.showTouches,
11+
): Readonly<{ status: 'completed'; result: ScreenRecordingCompletion }> {
12+
return Object.freeze({
13+
status: 'completed',
14+
result: Object.freeze({
15+
backend: snapshot.backend,
16+
outPath: snapshot.outPath,
17+
...(snapshot.clientOutPath === undefined ? {} : { clientOutPath: snapshot.clientOutPath }),
18+
startedAt: snapshot.startedAt,
19+
completedAt: Date.now(),
20+
scope: snapshot.scope,
21+
showTouches,
22+
recordOnlySession: snapshot.recordOnlySession,
23+
...(snapshot.activeSessionApp === undefined
24+
? {}
25+
: { activeSessionApp: snapshot.activeSessionApp }),
26+
...finalization,
27+
}),
28+
});
29+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import assert from 'node:assert/strict';
2+
import { test, vi } from 'vitest';
3+
import { createScreenRecordingLiveHandle } from './screen-recording-live-handle.ts';
4+
5+
test('keeps mutable gesture evidence on the live handle and settles cleanup once', async () => {
6+
let cleanupCalls = 0;
7+
const handle = createScreenRecordingLiveHandle(
8+
{
9+
backend: 'fixture',
10+
outPath: '/tmp/recording.mp4',
11+
startedAt: 1,
12+
scope: 'app',
13+
showTouches: true,
14+
recordOnlySession: false,
15+
gestureEvents: [],
16+
},
17+
{
18+
finish: async () => ({
19+
status: 'completed',
20+
result: {
21+
backend: 'fixture',
22+
outPath: '/tmp/recording.mp4',
23+
startedAt: 1,
24+
completedAt: 2,
25+
scope: 'app',
26+
showTouches: true,
27+
recordOnlySession: false,
28+
},
29+
}),
30+
forceCleanup: async () => {
31+
cleanupCalls += 1;
32+
return { status: 'cleaned' } as const;
33+
},
34+
},
35+
);
36+
handle.appendGestureEvents([{ kind: 'tap', tMs: 3, x: 4, y: 5 }]);
37+
handle.setRunnerSessionId('runner-1');
38+
handle.invalidate('runner restarted');
39+
assert.deepEqual(handle.inspect().gestureEvents, [{ kind: 'tap', tMs: 3, x: 4, y: 5 }]);
40+
assert.equal(handle.inspect().invalidatedReason, 'runner restarted');
41+
assert.equal(handle.inspect().runnerSessionId, 'runner-1');
42+
await handle.forceCleanup();
43+
await handle.forceCleanup();
44+
assert.equal(cleanupCalls, 1);
45+
});
46+
47+
test('successful finish makes concurrent disposal inert', async () => {
48+
let resolveFinish: ((outcome: ReturnType<typeof completed>) => void) | undefined;
49+
const finish = vi.fn(
50+
async () =>
51+
await new Promise<ReturnType<typeof completed>>((resolve) => {
52+
resolveFinish = resolve;
53+
}),
54+
);
55+
const cleanup = vi.fn(async () => ({ status: 'cleaned' }) as const);
56+
const handle = createScreenRecordingLiveHandle(snapshot(), { finish, forceCleanup: cleanup });
57+
58+
const finishing = handle.finish();
59+
const disposing = handle[Symbol.asyncDispose]();
60+
resolveFinish?.(completed());
61+
62+
await assert.doesNotReject(async () => await disposing);
63+
assert.deepEqual(await finishing, completed());
64+
assert.equal(finish.mock.calls.length, 1);
65+
assert.equal(cleanup.mock.calls.length, 0);
66+
});
67+
68+
test('failed finish permits one forced cleanup', async () => {
69+
const finish = vi.fn(async () => {
70+
throw new Error('finalization failed');
71+
});
72+
const cleanup = vi.fn(async () => ({ status: 'cleaned' }) as const);
73+
const handle = createScreenRecordingLiveHandle(snapshot(), { finish, forceCleanup: cleanup });
74+
75+
await assert.rejects(async () => await handle.finish(), /finalization failed/);
76+
await handle[Symbol.asyncDispose]();
77+
await handle[Symbol.asyncDispose]();
78+
79+
assert.equal(finish.mock.calls.length, 1);
80+
assert.equal(cleanup.mock.calls.length, 1);
81+
});
82+
83+
function snapshot() {
84+
return {
85+
backend: 'fixture',
86+
outPath: '/tmp/recording.mp4',
87+
startedAt: 1,
88+
scope: 'app' as const,
89+
showTouches: false,
90+
recordOnlySession: false,
91+
gestureEvents: [],
92+
};
93+
}
94+
95+
function completed() {
96+
return {
97+
status: 'completed' as const,
98+
result: {
99+
backend: 'fixture',
100+
outPath: '/tmp/recording.mp4',
101+
startedAt: 1,
102+
completedAt: 2,
103+
scope: 'app' as const,
104+
showTouches: false,
105+
recordOnlySession: false,
106+
},
107+
};
108+
}

0 commit comments

Comments
 (0)