diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index 347dfd8fb..42b6730e8 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -191,10 +191,12 @@ export const ARCHITECTURE_OWNERSHIP = { exports: [ 'BindDeviceRuntime', 'BindExactDeviceRuntime', + 'BoundDeviceIdentity', 'InspectDeviceRuntimeFacts', 'RequestRuntimeBindings', 'RuntimeAdmissionBindings', 'createRequestRuntimeBindings', + 'ensureBoundDeviceReady', ], }, { diff --git a/src/daemon/__tests__/request-runtime-binding.test.ts b/src/daemon/__tests__/request-runtime-binding.test.ts index 7b7e5ab77..029d9f21d 100644 --- a/src/daemon/__tests__/request-runtime-binding.test.ts +++ b/src/daemon/__tests__/request-runtime-binding.test.ts @@ -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, @@ -11,6 +11,8 @@ 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'; @@ -18,7 +20,14 @@ 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' }); @@ -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(); diff --git a/src/daemon/__tests__/session-device-resolution.test.ts b/src/daemon/__tests__/session-device-resolution.test.ts index f1919e089..2aeaeb824 100644 --- a/src/daemon/__tests__/session-device-resolution.test.ts +++ b/src/daemon/__tests__/session-device-resolution.test.ts @@ -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), @@ -26,6 +27,7 @@ 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(); @@ -33,6 +35,8 @@ beforeEach(() => { mockResolveTargetDevice.mockReset(); mockIsActiveProviderDevice.mockReset(); mockIsActiveProviderDevice.mockReturnValue(false); + mockEnsureDeviceReady.mockReset(); + mockEnsureDeviceReady.mockResolvedValue(undefined); }); const iosSimulatorSession: SessionState = { @@ -73,7 +77,6 @@ test('resolveCommandDevice keeps an existing session for a platform-only filter' await resolveCommandDevice({ session: iosSimulatorSession, flags: { platform: 'ios' }, - ensureReady: false, }), ); @@ -81,6 +84,16 @@ test('resolveCommandDevice keeps an existing session for a platform-only filter' 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); diff --git a/src/daemon/__tests__/snapshot-runtime-binding.test.ts b/src/daemon/__tests__/snapshot-runtime-binding.test.ts index 8b3ee18f7..7d6c81fdb 100644 --- a/src/daemon/__tests__/snapshot-runtime-binding.test.ts +++ b/src/daemon/__tests__/snapshot-runtime-binding.test.ts @@ -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 @@ -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']); +}); diff --git a/src/daemon/app-event-runtime.ts b/src/daemon/app-event-runtime.ts index 7cb2cd107..ababf46e0 100644 --- a/src/daemon/app-event-runtime.ts +++ b/src/daemon/app-event-runtime.ts @@ -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 @@ -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 { - 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; diff --git a/src/daemon/handlers/__tests__/session-clipboard.test.ts b/src/daemon/handlers/__tests__/session-clipboard.test.ts index e22c3018e..280fa7635 100644 --- a/src/daemon/handlers/__tests__/session-clipboard.test.ts +++ b/src/daemon/handlers/__tests__/session-clipboard.test.ts @@ -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 @@ -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({ diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 4f0aa728c..79b46c4ab 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -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, @@ -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) { @@ -91,12 +98,11 @@ function resolveRecordPlan(req: DaemonRequest, session: SessionState | undefined async function resolveRecordingSession( params: RecordRuntimeHandlerParams, existing: SessionState | undefined, -): Promise { +): 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); + if (existing) return { session: existing, needsReadiness: false }; + return { session: createRecordOnlySession(params, device), needsReadiness: true }; } async function startRecording( @@ -104,11 +110,13 @@ async function startRecording( session: SessionState, prepared: ReturnType, use: typeof screenRecordingStartUse, + needsReadiness: boolean, ): Promise { 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); @@ -195,6 +203,7 @@ async function stopRecording( params: RecordRuntimeHandlerParams, session: SessionState, kind: 'stop-live' | 'stop-recovery', + needsReadiness: boolean, ): Promise { let completion; try { @@ -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; @@ -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), ); @@ -270,6 +283,7 @@ async function finishRecovered(params: RecordRuntimeHandlerParams, session: Sess screenRecordingRecoveryUse, recoveryScope, ); + if (needsReadiness) await ensureBoundDeviceReady(runtime); return createScreenRecordingRecoveryControl({ runtime, dispose: async () => {} }); }, }); diff --git a/src/daemon/handlers/session-app-deployment.ts b/src/daemon/handlers/session-app-deployment.ts index 5688e9ec7..ce7552856 100644 --- a/src/daemon/handlers/session-app-deployment.ts +++ b/src/daemon/handlers/session-app-deployment.ts @@ -66,7 +66,7 @@ export async function handleAppDeploymentCommand(params: { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } - const device = await resolveCommandDevice({ session, flags, ensureReady: false }); + const device = await resolveCommandDevice({ session, flags }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); const unsupported = unavailableRuntimeOperationResponse(command, facts.operations.deployApp); if (unsupported) return unsupported; @@ -114,7 +114,7 @@ export async function handlePushNotificationCommand( ); } const payload = await readNotificationPayload(resolvePushPayload(payloadArg, req.meta?.cwd)); - const device = await resolveCommandDevice({ session, flags, ensureReady: false }); + const device = await resolveCommandDevice({ session, flags }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); const unsupported = unavailableRuntimeOperationResponse('push', facts.operations.ensureReady) ?? diff --git a/src/daemon/handlers/session-app-source-deployment.ts b/src/daemon/handlers/session-app-source-deployment.ts index 14bc983b9..adca6983a 100644 --- a/src/daemon/handlers/session-app-source-deployment.ts +++ b/src/daemon/handlers/session-app-source-deployment.ts @@ -144,7 +144,7 @@ async function resolveInstallDevice( if (session) { return session.device; } - return await resolveCommandDevice({ session, flags, ensureReady: false }); + return await resolveCommandDevice({ session, flags }); } function normalizePlatform( diff --git a/src/daemon/handlers/session-clipboard.ts b/src/daemon/handlers/session-clipboard.ts index 3f4975881..c58aa65f6 100644 --- a/src/daemon/handlers/session-clipboard.ts +++ b/src/daemon/handlers/session-clipboard.ts @@ -115,6 +115,7 @@ async function resolveBoundClipboardRuntime( use: clipboardReadUse, inspectFacts, bindDevice, + readiness: {}, }); if (admission.type === 'response') return { ok: false, response: admission.response }; const runtime = admission.runtime; @@ -126,6 +127,7 @@ async function resolveBoundClipboardRuntime( use: clipboardWriteUse, inspectFacts, bindDevice, + readiness: {}, }); if (admission.type === 'response') return { ok: false, response: admission.response }; const runtime = admission.runtime; @@ -152,7 +154,7 @@ export async function handleSessionClipboardCommand(params: { return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); } - const device = await resolveCommandDevice({ session, flags, ensureReady: true }); + const device = await resolveCommandDevice({ session, flags }); const bound = await resolveBoundClipboardRuntime({ device, action, diff --git a/src/daemon/handlers/session-prepare.ts b/src/daemon/handlers/session-prepare.ts index 747779cd0..ac2688a9f 100644 --- a/src/daemon/handlers/session-prepare.ts +++ b/src/daemon/handlers/session-prepare.ts @@ -54,7 +54,7 @@ export async function handlePrepareCommand(params: { // Device selection is side-effect free enough for facts admission. The bound lifecycle owns // readiness, keeping provider-first facts as the sole support authority. - const device = await resolveCommandDevice({ session, flags, ensureReady: false }); + const device = await resolveCommandDevice({ session, flags }); const admission = await admitPrepareRuntime({ device, inspectFacts: params.inspectFacts, diff --git a/src/daemon/handlers/session-runtime-port-reverse.ts b/src/daemon/handlers/session-runtime-port-reverse.ts index 111792b87..cd28c62cc 100644 --- a/src/daemon/handlers/session-runtime-port-reverse.ts +++ b/src/daemon/handlers/session-runtime-port-reverse.ts @@ -38,7 +38,6 @@ export async function handlePortReverseCommand(params: { const device = await resolveCommandDevice({ session, flags: req.flags, - ensureReady: false, }); const admission = await admitRuntimeUse({ device, diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index 64f57a80e..ae2611bb7 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -19,6 +19,7 @@ import { } from '../../platform-runtime-open-target.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { DaemonCommandContext } from '../context.ts'; +import type { DeviceReadyOptions } from '../device-ready.ts'; /** * What `runSessionOrSelectorDispatch`'s `prepare` thunk reports: either the early-exit response an @@ -76,7 +77,6 @@ async function runSessionOrSelectorDispatch(params: { const device = await resolveCommandDevice({ session, flags, - ensureReady: true, }); const prepared = await prepare(device, session); if (!prepared.ok) return prepared.response; @@ -138,6 +138,7 @@ type SessionRouteRuntimeResolver = ( params: Readonly<{ device: DeviceInfo; positionals: string[]; + readiness?: DeviceReadyOptions; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; }>, @@ -182,6 +183,7 @@ async function runBoundSessionRoute( const bound = await params.resolveRuntime({ device, positionals, + readiness: {}, inspectFacts, bindDevice, }); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index dc8987853..2102682ed 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -163,7 +163,6 @@ async function handleAppStateCommand(params: RuntimeCommandHandlerParams): Promi const device = await resolveCommandDevice({ session, flags, - ensureReady: false, }); if (isIosFamily(device)) { return errorResponse('SESSION_NOT_FOUND', IOS_APPSTATE_SESSION_REQUIRED_MESSAGE); @@ -232,7 +231,6 @@ export async function handleSessionStateCommands(params: { device = await resolveCommandDevice({ session, flags, - ensureReady: false, androidAvdSelection: 'include-stopped', }); } catch (error) { @@ -298,7 +296,6 @@ export async function handleSessionStateCommands(params: { if (guard) return guard; const device = await resolveCommandDevice({ - ensureReady: false, flags, session: activeSession, androidAvdSelection: 'include-stopped', diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index f06b976d0..265f71727 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -47,10 +47,21 @@ type ResolvedAlertExecution = * that wording is parity-pinned. */ async function resolveBoundAlertRuntime( - params: Readonly<{ device: DeviceInfo; action: AlertAction }> & RuntimeAdmissionBindings, + params: Readonly<{ + device: DeviceInfo; + action: AlertAction; + session: SessionState | undefined; + }> & + RuntimeAdmissionBindings, ): Promise { const { device, action, inspectFacts, bindDevice } = params; - const shared = { command: 'alert', device, inspectFacts, bindDevice }; + const shared = { + command: 'alert', + device, + inspectFacts, + bindDevice, + ...(params.session ? {} : { readiness: {} }), + }; if (action === 'wait') { const admission = await admitRuntimeUse({ ...shared, use: alertWaitUse }); if (admission.type === 'response') return { ok: false, response: admission.response }; @@ -108,7 +119,13 @@ export async function handleAlertCommand( ): Promise { const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; const action = normalizeAlertAction(req.positionals?.[0]); - const bound = await resolveBoundAlertRuntime({ device, action, inspectFacts, bindDevice }); + const bound = await resolveBoundAlertRuntime({ + device, + action, + session, + inspectFacts, + bindDevice, + }); if (!bound.ok) return bound.response; // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait are read-only. // The alert resolver returns `may-invalidate` only for the acting subactions, so this covers diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index f8865c163..0be848340 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -161,6 +161,7 @@ export async function handleSettingsCommand( use: settingsRuntimeUse, inspectFacts, bindDevice, + ...(session ? {} : { readiness: {} }), }); if (admission.type === 'response') return admission.response; if (isMacOs(device) && !isMacOsSettingSupported(setting)) { diff --git a/src/daemon/keyboard-runtime.ts b/src/daemon/keyboard-runtime.ts index 1a7f3f9b9..9b0bcfbe2 100644 --- a/src/daemon/keyboard-runtime.ts +++ b/src/daemon/keyboard-runtime.ts @@ -27,6 +27,7 @@ import { } 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'; type KeyboardRuntimeAction = 'status' | 'dismiss' | 'enter'; @@ -218,24 +219,46 @@ async function executeKeyboardEnter( export async function resolveBoundKeyboardRuntime( params: { device: DeviceInfo; + readiness?: DeviceReadyOptions; } & RuntimeAdmissionBindings & { positionals: readonly string[] }, ): Promise { const action = readKeyboardAction(params.positionals); - const { device, inspectFacts, bindDevice } = params; + const { device, inspectFacts, bindDevice, readiness } = params; if (action === 'status') { return await admitKeyboardAction( - { command: 'keyboard status', device, use: keyboardStatusUse, inspectFacts, bindDevice }, + { + command: 'keyboard status', + device, + use: keyboardStatusUse, + inspectFacts, + bindDevice, + readiness, + }, (runtime, context) => executeKeyboardStatus(runtime, context), ); } if (action === 'dismiss') { return await admitKeyboardAction( - { command: 'keyboard dismiss', device, use: keyboardDismissUse, inspectFacts, bindDevice }, + { + command: 'keyboard dismiss', + device, + use: keyboardDismissUse, + inspectFacts, + bindDevice, + readiness, + }, (runtime, context) => executeKeyboardDismiss(runtime, context), ); } return await admitKeyboardAction( - { command: 'keyboard enter', device, use: keyboardEnterUse, inspectFacts, bindDevice }, + { + command: 'keyboard enter', + device, + use: keyboardEnterUse, + inspectFacts, + bindDevice, + readiness, + }, (runtime, context) => executeKeyboardEnter(runtime, context), ); } diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts index 620e26525..32950b7fe 100644 --- a/src/daemon/request-runtime-binding.ts +++ b/src/daemon/request-runtime-binding.ts @@ -1,4 +1,5 @@ import { deviceIdentity, deviceIdentityKey, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { AsyncCleanupStack } from '@agent-device/contracts/async-lifecycle'; import { type BoundDeviceRuntime, @@ -14,6 +15,7 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { ensureDeviceReady, type DeviceReadyOptions } from './device-ready.ts'; export type BindDeviceRuntime = < const Required extends readonly RuntimeOperationKey[], @@ -68,6 +70,30 @@ export type InspectDeviceRuntimeFacts = ( device: DeviceInfo, ) => Promise>; +export type BoundDeviceIdentity = Readonly<{ + device: DeviceInfo; + owner: RuntimeOwnerRef; +}>; + +/** Runs legacy local readiness only after the request has crossed the binding/claim fence. */ +export async function ensureBoundDeviceReady( + bound: BoundDeviceIdentity, + options: DeviceReadyOptions = {}, +): Promise { + switch (bound.owner.kind) { + case 'provider-runtime': + return; + case 'managed-local': + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Managed-device readiness is unavailable until allocator confirmation.', + { reason: 'managed-readiness-unavailable' }, + ); + case 'local-family': + await ensureDeviceReady(bound.device, options); + } +} + export type RequestRuntimeBindings = AsyncDisposable & Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; diff --git a/src/daemon/runtime-admission.ts b/src/daemon/runtime-admission.ts index 7220643dd..df4346e3d 100644 --- a/src/daemon/runtime-admission.ts +++ b/src/daemon/runtime-admission.ts @@ -12,6 +12,8 @@ import type { InspectDeviceRuntimeFacts, RuntimeAdmissionBindings, } from './request-runtime-binding.ts'; +import { ensureBoundDeviceReady } from './request-runtime-binding.ts'; +import type { DeviceReadyOptions } from './device-ready.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; import type { DaemonCommandContext } from './context.ts'; import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; @@ -36,6 +38,7 @@ export type RuntimeAdmissionRequest = RuntimeAdmissionBindings & device: DeviceInfo; required: readonly RuntimeOperationKey[]; unavailableResponse?: UnavailableRuntimeResponse; + readiness?: DeviceReadyOptions; }>; export type { RuntimeAdmissionBindings }; @@ -91,7 +94,11 @@ export async function admitRuntimeUse< > { const admitted = await admitRuntimeOperations({ ...request, required: request.use.required }); if (admitted.type === 'response') return admitted; - return { type: 'runtime', runtime: await admitted.bind(request.device, request.use) }; + const runtime = await admitted.bind(request.device, request.use); + if (request.readiness !== undefined) { + await ensureBoundDeviceReady(runtime, request.readiness); + } + return { type: 'runtime', runtime }; } /** diff --git a/src/daemon/selector-capture-binding.ts b/src/daemon/selector-capture-binding.ts index e9375d03d..df6b02458 100644 --- a/src/daemon/selector-capture-binding.ts +++ b/src/daemon/selector-capture-binding.ts @@ -64,6 +64,7 @@ export async function resolveBoundSelectorCapture( hasActiveApp: params.session?.appBundleId !== undefined, intent: selectorCaptureIntent(params.command), }), + ...(params.session ? {} : { readiness: {} }), }); if (!bound.ok) return bound; // The read is present only when the admitted owner advertised it; its absence is not a failure diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 844f6b219..8f2905186 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -11,7 +11,6 @@ import type { SnapshotState, SnapshotNode } from '@agent-device/kernel/snapshot' import { createDaemonRuntimePolicy } from './runtime-policy.ts'; import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { contextFromFlags, type BoundContextFromFlags } from './context.ts'; -import { ensureDeviceReady } from './device-ready.ts'; import { readTextForNode } from './selector-text-runtime.ts'; import { setSessionSnapshot } from './session-snapshot.ts'; import { SessionStore } from './session-store.ts'; @@ -93,7 +92,6 @@ async function resolveSelectorRuntimeDevice( const session = params.sessionStore.get(params.sessionName); if (!session && requireSession) return { ok: false, response: noActiveSessionError() }; const device = session?.device ?? (await resolveTargetDevice(params.req.flags ?? {})); - if (!session) await ensureDeviceReady(device); return { ok: true, session, device }; } diff --git a/src/daemon/session-device-resolution.ts b/src/daemon/session-device-resolution.ts index 988b375a6..9e469cfbe 100644 --- a/src/daemon/session-device-resolution.ts +++ b/src/daemon/session-device-resolution.ts @@ -1,7 +1,6 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; -import { ensureDeviceReady } from './device-ready.ts'; import { inspectAppleRunnerSession } from '../platform-runtime-apple-resources.ts'; import { resolveTargetDevice } from '../core/dispatch-resolve.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; @@ -30,7 +29,6 @@ export function hasExplicitSessionFlag(flags: DaemonRequest['flags'] | undefined export async function resolveCommandDevice(params: { session: SessionState | undefined; flags: DaemonRequest['flags'] | undefined; - ensureReady?: boolean; androidAvdSelection?: 'running-only' | 'include-stopped'; }): Promise { const shouldUseExplicitIdentity = hasExplicitDeviceSelector(params.flags); @@ -40,9 +38,6 @@ export async function resolveCommandDevice(params: { androidAvdSelection: params.androidAvdSelection, }) : await refreshSessionDeviceIfNeeded(params.session.device); - if (params.ensureReady !== false) { - await ensureDeviceReady(device); - } return device; } diff --git a/src/daemon/session-lifecycle/internal/inventory.ts b/src/daemon/session-lifecycle/internal/inventory.ts index 817bd65e0..132c46b30 100644 --- a/src/daemon/session-lifecycle/internal/inventory.ts +++ b/src/daemon/session-lifecycle/internal/inventory.ts @@ -238,7 +238,6 @@ async function capabilitiesInventoryResponse(params: { }): Promise { const resolution = await resolveInventoryCommandDevice({ ...params, - ensureReady: false, androidAvdSelection: 'include-stopped', }); if ('response' in resolution) return resolution.response; @@ -341,7 +340,6 @@ async function handleAppsInventory(params: { req, sessionName, sessionStore, - ensureReady: false, androidAvdSelection: 'include-stopped', }); if ('response' in resolution) return resolution.response; @@ -452,10 +450,9 @@ async function resolveInventoryCommandDevice(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; - ensureReady: boolean; androidAvdSelection?: 'running-only' | 'include-stopped'; }): Promise<{ device: DeviceInfo } | { response: DaemonResponse }> { - const { req, sessionName, sessionStore, ensureReady, androidAvdSelection } = params; + const { req, sessionName, sessionStore, androidAvdSelection } = params; const session = sessionStore.get(sessionName); const flags = req.flags ?? {}; const response = requireSessionOrExplicitSelector(req.command, session, flags); @@ -465,7 +462,6 @@ async function resolveInventoryCommandDevice(params: { device: await resolveCommandDevice({ session, flags, - ensureReady, androidAvdSelection, }), }; diff --git a/src/daemon/session-lifecycle/internal/session-close.ts b/src/daemon/session-lifecycle/internal/session-close.ts index 4e2694e32..ae30edb0d 100644 --- a/src/daemon/session-lifecycle/internal/session-close.ts +++ b/src/daemon/session-lifecycle/internal/session-close.ts @@ -421,7 +421,6 @@ async function closeWithoutSession(params: { const device = await resolveCommandDevice({ session: undefined, flags: req.flags, - ensureReady: false, }); const admission = await admitCloseRuntime({ device, diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index 1b09cc8cc..87a8ac97b 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -11,7 +11,12 @@ import type { } from '@agent-device/contracts/snapshot-runtime'; import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; import { buildRuntimeCaptureInput } from './snapshot-runtime-capture-input.ts'; -import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; +import { + ensureBoundDeviceReady, + type BindDeviceRuntime, + type InspectDeviceRuntimeFacts, +} from './request-runtime-binding.ts'; +import type { DeviceReadyOptions } from './device-ready.ts'; import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; @@ -97,6 +102,7 @@ export async function admitAndBindSnapshotCapture( plan: SnapshotRuntimePlan | SelectorCaptureRuntimePlan; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; + readiness?: DeviceReadyOptions; }>, ): Promise { const { command, device, session, plan } = params; @@ -113,7 +119,7 @@ export async function admitAndBindSnapshotCapture( }), }; } - const bound = await bindSnapshotCaptureRuntime(params.bindDevice, admission); + const bound = await bindSnapshotCaptureRuntime(params.bindDevice, admission, params.readiness); return Object.freeze({ ok: true, capture: async (input: CaptureSnapshotInput) => await bound.captureSnapshot(input), @@ -145,6 +151,7 @@ export async function resolveBoundSnapshotCaptureRuntime( }), inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, + ...(session ? {} : { readiness: {} }), }); if (!bound.ok) return bound; @@ -172,6 +179,7 @@ export async function resolveBoundSnapshotCaptureRuntime( async function bindSnapshotCaptureRuntime( bindDevice: BindDeviceRuntime | undefined, admission: AdmittedRuntimePlan, + readiness: DeviceReadyOptions | undefined, ): Promise< Readonly<{ captureSnapshot(input: CaptureSnapshotInput): Promise; @@ -187,6 +195,11 @@ async function bindSnapshotCaptureRuntime( }> > { const bind = requireRuntimeBinding(bindDevice); + const bindReady: BindDeviceRuntime = async (device, use) => { + const runtime = await bind(device, use); + if (readiness !== undefined) await ensureBoundDeviceReady(runtime, readiness); + return runtime; + }; const { device, plan } = unwrapAdmittedRuntimePlan(admission); // One switch, one set of operation selectors. The selector arms reuse the SAME // `selectActiveAppSnapshot` / `selectSnapshotWithoutActiveApp` the snapshot arms use and only @@ -194,25 +207,25 @@ async function bindSnapshotCaptureRuntime( // `plan.use` per family. No parallel plan-to-operation dispatch is introduced. switch (plan.kind) { case 'active-app': { - const runtime = await bind(device, plan.use); + const runtime = await bindReady(device, plan.use); return selectActiveAppSnapshot(runtime); } case 'selector-active-app': { - return await bindActiveAppSelectorRuntime(bind, device, plan); + return await bindActiveAppSelectorRuntime(bindReady, device, plan); } case 'custom-actions-active-app': { - const runtime = await bind(device, plan.use); + const runtime = await bindReady(device, plan.use); return selectCustomActionsSnapshot(runtime); } case 'without-active-app': { - const runtime = await bind(device, plan.use); + const runtime = await bindReady(device, plan.use); return selectSnapshotWithoutActiveApp(runtime); } case 'selector-without-active-app': { - return await bindSelectorRuntimeWithoutActiveApp(bind, device, plan); + return await bindSelectorRuntimeWithoutActiveApp(bindReady, device, plan); } case 'custom-actions-without-active-app': { - const runtime = await bind(device, plan.use); + const runtime = await bindReady(device, plan.use); return selectCustomActionsSnapshot(runtime); } } diff --git a/src/daemon/snapshot-session.ts b/src/daemon/snapshot-session.ts index 66c05caa7..b3dcaf785 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -1,7 +1,6 @@ import { resolveTargetDevice } from '../core/dispatch-resolve.ts'; import type { PlatformResourceCleanup } from '@agent-device/contracts/platform-resource-cleanup'; import type { DaemonRequest, SessionScope, SessionState } from './types.ts'; -import { ensureDeviceReady } from './device-ready.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import { SessionStore } from './session-store.ts'; @@ -12,7 +11,6 @@ export async function resolveSessionDevice( ) { const session = sessionStore.get(sessionName); const device = session?.device ?? (await resolveTargetDevice(flags ?? {})); - if (!session) await ensureDeviceReady(device); return { session, device }; } @@ -28,9 +26,9 @@ export async function withSessionlessRunnerCleanup( try { return await task(); } finally { - // Symmetric with `ensureDeviceReady`: only a device this daemon prepared a local execution - // host for can have one to release. A provider-owned device runs on provider infrastructure, - // where local teardown drives host tooling at a device id this host does not own. + // Only a device this daemon prepared a local execution host for can have one to release. A + // provider-owned device runs on provider infrastructure, where local teardown drives host + // tooling at a device id this host does not own. if (!session && !isActiveProviderDevice(device)) { await platformCleanup!.cleanupSessionlessExecutionHost(device); } diff --git a/src/platform-runtime-managed-owner.test.ts b/src/platform-runtime-managed-owner.test.ts index b826bddc0..70bb8f9a3 100644 --- a/src/platform-runtime-managed-owner.test.ts +++ b/src/platform-runtime-managed-owner.test.ts @@ -77,8 +77,8 @@ describe('managed local runtime owner', () => { expect(binding.operations[MANAGED_RETAINED_OPERATION]).toBeTypeOf('function'); }); - // The exclusion covers binding cells only. Pre-binding readiness still boots a device through - // direct platform tooling before any binding exists; moving it under the binding is its own unit. + // The exclusion covers binding cells; request-runtime binding owns the separate pre-binding + // readiness fence. test('refuses every runtime use that needs a withheld cell', async () => { const { owner } = managedOwnerFixture(); const binding = await owner.bind({ device, intent: exactly(), scope }); diff --git a/src/platform-runtime-managed-owner.ts b/src/platform-runtime-managed-owner.ts index aea8b8fa8..5815095cb 100644 --- a/src/platform-runtime-managed-owner.ts +++ b/src/platform-runtime-managed-owner.ts @@ -73,10 +73,9 @@ const WITHHELD_MANAGED_OPERATIONS = [ { // Below cell-selection granularity, the Apple family runtime can boot the simulator lazily: // screenshot capture retries through a boot on a shutdown failure, and settings, clipboard and - // application launch each resolve a local interactor the same way. Closing that path is a - // family-runtime change (the same class as the pre-binding readiness bypass named in "Named - // out of scope" below); until then, a managed binding withholds these cells outright rather - // than leave an allocator-owned device open to an implicit boot. + // application launch each resolve a local interactor the same way. The daemon fences its + // pre-binding readiness path separately; until these cells are allocator-backed, a managed + // binding withholds them rather than leave an allocator-owned device open to an implicit boot. hint: 'Managed-device lifecycle belongs to the allocator; this cell can lazily boot the device.', keys: ['captureScreenshot', 'setSetting', 'readClipboard', 'writeClipboard', 'openApplication'], }, @@ -90,10 +89,9 @@ const WITHHELD_MANAGED_OPERATIONS = [ * Withholding cells is not a complete lifecycle exclusion and does not claim to be. Screenshot * capture, settings, clipboard and application launch are withheld here even though their declared * work is not device lifecycle, because their Apple family-runtime implementations can boot the - * simulator lazily below cell-selection granularity. Pre-binding readiness is the same class of - * gap, one level up: `session-device-resolution` boots a device through direct simctl/adb before - * any binding exists, gated only by provider ownership. Closing both is a family-runtime and - * daemon change tracked as a follow-up, not attempted here. + * simulator lazily below cell-selection granularity. The daemon's pre-binding readiness path is + * fenced at request-runtime binding, while these lazy cells remain allocator-backed follow-up + * work. * * It delegates with an ordinary intent because a family owner refuses an exact-owner intent that * names anyone but itself. The managed intent's fence is not read here: the device-claim gate owns