Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 21 additions & 2 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/capture-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/capture-kit/src/platform-runtime-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]();
Expand Down
35 changes: 26 additions & 9 deletions packages/capture-kit/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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,
Expand All @@ -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,
});
Expand All @@ -54,8 +57,7 @@ export function createUnavailablePlatformRuntimeBinding(
owner: RuntimeOwnerRef,
unavailable: UnavailablePlatformRuntimeFacts,
): DeviceBinding<PlatformRuntimeOperations> {
const appLog = Object.freeze({ ...unavailable.appLog });
const network = Object.freeze({ ...unavailable.network });
const { appLog, network, screenRecording } = freezeUnavailableFacts(unavailable);
const facts: RuntimeFacts<PlatformRuntimeOperations> = Object.freeze({
device: {
...deviceShape(device),
Expand All @@ -68,6 +70,9 @@ export function createUnavailablePlatformRuntimeBinding(
appLogReattach: appLog,
appLogCleanup: appLog,
networkDump: network,
screenRecordingStart: screenRecording,
screenRecordingReattach: screenRecording,
screenRecordingCleanup: screenRecording,
},
});
return Object.freeze({
Expand All @@ -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),
}),
});
}
36 changes: 36 additions & 0 deletions packages/capture-kit/src/screen-recording-completion.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
29 changes: 29 additions & 0 deletions packages/capture-kit/src/screen-recording-completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type {
ScreenRecordingCompletion,
ScreenRecordingFinalizer,
ScreenRecordingLiveSnapshot,
} from '@agent-device/contracts/platform';

export function createScreenRecordingCompletion(
snapshot: ScreenRecordingLiveSnapshot,
finalization: Awaited<ReturnType<ScreenRecordingFinalizer['complete']>>,
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,
}),
});
}
108 changes: 108 additions & 0 deletions packages/capture-kit/src/screen-recording-live-handle.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof completed>) => void) | undefined;
const finish = vi.fn(
async () =>
await new Promise<ReturnType<typeof completed>>((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,
},
};
}
Loading
Loading