diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 47dbeded3d..9582f1ada8 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -352,6 +352,10 @@ "types": "./src/platform-runtime-unavailable.ts", "default": "./src/platform-runtime-unavailable.ts" }, + "./runtime-contract-error": { + "types": "./src/runtime-contract-error.ts", + "default": "./src/runtime-contract-error.ts" + }, "./runtime-operation-names": { "types": "./src/runtime-operation-names.ts", "default": "./src/runtime-operation-names.ts" diff --git a/packages/contracts/src/app-state-runtime.ts b/packages/contracts/src/app-state-runtime.ts index 6b094ee6b1..0d74d91d18 100644 --- a/packages/contracts/src/app-state-runtime.ts +++ b/packages/contracts/src/app-state-runtime.ts @@ -1,6 +1,5 @@ import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; - -export type { AppleApplicationState } from '@agent-device/kernel/snapshot'; +import type { LocalInteractorOperationResolver } from './interactor-operation-binding.ts'; /** Which app a session-scoped read is about; the Android foreground read needs nothing. */ export type AppStateRuntimeInput = Readonly<{ appBundleId?: string }>; @@ -20,3 +19,6 @@ export type AppStateRuntimeResult = Readonly<{ export type AppStateRuntimeOperations = Readonly<{ appState(input?: AppStateRuntimeInput): Promise; }>; + +/** Resolves the selected owner's interactor, as every other local interactor-backed read does. */ +export type AppStateInteractorResolver = LocalInteractorOperationResolver; diff --git a/packages/contracts/src/app-state.ts b/packages/contracts/src/app-state.ts index 6d339fe46d..81d1aa366d 100644 --- a/packages/contracts/src/app-state.ts +++ b/packages/contracts/src/app-state.ts @@ -1,4 +1,4 @@ -import type { AppleApplicationState } from './app-state-runtime.ts'; +import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; import type { SessionSurface } from './session-surface.ts'; /** diff --git a/packages/contracts/src/runtime-contract-error.ts b/packages/contracts/src/runtime-contract-error.ts index 905227bf4f..ce7ac1ebe0 100644 --- a/packages/contracts/src/runtime-contract-error.ts +++ b/packages/contracts/src/runtime-contract-error.ts @@ -8,10 +8,11 @@ import { AppError } from '@agent-device/kernel/errors'; * licenses a fallback, and the command then answers from stale data precisely because the * runtime lied about itself (ADR 0019 §2). * - * Deliberately its own module rather than an export of `platform-runtime.ts`: the façade - * re-exports that module and must stay exhaustive over it, which would make this a public - * symbol with no external consumer — the unused-export defect. Here it stays internal to - * `packages/contracts` and both runtime modules share one construction. + * Deliberately its own module rather than an export of `platform-runtime.ts`: that façade must stay + * exhaustive over its sources and keep its eager closure at budget, so it carries no leaf value + * symbol nobody in `contracts` consumes. This module stays a subpath of its own and imports nothing + * else from `contracts`, so a platform package whose facts advertised an operation its interactor + * cannot perform builds the same failure here instead of restating the code, reason, and hint. */ export function invalidRuntimeContract(message: string): AppError { return new AppError('COMMAND_FAILED', message, { diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index c0d2af3147..7ce4fba3ed 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -471,32 +471,37 @@ export function isIosTargetActivationReason(value: unknown): value is IosTargetA } /** - * States an activation could have been needed for, in `XCApplicationState` raw order: unknown 0, - * notRunning 1, suspended 2, plain background 3 — the SDK declares suspended on non-macOS platforms - * only. `runningForeground` is excluded because the runner skips `activate()` when the app is already - * foreground and never stamps a fact there. The decoder, which assigns raw values, is what keeps this - * order honest. + * How XCTest reports an app running (`XCUIApplication.State`), in the SDK's raw order: unknown 0, + * notRunning 1, suspended 2, plain background 3, foreground 4 — the SDK declares suspended on + * non-macOS platforms only. This is the one declaration of those names; the `appState` runner + * command answers the session app's state with them, and `RunnerTests+ApplicationStateRawValueTests` + * ties them to the SDK enum. The `appState` path names states, so nothing here assigns a raw value; + * only the activation decoder's raw table does. */ -export const IOS_TARGET_ACTIVATION_PRIOR_STATES = [ +export const APPLE_APPLICATION_STATES = [ 'unknown', 'notRunning', 'runningBackgroundSuspended', 'runningBackground', + 'runningForeground', ] as const; -export type IosTargetActivationPriorState = (typeof IOS_TARGET_ACTIVATION_PRIOR_STATES)[number]; +export type AppleApplicationState = (typeof APPLE_APPLICATION_STATES)[number]; /** - * How XCTest reports an app running (`XCUIApplication.State`): the prior states above plus the - * foreground state the activation disclosure never carries. The `appState` runner command names - * the session app's state with these words. + * States an activation could have been needed for: every Apple state except the foreground one, + * which the runner skips `activate()` in and therefore stamps no fact about. Derived from the full + * list so the two cannot drift, and in the SDK's raw order — a state added to the full list lands + * here and must then be pinned natively before the decoder tie accepts it. */ -export const APPLE_APPLICATION_STATES = [ - ...IOS_TARGET_ACTIVATION_PRIOR_STATES, - 'runningForeground', -] as const; +export const IOS_TARGET_ACTIVATION_PRIOR_STATES = Object.freeze( + APPLE_APPLICATION_STATES.filter( + (state): state is Exclude => + state !== 'runningForeground', + ), +); -export type AppleApplicationState = (typeof APPLE_APPLICATION_STATES)[number]; +export type IosTargetActivationPriorState = (typeof IOS_TARGET_ACTIVATION_PRIOR_STATES)[number]; export function isAppleApplicationState(value: unknown): value is AppleApplicationState { return ( diff --git a/packages/platform-apple/src/app-state-runtime.test.ts b/packages/platform-apple/src/app-state-runtime.test.ts index 754426e7fa..bfa83dcbf3 100644 --- a/packages/platform-apple/src/app-state-runtime.test.ts +++ b/packages/platform-apple/src/app-state-runtime.test.ts @@ -13,9 +13,10 @@ const device: DeviceInfo = { booted: true, }; -function bind(liveRunner: boolean) { +function bind(liveRunner: boolean, interactor?: Interactor) { const appState = vi.fn(async () => ({ applicationState: 'runningBackground' as const })); - const resolveInteractor = vi.fn(async () => ({ appState }) as unknown as Interactor); + const resolved: Interactor = interactor ?? ({ appState } as unknown as Interactor); + const resolveInteractor = vi.fn(async () => resolved); const hasLiveRunnerSession = vi.fn(async () => liveRunner); const operations = bindAppleAppStateRuntime( { appleApplications: { hasLiveRunnerSession } as never }, @@ -43,3 +44,19 @@ test('without a live runner session the read answers nothing and resolves no int expect(hasLiveRunnerSession).toHaveBeenCalledWith(device, {}); expect(resolveInteractor).not.toHaveBeenCalled(); }); + +/** + * The facts promised `appState` and the interactor has none. Answering from the session record + * instead would invent a state the runner never read, so this fails as the contract bug it is + * (ADR 0019 §2) rather than degrading, and the typed reason is what keeps it outside every + * closed reason set that licenses a fallback. + */ +test('an advertised appState with no interactor implementation fails as a contract bug', async () => { + // An interactor with NO appState — the mismatch the facts promised away. + const { operations } = bind(true, {} as unknown as Interactor); + await expect(operations.appState({ appBundleId: 'com.example.app' })).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: expect.stringContaining('advertised appState'), + details: { reason: 'runtime-contract-invalid' }, + }); +}); diff --git a/packages/platform-apple/src/app-state-runtime.ts b/packages/platform-apple/src/app-state-runtime.ts index 8250a0602a..97f14860a4 100644 --- a/packages/platform-apple/src/app-state-runtime.ts +++ b/packages/platform-apple/src/app-state-runtime.ts @@ -1,18 +1,12 @@ import type { + AppStateInteractorResolver, AppStateRuntimeInput, AppStateRuntimeOperations, AppStateRuntimeResult, } from '@agent-device/contracts/app-state-runtime'; -import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import { invalidRuntimeContract } from '@agent-device/contracts/runtime-contract-error'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; - -/** Resolves the selected owner's interactor, exactly as the element text runtime does. */ -export type AppStateInteractorResolver = ( - device: DeviceInfo, - runner: RunnerContext, -) => Promise; /** * Binds the runner's read of the session app's state for the lifetime of a request binding, on the @@ -40,13 +34,8 @@ export function bindAppleAppStateRuntime( if (typeof interactor.appState !== 'function') { // Facts advertised the read but the interactor cannot perform it: a contract bug (ADR 0019 // §2), not a refusal, so nothing upstream may answer from the session record instead. - throw new AppError( - 'COMMAND_FAILED', + throw invalidRuntimeContract( 'Runtime owner advertised appState without an interactor implementation', - { - reason: 'runtime-contract-invalid', - hint: 'This is an agent-device runtime contract bug; report the selected device and command.', - }, ); } return await interactor.appState(); diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 419ebc6e98..3095a7699e 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -95,6 +95,7 @@ "@agent-device/contracts/remote", "@agent-device/contracts/replay", "@agent-device/contracts/runner-lease-context", + "@agent-device/contracts/runtime-contract-error", "@agent-device/contracts/runtime-operation-names", "@agent-device/contracts/screen-capture-contract", "@agent-device/contracts/screen-recording-runtime", diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index f74f7d7042..1b095e3bb5 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -16,7 +16,7 @@ import { import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { SessionStore } from '../session-store.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import type { AppleApplicationState } from '@agent-device/contracts/app-state-runtime'; +import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; import { hasExplicitSessionFlag,