Skip to content
Open
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
2 changes: 2 additions & 0 deletions scripts/layering/architecture-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,12 @@ export const ARCHITECTURE_OWNERSHIP = {
exports: [
'BindDeviceRuntime',
'BindExactDeviceRuntime',
'BoundDeviceIdentity',
'InspectDeviceRuntimeFacts',
'RequestRuntimeBindings',
'RuntimeAdmissionBindings',
'createRequestRuntimeBindings',
'ensureBoundDeviceReady',
],
},
{
Expand Down
79 changes: 77 additions & 2 deletions src/daemon/__tests__/request-runtime-binding.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test, vi } from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime';
import {
appLogAdmissionUse,
Expand All @@ -11,14 +11,23 @@ import {
type DeviceRuntimeGateway,
type RuntimeOwnerRef,
localRuntimeOwner,
managedLocalRuntimeOwner,
providerRuntimeOwner,
} from '@agent-device/contracts/platform-runtime';
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
import { screenRecordingRecoveryUse } from '@agent-device/contracts/screen-recording-runtime-plan';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts';
import { createDurableResourceEnvelope } from '@agent-device/capture-kit';
import { acquireDurableCaptureRecoveryAuthorityBeforeDeadline } from '../durable-capture-recovery-authority.ts';
import { createRequestRuntimeBindings } from '../request-runtime-binding.ts';
import {
createRequestRuntimeBindings,
ensureBoundDeviceReady,
} from '../request-runtime-binding.ts';
import { ensureDeviceReady } from '../device-ready.ts';
import { admitRuntimeUse } from '../runtime-admission.ts';

vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) }));

const inspectPlan = resolveLogsRuntimePlan({ action: 'path' });
const doctorPlan = resolveLogsRuntimePlan({ action: 'doctor' });
Expand All @@ -35,6 +44,72 @@ const scope = {
};

const admitDeviceClaim = async () => {};
const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady);

beforeEach(() => {
mockEnsureDeviceReady.mockReset();
mockEnsureDeviceReady.mockResolvedValue(undefined);
});

test('bound readiness preserves local behavior after the binding fence', async () => {
const selected = device('ready-after-bind');
const options = { focusExisting: true };

await ensureBoundDeviceReady({ device: selected, owner: localRuntimeOwner('android') }, options);

expect(mockEnsureDeviceReady).toHaveBeenCalledWith(selected, options);
});

test('bound readiness leaves provider-owned devices alone', async () => {
await ensureBoundDeviceReady({
device: device('provider-ready'),
owner: providerRuntimeOwner('test', 'provider-ready'),
});

expect(mockEnsureDeviceReady).not.toHaveBeenCalled();
});

test('bound readiness refuses managed devices without allocator confirmation', async () => {
await expect(
ensureBoundDeviceReady({
device: device('managed-ready'),
owner: managedLocalRuntimeOwner('simlock-test'),
}),
).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'managed-readiness-unavailable' },
});
expect(mockEnsureDeviceReady).not.toHaveBeenCalled();
});

test('runtime readiness follows allocator claim admission', async () => {
const events: string[] = [];
const runtime = makeGateway();
const admit = vi.fn(async () => {
events.push('claim');
});
const bindings = createRequestRuntimeBindings({
gateway: runtime.gateway,
scope,
admitDeviceClaim: admit,
});
mockEnsureDeviceReady.mockImplementation(async () => {
events.push('ready');
});

const admission = await admitRuntimeUse({
command: 'logs',
device: device('claim-order'),
use: appLogInspectUse,
inspectFacts: bindings.inspectFacts,
bindDevice: bindings.bindDevice,
readiness: {},
});

expect(admission.type).toBe('runtime');
expect(events).toEqual(['claim', 'ready']);
await bindings[Symbol.asyncDispose]();
});

test('request runtime binding caches one broad owner and projects each declared use', async () => {
const runtime = makeGateway();
Expand Down
15 changes: 14 additions & 1 deletion src/daemon/__tests__/session-device-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { getRunnerSessionSnapshot } from '@agent-device/platform-apple/runner/operations';
import { resolveTargetDevice } from '../../core/dispatch-resolve.ts';
import { isActiveProviderDevice } from '../../provider-device-runtime.ts';
import { ensureDeviceReady } from '../device-ready.ts';

vi.mock('@agent-device/platform-apple/runner/operations', () => ({
getRunnerSessionSnapshot: vi.fn(async () => null),
Expand All @@ -26,13 +27,16 @@ vi.mock('../device-ready.ts', () => ({
const mockGetRunnerSessionSnapshot = vi.mocked(getRunnerSessionSnapshot);
const mockResolveTargetDevice = vi.mocked(resolveTargetDevice);
const mockIsActiveProviderDevice = vi.mocked(isActiveProviderDevice);
const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady);

beforeEach(() => {
mockGetRunnerSessionSnapshot.mockReset();
mockGetRunnerSessionSnapshot.mockResolvedValue(null);
mockResolveTargetDevice.mockReset();
mockIsActiveProviderDevice.mockReset();
mockIsActiveProviderDevice.mockReturnValue(false);
mockEnsureDeviceReady.mockReset();
mockEnsureDeviceReady.mockResolvedValue(undefined);
});

const iosSimulatorSession: SessionState = {
Expand Down Expand Up @@ -73,14 +77,23 @@ test('resolveCommandDevice keeps an existing session for a platform-only filter'
await resolveCommandDevice({
session: iosSimulatorSession,
flags: { platform: 'ios' },
ensureReady: false,
}),
);

expect(device).toBe(iosSimulatorSession.device);
expect(mockResolveTargetDevice).not.toHaveBeenCalled();
});

test('resolveCommandDevice does not prepare a sessionless device', async () => {
const device = { ...iosSimulatorSession.device, id: 'sessionless-sim' };
mockResolveTargetDevice.mockResolvedValue(device);

await resolveCommandDevice({ session: undefined, flags: { platform: 'ios' } });

expect(mockResolveTargetDevice).toHaveBeenCalledOnce();
expect(mockEnsureDeviceReady).not.toHaveBeenCalled();
});

test('refreshSessionDeviceIfNeeded keeps provider-owned iOS simulators out of local refresh', async () => {
mockIsActiveProviderDevice.mockReturnValue(true);

Expand Down
39 changes: 37 additions & 2 deletions src/daemon/__tests__/snapshot-runtime-binding.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import { resolveSnapshotRuntimePlan } from '@agent-device/contracts/platform-runtime-operations';
import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import type { BindDeviceRuntime } from '../request-runtime-binding.ts';
import { resolveBoundSnapshotCaptureRuntime } from '../snapshot-runtime-binding.ts';
import {
admitAndBindSnapshotCapture,
resolveBoundSnapshotCaptureRuntime,
} from '../snapshot-runtime-binding.ts';
import type { DaemonRequest } from '../types.ts';
import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts';
import { ensureDeviceReady } from '../device-ready.ts';

vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) }));

const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady);

// The owning interface (ADR 0019 §9): one plan, one facts-first admission, one bind — and the
// bind target is whatever the admission was minted for, read back by token identity. The binder
Expand Down Expand Up @@ -46,3 +55,29 @@ test('the owning interface binds exactly the session device the facts were admit
expect(resolved.ok).toBe(true);
expect(boundDevices).toEqual([IOS_SIMULATOR]);
});

test('sessionless capture readiness follows runtime binding', async () => {
const fixture = snapshotRuntimeFixture();
const events: string[] = [];
mockEnsureDeviceReady.mockReset();
mockEnsureDeviceReady.mockImplementation(async () => {
events.push('ready');
});
const recordingBind: BindDeviceRuntime = async (device, use) => {
events.push('bind');
return await fixture.bindDevice(device, use);
};

const resolved = await admitAndBindSnapshotCapture({
command: 'snapshot',
device: IOS_SIMULATOR,
session: undefined,
plan: resolveSnapshotRuntimePlan({ customActions: false, hasActiveApp: true }),
inspectFacts: fixture.inspectFacts,
bindDevice: recordingBind,
readiness: {},
});

expect(resolved.ok).toBe(true);
expect(events).toEqual(['bind', 'ready']);
});
10 changes: 8 additions & 2 deletions src/daemon/app-event-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { DaemonCommandContext } from './context.ts';
import { admitRuntimeUse, type RuntimeAdmissionBindings } from './runtime-admission.ts';
import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts';
import type { DaemonFailureResponse } from './response.ts';
import type { DeviceReadyOptions } from './device-ready.ts';

/**
* What the admit-then-bind step reports: either the refusal an unadmitted cell produced, or the
Expand Down Expand Up @@ -55,16 +56,21 @@ async function executeAppEvent(
* argument is read.
*/
export async function resolveBoundAppEventRuntime(
params: Readonly<{ device: DeviceInfo; positionals: readonly string[] }> &
params: Readonly<{
device: DeviceInfo;
positionals: readonly string[];
readiness?: DeviceReadyOptions;
}> &
RuntimeAdmissionBindings,
): Promise<ResolvedAppEventExecution> {
const { device, positionals, inspectFacts, bindDevice } = params;
const { device, positionals, inspectFacts, bindDevice, readiness } = params;
const admission = await admitRuntimeUse({
command: 'trigger-app-event',
device,
use: appEventRuntimeUse,
inspectFacts,
bindDevice,
readiness,
});
if (admission.type === 'response') return { ok: false, response: admission.response };
const runtime = admission.runtime;
Expand Down
22 changes: 21 additions & 1 deletion src/daemon/handlers/__tests__/session-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import type {
BindDeviceRuntime,
InspectDeviceRuntimeFacts,
} from '../../request-runtime-binding.ts';
import { makeSession, makeSessionStore, mockResolveTargetDevice } from './session-test-harness.ts';
import {
makeSession,
makeSessionStore,
mockEnsureDeviceReady,
mockResolveTargetDevice,
} from './session-test-harness.ts';
import { handleSessionClipboardCommand } from '../session-clipboard.ts';

// File-scoped id, not a shared literal: this owner binding's `local-family` kind reaches the real
Expand Down Expand Up @@ -97,6 +102,21 @@ test('clipboard read admits clipboardReadUse and reports the platform-labelled t
expect(spies.writeClipboard).not.toHaveBeenCalled();
});

test('clipboard readiness follows runtime binding', async () => {
const spies = harness({ read: available, write: available });
const response = await handleSessionClipboardCommand({ ...request(['read']), ...spies });

expect(response.ok).toBe(true);
expect(spies.bindDevice).toHaveBeenCalledTimes(1);
expect(mockEnsureDeviceReady).toHaveBeenCalledTimes(1);
const bindSpy = vi.mocked(spies.bindDevice);
const bindOrder = bindSpy.mock.invocationCallOrder[0];
const readyOrder = mockEnsureDeviceReady.mock.invocationCallOrder[0];
expect(bindOrder).toBeDefined();
expect(readyOrder).toBeDefined();
expect(bindOrder!).toBeLessThan(readyOrder!);
});

test('clipboard write joins its positionals and reports the code-point length', async () => {
const spies = harness({ read: available, write: available });
const response = await handleSessionClipboardCommand({
Expand Down
34 changes: 24 additions & 10 deletions src/daemon/handlers/record-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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-resolve.ts';
import { ensureDeviceReady } from '../device-ready.ts';
import { ensureBoundDeviceReady } from '../request-runtime-binding.ts';
import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts';
import {
adoptStartedScreenRecording,
Expand Down Expand Up @@ -69,11 +69,18 @@ async function handleRecordCommandUnsafe(
if (plan.kind === 'start' && !isWholeScreenRecordingScope(scope) && !existingSession) {
return missingAppSessionResponse(req);
}
const session = await resolveRecordingSession(params, existingSession);
const resolvedSession = await resolveRecordingSession(params, existingSession);
const { session } = resolvedSession;
if (plan.kind === 'start') {
return await startRecording(params, session, prepareRecordingRequest(req), plan.use);
return await startRecording(
params,
session,
prepareRecordingRequest(req),
plan.use,
resolvedSession.needsReadiness,
);
}
return await stopRecording(params, session, plan.kind);
return await stopRecording(params, session, plan.kind, resolvedSession.needsReadiness);
}

function resolveRecordPlan(req: DaemonRequest, session: SessionState | undefined) {
Expand All @@ -91,24 +98,25 @@ function resolveRecordPlan(req: DaemonRequest, session: SessionState | undefined
async function resolveRecordingSession(
params: RecordRuntimeHandlerParams,
existing: SessionState | undefined,
): Promise<SessionState> {
): Promise<Readonly<{ session: SessionState; needsReadiness: boolean }>> {
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);
if (existing) return { session: existing, needsReadiness: false };
return { session: createRecordOnlySession(params, device), needsReadiness: true };
}

async function startRecording(
params: RecordRuntimeHandlerParams,
session: SessionState,
prepared: ReturnType<typeof prepareRecordingRequest>,
use: typeof screenRecordingStartUse,
needsReadiness: boolean,
): Promise<DaemonResponse> {
if (session.screenRecording) {
return { ok: false, error: { code: 'INVALID_ARGS', message: 'recording already in progress' } };
}
const admission = await params.bindDevice(session.device, screenRecordingAdmissionUse);
if (needsReadiness) await ensureBoundDeviceReady(admission);
const startFact = admission.facts.screenRecordingStart;
if (!startFact.available) return buildRecordingUnsupportedResponse(startFact);
const runtime = await params.bindDevice(session.device, use);
Expand Down Expand Up @@ -195,6 +203,7 @@ async function stopRecording(
params: RecordRuntimeHandlerParams,
session: SessionState,
kind: 'stop-live' | 'stop-recovery',
needsReadiness: boolean,
): Promise<DaemonResponse> {
let completion;
try {
Expand All @@ -205,7 +214,7 @@ async function stopRecording(
sessionName: params.sessionName,
sessionStore: params.sessionStore,
})
: await finishRecovered(params, session);
: await finishRecovered(params, session, needsReadiness);
} catch (error) {
deleteTerminalRecordOnlySession(params, session);
throw error;
Expand Down Expand Up @@ -237,7 +246,11 @@ function deleteTerminalRecordOnlySession(
}
}

async function finishRecovered(params: RecordRuntimeHandlerParams, session: SessionState) {
async function finishRecovered(
params: RecordRuntimeHandlerParams,
session: SessionState,
needsReadiness: boolean,
) {
const resourcePath = screenRecordingDurableResource.store.resolvePath(
params.sessionStore.resolveSessionDir(params.sessionName),
);
Expand Down Expand Up @@ -270,6 +283,7 @@ async function finishRecovered(params: RecordRuntimeHandlerParams, session: Sess
screenRecordingRecoveryUse,
recoveryScope,
);
if (needsReadiness) await ensureBoundDeviceReady(runtime);
return createScreenRecordingRecoveryControl({ runtime, dispose: async () => {} });
},
});
Expand Down
Loading
Loading