diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index f16d14c65..2c2a1dba9 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -115,10 +115,15 @@ never daemon orchestration, command policy, or cross-family defaults. The root composition module, `src/platform-runtime.ts`, constructs immutable inventory/runtime registries and injects a composed inventory gateway plus provider-first runtime -gateway into daemon request execution. Daemon device-execution modules import runtime contracts only. +gateway into daemon request execution. Its private +`src/platform-runtime/request-providers.ts` implementation submodule owns the cross-family request +provider resolver table and wrapper ordering; only the canonical root may load it, and it remains +lazy until a request enters a provider scope. Daemon device-execution modules import the canonical +root interface or runtime contracts only. Shared runtime interfaces and neutral data types live in `@agent-device/contracts`. In production, -only that composition module may import a concrete platform package; reusable types do not leak -through type-only platform imports. Platform packages may import contracts, kernel/domain packages, +only that composition module or its one R13-governed private implementation submodule may import a +concrete platform package; reusable types do not leak through type-only platform imports. Platform +packages may import contracts, kernel/domain packages, and explicitly injected host capabilities; they may not import daemon requests or responses, mutable session state, command catalogs/grammar, root implementation files, sibling platform packages, or raw process primitives outside the shared host-command port. R13 applies these rules to static, type-only, @@ -615,7 +620,8 @@ The final gates passed: integration-progress checks. - `pnpm check:layering` passed 131 structural/model tests and scanned 1,157 production source files. R11 owns 17 workspace packages behind 39 exported subpaths with no root back-imports; R13 keeps six - private implementation-lazy platform packages above capture-kit behind one composition root; R14 + private implementation-lazy platform packages above capture-kit behind one canonical composition + root and its single private provider-composition implementation submodule; R14 and R15 retain one typed route for `logs` and `network` with no legacy route. - Six local inventory/runtime owners, all enumerated Apple leaf/kind cells, and the production BrowserStack, AWS Device Farm, and Limrun provider modes remain covered. Provider ownership and diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 4292dda91..3f0aa6574 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -67,6 +67,10 @@ "types": "./src/apple-multitouch-support.ts", "default": "./src/apple-multitouch-support.ts" }, + "./apple-runner-request": { + "types": "./src/apple-runner-request.ts", + "default": "./src/apple-runner-request.ts" + }, "./application-lifecycle-interaction": { "types": "./src/application-lifecycle-interaction.ts", "default": "./src/application-lifecycle-interaction.ts" @@ -111,6 +115,10 @@ "types": "./src/back-runtime.ts", "default": "./src/back-runtime.ts" }, + "./boot-failure": { + "types": "./src/boot-failure.ts", + "default": "./src/boot-failure.ts" + }, "./capture": { "types": "./src/facades/capture.ts", "default": "./src/facades/capture.ts" diff --git a/packages/contracts/src/apple-runner-request.ts b/packages/contracts/src/apple-runner-request.ts new file mode 100644 index 000000000..52fd3da5f --- /dev/null +++ b/packages/contracts/src/apple-runner-request.ts @@ -0,0 +1,18 @@ +import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts'; + +/** + * Request-scoped options shared across daemon routing and Apple runner adapters. + * + * The Apple runner owns its lifecycle and command options; this is only the + * neutral request vocabulary that a caller may pass to an adapter. + */ +export type AppleRunnerRequestOptions = Readonly<{ + verbose?: boolean; + logPath?: string; + traceLogPath?: string; + requestId?: string; + runnerLeaseContext?: RunnerLogicalLeaseContext; + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; +}>; diff --git a/packages/contracts/src/boot-failure.ts b/packages/contracts/src/boot-failure.ts new file mode 100644 index 000000000..82e7fa22f --- /dev/null +++ b/packages/contracts/src/boot-failure.ts @@ -0,0 +1,24 @@ +const INFRASTRUCTURE_BOOT_FAILURE_REASONS = [ + 'IOS_BOOT_TIMEOUT', + 'IOS_RUNNER_CONNECT_TIMEOUT', + 'IOS_RUNNER_OWNED_BY_OTHER_DAEMON', + 'IOS_TOOL_MISSING', + 'ANDROID_BOOT_TIMEOUT', + 'ADB_TRANSPORT_UNAVAILABLE', + 'CI_RESOURCE_STARVATION_SUSPECTED', +] as const; + +export type InfrastructureBootFailureReason = (typeof INFRASTRUCTURE_BOOT_FAILURE_REASONS)[number]; + +const infrastructureBootFailureReasons: ReadonlySet = new Set( + INFRASTRUCTURE_BOOT_FAILURE_REASONS, +); + +/** True when a boot failure can be retried by changing host/transport conditions. */ +export function isInfrastructureBootFailureReason( + reason: string, +): reason is InfrastructureBootFailureReason { + return infrastructureBootFailureReasons.has( + reason.toUpperCase() as InfrastructureBootFailureReason, + ); +} diff --git a/packages/contracts/src/platform-plugin.ts b/packages/contracts/src/platform-plugin.ts index 6e70da69d..8c4e66a5f 100644 --- a/packages/contracts/src/platform-plugin.ts +++ b/packages/contracts/src/platform-plugin.ts @@ -18,10 +18,10 @@ type CapabilityBucket = 'apple' | 'android' | 'harmonyos' | 'vega' | 'linux' | ' * `import()` inside `createInteractor`, preserving the * CLI cold-start laziness that today's `getInteractor` switch relies on. * - * Daemon-owned columns (step b.3, issue #974): each is declared ONLY once it is + * Root-composed columns (step b.3, issue #974): each is declared ONLY once it is * populated by wrapping the existing daemon branch AND pinned by a table-equivalence * parity test before a real call-site routes through it. A facet's type stays - * PLATFORM-NEUTRAL and daemon-owned (never the iOS-simulator-shaped provider seam): + * PLATFORM-NEUTRAL and composition-owned (never the iOS-simulator-shaped provider seam): * {@link PlatformPlugin.providers} carries the per-family platform-gated request * provider resolver list (replaces the hand `device.platform === …` gate in * `request-platform-providers.ts`, pinned by the providers routing parity test). The @@ -59,15 +59,15 @@ export type PlatformPlugin = { >; }; /** - * The daemon request-scope provider facet (issue #974). `platformGatedResolvers` + * The request-scope provider facet (issue #974). `platformGatedResolvers` * declares which PLATFORM-GATED request provider resolvers apply to this family's * devices — the DATA that replaces the hand `device.platform === …` gate formerly * open-coded inside each descriptor's `resolve` in - * src/daemon/request-platform-providers.ts. The daemon still OWNS the resolver + * src/platform-runtime/request-providers.ts. The canonical root composition owns the resolver * functions, their wrapper composition, and the request-scope concurrency isolation; * this facet supplies only the per-family gate (a plain string list, the keys * type-only in the plugin). Focused command transports that are not family-gated - * are intentionally NOT part of the facet and stay ungated in the daemon. + * are intentionally NOT part of the facet and stay ungated in the composition. * Every family carries this facet (each * owns at least one platform-specific resolver); a device on an unregistered platform * resolves to no gated resolvers, matching the former hand gate. Pinned by the diff --git a/packages/contracts/src/platform-providers.ts b/packages/contracts/src/platform-providers.ts index 5f6eb4817..7519e10d4 100644 --- a/packages/contracts/src/platform-providers.ts +++ b/packages/contracts/src/platform-providers.ts @@ -1,18 +1,59 @@ -// Vocabulary for the platform-plugin provider facet. +// Vocabulary for the platform-plugin provider facet and the request-scoped provider seam. // -// `core/platform-plugin/plugin.ts` declares which daemon provider resolvers a platform -// family gates. The daemon owns the resolvers themselves and asserts at compile time, in -// `daemon/request-platform-providers.ts`, that every key named here is a real resolver — -// so the facet can never name a resolver the daemon does not compose. +// `core/platform-plugin/plugin.ts` declares which provider resolvers a platform family gates. +// The concrete resolver table and wrapper composition live in the root composition module; the +// daemon only supplies this neutral request context and consumes the one capability it currently +// needs from the resulting scope. + +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { SessionSurface } from './session-surface.ts'; + +export type PlatformProviderRequestSession = Readonly<{ + name: string; + device: DeviceInfo; + appBundleId?: string; + appName?: string; + surface?: SessionSurface; +}>; + +/** Request data shared with a root-composed platform provider resolver. */ +export type PlatformProviderRequestContext = Readonly<{ + device: DeviceInfo; + session?: PlatformProviderRequestSession; + requestedSession?: string; + requestId?: string; + /** Daemon policy says that the root may construct its managed Web provider for this request. */ + useDefaultWebProvider?: boolean; +}>; + +/** The only request-scoped platform value currently consumed by daemon handlers. + * + * Its concrete Android executor type remains owned by the Android package. The daemon handlers + * already pass this value through as an opaque capability, so duplicating that package type here + * would make the seam another declaration site rather than a neutral contract. + */ +export type RequestPlatformProviderScope = Readonly<{ + androidAdbExecutor?: unknown; +}>; + +/** Root-composed provider wrappers; device selection remains a daemon policy. */ +export type RequestPlatformProviders = Readonly<{ + /** Avoid resolving a daemon device when no resolver or default Web provider is configured. */ + hasConfiguredResolvers: boolean; + run( + context: PlatformProviderRequestContext, + task: (scope: RequestPlatformProviderScope) => Promise, + ): Promise; +}>; /** * The request provider resolvers whose application is PLATFORM-GATED — each ran behind * a hand `device.platform === …` predicate inside its descriptor's `resolve`. The * PlatformPlugin `providers` facet (issue #974) declares, per family, which of these * apply to that family's devices (data-only: a plain string list, type-only in the - * plugin), and `platformGatedResolverApplies` routes the gate through it. The daemon - * still OWNS the resolver invocation, wrapper composition, and request-scope - * concurrency isolation — only the platform GATE moved to data. + * plugin), and the root composition routes the gate through it. Resolver invocation, + * wrapper composition, and request-scope concurrency isolation live with the concrete + * provider composition, not in daemon request code. * * App-log and screen-recording transports are deliberately ABSENT: they carry no * platform gate (they apply on every platform), so they stay ungated in the daemon and diff --git a/packages/kernel/src/contracts.ts b/packages/kernel/src/contracts.ts index feabd7cd0..a42a4154b 100644 --- a/packages/kernel/src/contracts.ts +++ b/packages/kernel/src/contracts.ts @@ -39,6 +39,9 @@ export type DaemonInstallSource = } )); +/** Install sources that can be materialized by a local daemon. */ +export type LocalInstallSource = Extract; + const DAEMON_LOCK_POLICIES = ['reject', 'strip'] as const; export type DaemonLockPolicy = (typeof DAEMON_LOCK_POLICIES)[number]; const LEASE_BACKENDS = ['ios-simulator', 'ios-instance', 'android-instance'] as const; diff --git a/packages/platform-apple/src/runner/host.ts b/packages/platform-apple/src/runner/host.ts index 9ee7d2a75..ab5d181b6 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from 'node:child_process'; import type { RequestProgressEvent } from '@agent-device/contracts/progress'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { InfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure'; import type { XmlNode } from '@agent-device/xml'; /** @@ -123,14 +124,8 @@ export type TtlMemoOptions = { export type DefinedEnvMap = Record; export type BootFailureReason = - | 'IOS_BOOT_TIMEOUT' - | 'IOS_RUNNER_CONNECT_TIMEOUT' - | 'IOS_RUNNER_OWNED_BY_OTHER_DAEMON' + | InfrastructureBootFailureReason | 'IOS_RUNNER_DEVICE_NOT_PROVISIONED' - | 'IOS_TOOL_MISSING' - | 'ANDROID_BOOT_TIMEOUT' - | 'ADB_TRANSPORT_UNAVAILABLE' - | 'CI_RESOURCE_STARVATION_SUSPECTED' | 'BOOT_COMMAND_FAILED' | 'UNKNOWN'; diff --git a/packages/platform-apple/src/runner/runner-provider.ts b/packages/platform-apple/src/runner/runner-provider.ts index 49a9711e5..09ce57879 100644 --- a/packages/platform-apple/src/runner/runner-provider.ts +++ b/packages/platform-apple/src/runner/runner-provider.ts @@ -1,23 +1,14 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context'; +import type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Deadline } from './host.ts'; import type { RunnerCommand } from './runner-contract.ts'; -import type { - RunnerXctestrunArtifactState, - RunnerXctestrunCacheKind, - ExternalXctestRunnerOptions, -} from './runner-xctestrun.ts'; +import type { RunnerXctestrunArtifactState, RunnerXctestrunCacheKind } from './runner-xctestrun.ts'; -export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & { +export type AppleRunnerCommandOptions = AppleRunnerRequestOptions & { signal?: AbortSignal; - verbose?: boolean; - logPath?: string; - traceLogPath?: string; cleanStaleBundles?: boolean; startupTimeoutMs?: number; - requestId?: string; - runnerLeaseContext?: RunnerLogicalLeaseContext; /** * Restricts a command to the already-owned durable runner session. Exact * cleanup must never start, adopt, or dispatch to a replacement session. diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 97aa58947..161ab7144 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -73,8 +73,8 @@ test('daemon modularity baseline records the measured R7 ownership pressure', () Object.values(SESSION_STATE_FIELD_OWNERS).reduce((sum, owners) => sum + owners.length, 0), DAEMON_MODULARITY_BASELINE.sessionState.ownerFileClaims, ); - assert.equal(TYPE_CYCLE_BASELINE, 18); - assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 11); + assert.equal(TYPE_CYCLE_BASELINE, 16); + assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 10); assert.equal('daemon' in DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers, false); }); @@ -239,7 +239,7 @@ test('R10 zone overflow lists the whole zone so the joining member is visible', const [violation] = violations; assert.equal(violation!.rule, 'R10 daemon-modularity'); assert.equal(violation!.file, 'scripts/layering/daemon-modularity.ts'); - assert.match(violation!.message, /contains 12 daemon-server file\(s\) \(baseline 11\)/); + assert.match(violation!.message, /contains 11 daemon-server file\(s\) \(baseline 10\)/); for (const member of daemonMembers) { assert.ok(violation!.message.includes(member), `${member} missing from: ${violation!.message}`); } @@ -257,6 +257,6 @@ test('R9 rejects a baseline left above the measured cycle', () => { assert.equal(violations.length, 1); assert.match(violations[0]!.rule, /^R9 /); - assert.match(violations[0]!.message, /dropped to 17 files \(baseline 18\)/); + assert.match(violations[0]!.message, /dropped to 15 files \(baseline 16\)/); assert.match(violations[0]!.message, /Lower LARGEST_TYPE_CYCLE_ZONE_CEILINGS by the same 1/); }); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index ded85cdf4..67caaa02e 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -3,7 +3,9 @@ import { targetDagZone, type LayeringViolation, type ResolvedImportEdge } from ' import { SESSION_STATE_FIELD_OWNERS } from './session-state.ts'; const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly> = { - '(root)': 2, + // Request provider composition now consumes the neutral contracts context instead of importing + // daemon request/session types, removing the root provider seam from this component. + '(root)': 1, // R58 retired the legacy command dispatcher, taking `core/dispatch.ts` and the // `core/interactors.ts` registry it pulled in out of the cycle with it. R64 removes the // legacy perf projection and lowers the remaining core component by one more file. @@ -13,7 +15,8 @@ const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly> = { // `interaction-outcome-policy.ts` and `deferred-interaction-outcome.ts` both left the cycle. // R63 then deleted `session-install-capability-projection.ts` outright — the general // fact-owned projection subsumes it — taking a third member with it. - 'daemon-server': 11, + // The daemon side of that seam no longer imports the concrete provider resolver table. + 'daemon-server': 10, // R64 deletes the last perf support closure from `apple/plugin.ts`, taking the final // platform-owned member out of the type cycle. platforms: 0, diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index 1c447875c..ba55390a8 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -181,6 +181,7 @@ test('classifyZone separates the ranked spine from intentionally-unranked zones' assert.equal(classifyZone('contracts'), 'ranked'); assert.equal(classifyZone('daemon-server'), 'ranked'); assert.equal(classifyZone('(root)'), 'unranked'); + assert.equal(classifyZone('platform-runtime'), 'unranked'); assert.equal(classifyZone('utils'), 'ranked'); // Every satellite zone joined the spine; only the composition root stays out, because R2 // forbids daemon/ from importing commands/ so the files that wire them cannot be ranked. diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index c64d20ec3..2dc99f55f 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -85,6 +85,9 @@ export function zoneRank(zone: string): number | null { // exact-family/composition/laziness policy. export const UNRANKED_ZONES: ReadonlySet = new Set([ '(root)', + // Private implementation submodules of the canonical root composition. R13 owns their exact + // importer and concrete-platform authority; giving them a spine rank would duplicate that seam. + 'platform-runtime', 'kernel', 'capture-kit', 'platform-apple', diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 016730c93..cd9ba95cc 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -63,6 +63,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/app-state-runtime', '@agent-device/contracts/app-switcher-runtime', '@agent-device/contracts/apple-multitouch-support', + '@agent-device/contracts/apple-runner-request', '@agent-device/contracts/application-lifecycle-interaction', '@agent-device/contracts/application-lifecycle-runtime', '@agent-device/contracts/application-lifecycle-runtime-plan', @@ -74,6 +75,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/audio-runtime-plan', '@agent-device/contracts/back-mode', '@agent-device/contracts/back-runtime', + '@agent-device/contracts/boot-failure', '@agent-device/contracts/capture', '@agent-device/contracts/click-button', '@agent-device/contracts/client', diff --git a/scripts/layering/platform-composition-policy.ts b/scripts/layering/platform-composition-policy.ts index 2f2cef834..cf40d7887 100644 --- a/scripts/layering/platform-composition-policy.ts +++ b/scripts/layering/platform-composition-policy.ts @@ -63,6 +63,7 @@ function isAllowedCompositionImport(specifier: string): boolean { specifier === './platform-runtime-app-state-host.ts' || specifier === './platform-runtime-device-inventory.ts' || specifier === './platform-runtime-host.ts' || + specifier === './platform-runtime/request-providers.ts' || specifier.startsWith('./platform-runtime-host/') ); } diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts index 38b3587e8..f5b9613b7 100644 --- a/scripts/layering/platform-package-policy.test.ts +++ b/scripts/layering/platform-package-policy.test.ts @@ -128,7 +128,7 @@ test('composition policy does not pin local platform-module identifier spelling' assert.deepEqual(checkPlatformPackagePolicy(sources, declarations()), []); }); -test('only src/platform-runtime.ts may import a concrete platform package', () => { +test('only the canonical composition root and its governed provider submodule may import a concrete platform package', () => { for (const statement of [ "import { applePlatformMetadata } from '@agent-device/platform-apple';", "import type { AppleThing } from '@agent-device/platform-apple';", @@ -137,8 +137,37 @@ test('only src/platform-runtime.ts may import a concrete platform package', () = ]) { const sources = validSources(); sources.set('src/daemon/not-the-root.test.ts', statement); - assert.match(messages(sources).join('\n'), /only src\/platform-runtime\.ts may import/); + assert.match( + messages(sources).join('\n'), + /only src\/platform-runtime\.ts or its governed request-provider composition submodule may import/, + ); } + + const governed = validSources(); + governed.set( + 'src/platform-runtime/request-providers.ts', + "void import('@agent-device/platform-web');", + ); + assert.deepEqual(checkPlatformPackagePolicy(governed, declarations()), []); +}); + +test('only the canonical composition root may import the private provider composition submodule', () => { + const sources = validSources(); + sources.set( + 'src/platform-runtime.ts', + composition() + "\nvoid import('./platform-runtime/request-providers.ts');", + ); + sources.set('src/platform-runtime/request-providers.ts', 'export const providerScope = true;'); + assert.deepEqual(checkPlatformPackagePolicy(sources, declarations()), []); + + sources.set( + 'src/daemon/request-router.ts', + "import { providerScope } from '../platform-runtime/request-providers.ts';", + ); + assert.match( + messages(sources).join('\n'), + /only src\/platform-runtime\.ts may import the private request-provider composition submodule/, + ); }); test('the apple runner mechanics facet subpaths are the enumerated exception', () => { @@ -250,7 +279,7 @@ test('transitional #2041 android adb subpaths are importable only by their named denied.set('src/daemon/handlers/session.ts', shimImport); assert.match( messages(denied).join('\n'), - /only src\/platform-runtime\.ts may import '@agent-device\/platform-android\/adb-executor'/, + /only src\/platform-runtime\.ts or its governed request-provider composition submodule may import '@agent-device\/platform-android\/adb-executor'/, ); // The cluster's own root tests may name the package module (to mock its internal edges) … @@ -266,7 +295,7 @@ test('transitional #2041 android adb subpaths are importable only by their named foreignTest.set('src/daemon/handlers/session.test.ts', shimImport); assert.match( messages(foreignTest).join('\n'), - /only src\/platform-runtime\.ts may import '@agent-device\/platform-android\/adb-executor'/, + /only src\/platform-runtime\.ts or its governed request-provider composition submodule may import '@agent-device\/platform-android\/adb-executor'/, ); // Android may export exactly the transitional subpaths; any other subpath is still a violation. diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index bd4ead0e9..13a1d4dbf 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -20,6 +20,8 @@ export type PlatformPackageDeclaration = { exportedSubpaths: readonly string[]; }; const COMPOSITION_FILE = 'src/platform-runtime.ts'; +const REQUEST_PROVIDER_COMPOSITION_FILE = 'src/platform-runtime/request-providers.ts'; +const COMPOSITION_FILES = new Set([COMPOSITION_FILE, REQUEST_PROVIDER_COMPOSITION_FILE]); const RULE = 'R13 platform-package-substrate'; const RAW_PROCESS_SPECIFIERS = new Set(['child_process', 'node:child_process']); @@ -110,6 +112,12 @@ function resolvesOutsidePackage(file: string, specifier: string, family: string) return !resolved.startsWith(`packages/platform-${family}/`); } +function resolvesToRequestProviderComposition(file: string, specifier: string): boolean { + if (!specifier.startsWith('.')) return false; + const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)); + return resolved === REQUEST_PROVIDER_COMPOSITION_FILE; +} + function isPackageOwnedFacadeTest(file: string, family: string, specifier: string): boolean { return ( file.startsWith(`packages/platform-${family}/`) && @@ -194,6 +202,15 @@ function checkSource(file: string, source: string): LayeringViolation[] { violations.push(...checkPlatformPackageSourcePolicy(file, source, ownerFamily)); } for (const site of parseImports(source)) { + if (resolvesToRequestProviderComposition(file, site.spec) && file !== COMPOSITION_FILE) { + violations.push( + violation( + file, + site.line, + `only ${COMPOSITION_FILE} may import the private request-provider composition submodule`, + ), + ); + } const importedFamily = concretePlatformFamily(site.spec); if (file.startsWith('packages/contracts/') && importedFamily) { violations.push( @@ -222,7 +239,7 @@ function checkSource(file: string, source: string): LayeringViolation[] { ); } else if ( importedFamily && - file !== COMPOSITION_FILE && + !COMPOSITION_FILES.has(file) && // The runner façade subpath is the facet's consumer seam: root code // that reaches runner mechanics directly imports its types and host-free // helpers here. R11's workspace-dependency declarations bound the @@ -237,7 +254,7 @@ function checkSource(file: string, source: string): LayeringViolation[] { violation( file, site.line, - `only ${COMPOSITION_FILE} may import '${site.spec}' outside its package-owned tests`, + `only ${COMPOSITION_FILE} or its governed request-provider composition submodule may import '${site.spec}' outside its package-owned tests`, ), ); } @@ -322,5 +339,5 @@ export function checkPlatformPackagePolicy( } export function platformPackagePolicySummary(): string { - return 'R13 holds six private implementation-lazy platform packages above capture-kit behind one composition root, with the apple runner mechanics facet behind its enumerated seam'; + return 'R13 holds six private implementation-lazy platform packages above capture-kit behind one canonical composition root and its single private provider-composition submodule, with the apple runner mechanics facet behind its enumerated seam'; } diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index 378bf70d3..64bca846b 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -138,6 +138,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/app-inventory-runtime.ts': 1, 'packages/contracts/src/app-log-runtime.ts': 1, 'packages/contracts/src/app-state-runtime.ts': 1, + 'packages/contracts/src/apple-runner-request.ts': 1, 'packages/contracts/src/apple-multitouch-support.ts': 6, 'packages/contracts/src/application-lifecycle-interaction.ts': 7, 'packages/contracts/src/application-lifecycle-runtime-plan.ts': 3, @@ -149,6 +150,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/audio-probe-support.ts': 5, 'packages/contracts/src/audio-runtime-plan.ts': 5, 'packages/contracts/src/back-mode.ts': 1, + 'packages/contracts/src/boot-failure.ts': 1, 'packages/contracts/src/click-button.ts': 3, 'packages/contracts/src/clipboard.ts': 1, 'packages/contracts/src/command-platform-execution.ts': 2, diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 69095b886..327ec6b5a 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -84,11 +84,12 @@ test('provider device runtime composition exposes focused runner recording autho const resolver = createProviderDeviceRuntimeRequestProviders([ runtime, ]).appleRunnerScreenRecordingTransport; - const req = { token: 'token', session: 'default', command: 'record', positionals: [], flags: {} }; - - assert.equal(resolver?.({ req, device }), transport); + assert.equal(resolver?.({ requestedSession: 'default', device }), transport); assert.equal( - resolver?.({ req, device: { ...device, id: 'provider:macos:replacement' } }), + resolver?.({ + requestedSession: 'default', + device: { ...device, id: 'provider:macos:replacement' }, + }), undefined, ); }); diff --git a/src/daemon/__tests__/providers-plugin-routing-parity.test.ts b/src/daemon/__tests__/providers-plugin-routing-parity.test.ts index 96d1fb95a..9ef989127 100644 --- a/src/daemon/__tests__/providers-plugin-routing-parity.test.ts +++ b/src/daemon/__tests__/providers-plugin-routing-parity.test.ts @@ -1,4 +1,8 @@ -import type { PlatformGatedProviderResolverKey } from '@agent-device/contracts/platform-providers'; +import type { + PlatformGatedProviderResolverKey, + PlatformProviderRequestContext, + RequestPlatformProviderScope, +} from '@agent-device/contracts/platform-providers'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { @@ -25,19 +29,20 @@ import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { getPlugin, tryGetPlugin } from '../../core/platform-plugin-registry.ts'; import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; import { - withRequestPlatformProviderScope, + createRequestPlatformProviders, type PlatformProviderResolvers, -} from '../request-platform-providers.ts'; +} from '../../platform-runtime.ts'; +import { resolvePlatformProviderRequestContext } from '../request-platform-provider-context.ts'; import type { DaemonRequest } from '../types.ts'; -// Phase 3 step b.3 (issue #974) parity gate for the daemon request-scope provider -// facet. The per-platform GATE that each descriptor in `request-platform-providers.ts` +// Phase 3 step b.3 (issue #974) parity gate for the request-scope provider +// facet. The per-platform GATE that each descriptor in the root-owned provider composition // open-coded (`device.platform === 'android'`, `isApplePlatform(...)`, etc.) now flows // through the PlatformPlugin `providers.platformGatedResolvers` facet. The daemon still -// OWNS the resolver invocation, wrapper composition, and concurrency isolation — only -// the gate moved to data. An INDEPENDENT verbatim copy of the former gates below is the +// root composition owns the resolver invocation, wrapper composition, and concurrency isolation — +// only the gate moved to data. An INDEPENDENT verbatim copy of the former gates below is the // BEFORE oracle, checked at the facet level AND end-to-end through -// `withRequestPlatformProviderScope`. +// the root-composed RequestPlatformProviders boundary. registerBuiltinPlatformPlugins(); @@ -138,13 +143,13 @@ test('every family carries the providers facet with the resolvers it owns', () = assert.deepEqual([...getPlugin('web').providers!.platformGatedResolvers], ['webProvider']); }); -// End-to-end routing proof: drive the REAL `withRequestPlatformProviderScope` with a +// End-to-end routing proof: drive the REAL root-composed provider boundary with a // spy for every resolver and assert exactly the gated resolvers the former hand gate // admitted are invoked (plus the ungated resolver, on every platform). Each spy // returns `undefined`, so no wrapper is composed — but the resolver is still called iff // its gate passed, which is precisely what the former `device.platform === …` branch // decided. Breaking the facet flips which resolvers run and fails this test. -test('withRequestPlatformProviderScope invokes exactly the resolvers the former gate did', async () => { +test('root provider composition invokes exactly the resolvers the former gate did', async () => { for (const device of SAMPLE_DEVICES) { const invoked = new Set(); const spy = (key: string) => (): undefined => { @@ -193,3 +198,20 @@ function request(command: string): DaemonRequest { meta: { requestId: `req-${command}` }, }; } + +async function withRequestPlatformProviderScope( + params: { + req: DaemonRequest; + existingSession: Parameters[0]['existingSession']; + providers: PlatformProviderResolvers; + }, + task: (scope: RequestPlatformProviderScope) => Promise, +): Promise { + const context: PlatformProviderRequestContext | undefined = + await resolvePlatformProviderRequestContext({ + req: params.req, + existingSession: params.existingSession, + }); + if (!context) return await task({}); + return await createRequestPlatformProviders({ providers: params.providers }).run(context, task); +} diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index a63f55b9b..6daf0da85 100644 --- a/src/daemon/__tests__/request-platform-providers.test.ts +++ b/src/daemon/__tests__/request-platform-providers.test.ts @@ -17,13 +17,22 @@ import { createLocalAppleToolProvider, runXcrun, } from '../../platforms/apple/core/tool-provider.ts'; +import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; import { resolveWebProvider, type WebProvider } from '../../platforms/web/provider.ts'; import { resolveAppleRunnerScreenRecordingTransport, type AppleRunnerScreenRecordingTransport, } from '../../platform-runtime-screen-recording-apple-runner-transport.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { withRequestPlatformProviderScope } from '../request-platform-providers.ts'; +import { + createRequestPlatformProviders, + type PlatformProviderResolvers, +} from '../../platform-runtime.ts'; +import type { + RequestPlatformProviderScope, + PlatformProviderRequestContext, +} from '@agent-device/contracts/platform-providers'; +import { resolvePlatformProviderRequestContext } from '../request-platform-provider-context.ts'; import type { DaemonRequest } from '../types.ts'; const OTHER_IOS_SIMULATOR: DeviceInfo = { @@ -171,8 +180,9 @@ test('request platform provider scopes stay isolated across concurrent requests' }, }, async (scope) => { - assert.ok(scope.androidAdbExecutor); - return (await scope.androidAdbExecutor(['shell', 'echo', 'android'])).stdout; + const executor = scope.androidAdbExecutor as AndroidAdbExecutor | undefined; + assert.ok(executor); + return (await executor(['shell', 'echo', 'android'])).stdout; }, ); @@ -253,6 +263,15 @@ test('generic Apple runner provider cannot fall back to local recording authorit ); }); +test('request provider context preserves an explicitly empty request id', async () => { + const context = await resolvePlatformProviderRequestContext({ + req: { ...request('snapshot'), meta: { requestId: '' } }, + existingSession: makeIosSession('ios-session'), + }); + + assert.equal(context?.requestId, ''); +}); + test('focused Apple runner recording authority remains exact across recreated request scopes', async () => { let activeSessionId: string | undefined; const transport: AppleRunnerScreenRecordingTransport = Object.freeze({ @@ -353,6 +372,23 @@ function request(command: string): DaemonRequest { }; } +async function withRequestPlatformProviderScope( + params: { + req: DaemonRequest; + existingSession: Parameters[0]['existingSession']; + providers: PlatformProviderResolvers; + }, + task: (scope: RequestPlatformProviderScope) => Promise, +): Promise { + const context: PlatformProviderRequestContext | undefined = + await resolvePlatformProviderRequestContext({ + req: params.req, + existingSession: params.existingSession, + }); + if (!context) return await task({}); + return await createRequestPlatformProviders({ providers: params.providers }).run(context, task); +} + function makeWebProvider(overrides: Partial = {}): WebProvider { return { open: async () => {}, diff --git a/src/daemon/__tests__/request-router-android-perf.test.ts b/src/daemon/__tests__/request-router-android-perf.test.ts index 38ed55262..6d4a691f6 100644 --- a/src/daemon/__tests__/request-router-android-perf.test.ts +++ b/src/daemon/__tests__/request-router-android-perf.test.ts @@ -8,7 +8,10 @@ import type { AndroidAdbExecutor, AndroidAdbProvider, } from '../../platforms/android/adb-executor.ts'; -import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; +import { + createPlatformRuntimeGateway, + createRequestPlatformProviders, +} from '../../platform-runtime.ts'; function makeAndroidSessionStore(name: string): SessionStore { const sessionStore = new SessionStore(`/tmp/${name}`); @@ -42,7 +45,9 @@ function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => Andro sessionStore, leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), - androidAdbProvider, + requestPlatformProviders: createRequestPlatformProviders({ + providers: { androidAdbProvider }, + }), deviceRuntimeGateway, trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts index 586910085..879e01d37 100644 --- a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -7,7 +7,10 @@ import { AppError } from '@agent-device/kernel/errors'; import { resetAndroidSnapshotHelperInstallCache } from '../../platforms/android/snapshot-helper-install.ts'; import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../__tests__/test-utils/android-snapshot-helper.ts'; import type { AndroidAdbProvider } from '../../platforms/android/adb-executor.ts'; -import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; +import { + createPlatformRuntimeGateway, + createRequestPlatformProviders, +} from '../../platform-runtime.ts'; function makeAndroidSessionStore(name: string): SessionStore { const sessionStore = new SessionStore(`/tmp/${name}`); @@ -41,7 +44,9 @@ function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => Andro sessionStore, leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), - androidAdbProvider, + requestPlatformProviders: createRequestPlatformProviders({ + providers: { androidAdbProvider }, + }), deviceRuntimeGateway, trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-replay-env.test.ts b/src/daemon/__tests__/request-router-replay-env.test.ts index fa25456ba..8292ee659 100644 --- a/src/daemon/__tests__/request-router-replay-env.test.ts +++ b/src/daemon/__tests__/request-router-replay-env.test.ts @@ -14,7 +14,6 @@ function createHarness() { root, handler: createRequestHandler({ logPath: path.join(root, 'daemon.log'), - stateDir: root, token: 'test-token', sessionStore: makeSessionStore('agent-device-router-replay-env-store-'), leaseRegistry: new LeaseRegistry(), diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index bf2c48c4f..bcccba191 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -42,6 +42,7 @@ import { import { ensureDeviceReady } from '../device-ready.ts'; // Readiness is package-owned; hold the open at the fixture's platform-neutral readiness gate. import { awaitFixtureReadiness } from './application-lifecycle-runtime-fixture.ts'; +import { createRequestPlatformProviders } from '../../platform-runtime.ts'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); @@ -75,7 +76,9 @@ test('replay runs active-session actions inside the parent request provider scop leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), - appleRunnerProvider, + requestPlatformProviders: createRequestPlatformProviders({ + providers: { appleRunnerProvider }, + }), trackDownloadableArtifact: () => 'artifact-id', }); @@ -111,7 +114,9 @@ test('replay routes session-changing actions through the full request path', asy leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), - appleRunnerProvider, + requestPlatformProviders: createRequestPlatformProviders({ + providers: { appleRunnerProvider }, + }), trackDownloadableArtifact: () => 'artifact-id', }); @@ -144,7 +149,9 @@ test('session list includes a cwd-scoped session opened by replay', async () => leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), - appleRunnerProvider: () => undefined, + requestPlatformProviders: createRequestPlatformProviders({ + providers: { appleRunnerProvider: () => undefined }, + }), trackDownloadableArtifact: () => 'artifact-id', }); @@ -207,7 +214,9 @@ test('fresh replay retains a dynamically selected device through finalization', leaseRegistry, deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), - appleRunnerProvider: () => undefined, + requestPlatformProviders: createRequestPlatformProviders({ + providers: { appleRunnerProvider: () => undefined }, + }), trackDownloadableArtifact: () => 'artifact-id', }); const replayResponse = handler({ diff --git a/src/daemon/apple-runner-options.ts b/src/daemon/apple-runner-options.ts index b0c374087..79cd1a9ce 100644 --- a/src/daemon/apple-runner-options.ts +++ b/src/daemon/apple-runner-options.ts @@ -1,17 +1,7 @@ -import type { AppleRunnerLifecycleOptions } from '@agent-device/platform-apple/runner'; +import type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request'; import type { DaemonRequest } from './types.ts'; -export type AppleRunnerRequestOptions = Pick< - AppleRunnerLifecycleOptions, - | 'verbose' - | 'logPath' - | 'traceLogPath' - | 'requestId' - | 'runnerLeaseContext' - | 'iosXctestrunFile' - | 'iosXctestDerivedDataPath' - | 'iosXctestEnvDir' ->; +export type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request'; export function buildAppleRunnerRequestOptions(params: { req: Pick; diff --git a/src/daemon/handlers/session-test-infrastructure.ts b/src/daemon/handlers/session-test-infrastructure.ts index e73a82ca7..319b1fe05 100644 --- a/src/daemon/handlers/session-test-infrastructure.ts +++ b/src/daemon/handlers/session-test-infrastructure.ts @@ -1,4 +1,4 @@ -import { isInfrastructureBootFailureReason } from '../../platforms/boot-diagnostics.ts'; +import { isInfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure'; import type { DaemonResponse } from '../types.ts'; import type { ReplaySuiteTestResult } from '@agent-device/contracts/replay'; import { isDeviceClaimConflictReason } from '../device-claim-conflict.ts'; diff --git a/src/daemon/install-source-resolution.ts b/src/daemon/install-source-resolution.ts index 96c1bc88e..c47980f72 100644 --- a/src/daemon/install-source-resolution.ts +++ b/src/daemon/install-source-resolution.ts @@ -1,5 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { MaterializeInstallSource } from '../platforms/install-source.ts'; +import type { LocalInstallSource } from '@agent-device/kernel/contracts'; import { cleanupUploadedArtifact, prepareUploadedArtifact } from './artifact-tracking.ts'; import type { DaemonInstallSource, DaemonRequest } from './types.ts'; @@ -10,7 +10,7 @@ function assertUnsupportedInstallSource(source: never): never { ); } -function requireInstallSource(req: DaemonRequest): MaterializeInstallSource { +function requireInstallSource(req: DaemonRequest): LocalInstallSource { const source = req.meta?.installSource; if (!source) { throw new AppError('INVALID_ARGS', 'install_from_source requires a source payload'); @@ -43,7 +43,7 @@ function requireInstallSource(req: DaemonRequest): MaterializeInstallSource { } export function resolveInstallSource(req: DaemonRequest): { - source: MaterializeInstallSource; + source: LocalInstallSource; cleanup: () => void; } { const source = requireInstallSource(req); diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index c5cfc1d80..d81c815d2 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -20,7 +20,7 @@ import type { PerfCaptureAdmissionLedger } from './perf-capture-admission-ledger import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; import type { ScreenRecordingAdmissionLedger } from './screen-recording-admission-ledger.ts'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; -import type { RequestPlatformProviderScope } from './request-platform-providers.ts'; +import type { RequestPlatformProviderScope } from '@agent-device/contracts/platform-providers'; type RequestHandlerChainParams = { req: DaemonRequest; @@ -36,7 +36,7 @@ type RequestHandlerChainParams = { invokeReplayAction?: DaemonInvokeFn; /** * Per-request platform-provider injections resolved by the generic - * `withRequestPlatformProviderScope` mechanism. Route handlers pick their own + * root-composed request-provider seam. Route handlers pick their own * platform-specific field back out of this neutral scope instead of the chain * carrying one named slot per platform (e.g. `androidAdbExecutor`). */ diff --git a/src/daemon/request-platform-provider-context.ts b/src/daemon/request-platform-provider-context.ts new file mode 100644 index 000000000..bd260a911 --- /dev/null +++ b/src/daemon/request-platform-provider-context.ts @@ -0,0 +1,77 @@ +import type { PlatformProviderRequestContext } from '@agent-device/contracts/platform-providers'; +import { resolveTargetDevice } from '../core/dispatch-resolve.ts'; +import { hasDeviceSelectionInput, hasExplicitDeviceSelector } from './device-selector-intent.ts'; +import { buildOpenTargetDeviceResolutionOptions } from './open-device-selection.ts'; +import { resolveProviderDeviceResolutionIntent } from './daemon-command-registry.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; + +/** + * Resolves the daemon-owned part of the request-provider seam. + * + * Provider implementations receive a neutral context from the root composition. In particular, + * they never receive a DaemonRequest or SessionState: those types contain daemon-only callbacks + * and lifecycle state. An unresolvable provider device intentionally produces no context; command + * admission remains responsible for reporting the request's device-resolution error. + */ +export async function resolvePlatformProviderRequestContext(params: { + req: DaemonRequest; + existingSession: SessionState | undefined; + useDefaultWebProvider?: boolean; +}): Promise { + const device = await resolveScopedProviderDevice(params.req, params.existingSession); + if (!device) return undefined; + + return { + device, + ...(params.req.session !== undefined ? { requestedSession: params.req.session } : {}), + ...(params.req.meta?.requestId !== undefined ? { requestId: params.req.meta.requestId } : {}), + ...(params.existingSession + ? { + session: { + name: params.existingSession.name, + device: params.existingSession.device, + ...(params.existingSession.appBundleId + ? { appBundleId: params.existingSession.appBundleId } + : {}), + ...(params.existingSession.appName ? { appName: params.existingSession.appName } : {}), + ...(params.existingSession.surface ? { surface: params.existingSession.surface } : {}), + }, + } + : {}), + ...(params.useDefaultWebProvider ? { useDefaultWebProvider: true } : {}), + }; +} + +async function resolveScopedProviderDevice( + req: DaemonRequest, + existingSession: SessionState | undefined, +): Promise { + const intent = resolveProviderDeviceResolutionIntent(req, { + hasExistingSession: Boolean(existingSession), + hasExplicitDeviceIdentity: hasExplicitDeviceSelector(req.flags), + hasDeviceSelectionInput: hasDeviceSelectionInput(req.flags), + }); + switch (intent) { + case 'existing-session': + return existingSession?.device; + case 'explicit-device': + case 'sessionless-default-device': + // Provider-scope plumbing only: an unresolvable device means "no provider scope for this + // request", never a failed request. The command's own device resolution reports errors. + try { + return await resolveProviderTargetDevice(req); + } catch { + return undefined; + } + case 'skip': + return undefined; + } +} + +async function resolveProviderTargetDevice(req: DaemonRequest): Promise { + const options = + req.command === 'open' + ? buildOpenTargetDeviceResolutionOptions(req.positionals?.[0]) + : undefined; + return await resolveTargetDevice(req.flags ?? {}, options); +} diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index cd2347c69..dce3aea20 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -14,22 +14,15 @@ import { supportedPlatformsForCommand } from '../core/capabilities.ts'; import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import type { DaemonArtifactType, ResponseCost } from '@agent-device/kernel/contracts'; import type { CloudArtifactProvider } from '@agent-device/contracts/observability'; +import type { + RequestPlatformProviderScope, + RequestPlatformProviders, +} from '@agent-device/contracts/platform-providers'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, DaemonResponseData } from './types.ts'; import { RESPONSE_VIEWS } from './response-views.ts'; import { SessionStore } from './session-store.ts'; import { errorResponse, noActiveSessionError } from './handlers/response.ts'; -import { - type AndroidAdbProviderResolver, - type AppleRunnerProviderResolver, - type AppleRunnerScreenRecordingTransportResolver, - type AppleToolProviderResolver, - type LinuxToolProviderResolver, - type RequestPlatformProviderScope, - type AppleSimulatorScreenRecordingTransportResolver, - type VegaToolProviderResolver, - type WebProviderResolver, - withRequestPlatformProviderScope, -} from './request-platform-providers.ts'; +import { resolvePlatformProviderRequestContext } from './request-platform-provider-context.ts'; import { countDiagnosticEventsByPhase, emitDiagnostic, @@ -52,9 +45,7 @@ import { } from './request-execution-scope.ts'; import { unsupportedSaveScriptFlagResponse } from './request-save-script-policy.ts'; import { canRunReplayScopedAction } from './daemon-command-registry.ts'; -import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts'; import { isWebSession } from './session-teardown.ts'; -import { openWebSessionNames } from './web-session-names.ts'; import { inferFillText } from './action-utils.ts'; import { createPlatformRequestScope } from './platform-request-scope.ts'; import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; @@ -76,7 +67,6 @@ import { type ScreenRecordingAdmissionLedger, } from './screen-recording-admission-ledger.ts'; import { resolveGenericRuntimeExecution } from './generic-runtime-execution.ts'; -import type { OwnedProcessRecordStore } from '../utils/owned-process-record.ts'; // --------------------------------------------------------------------------- // Request handler API @@ -84,19 +74,10 @@ import type { OwnedProcessRecordStore } from '../utils/owned-process-record.ts'; export type RequestRouterDeps = { logPath: string; - stateDir?: string; - ownedProcessRecords?: OwnedProcessRecordStore; token: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; - androidAdbProvider?: AndroidAdbProviderResolver; - appleRunnerProvider?: AppleRunnerProviderResolver; - appleRunnerScreenRecordingTransport?: AppleRunnerScreenRecordingTransportResolver; - appleToolProvider?: AppleToolProviderResolver; - linuxToolProvider?: LinuxToolProviderResolver; - vegaToolProvider?: VegaToolProviderResolver; - webProvider?: WebProviderResolver; - appleSimulatorScreenRecordingTransport?: AppleSimulatorScreenRecordingTransportResolver; + requestPlatformProviders?: RequestPlatformProviders; deviceInventoryGateways: ComposedDeviceInventoryGateways; deviceRuntimeGateway: DeviceRuntimeGateway; appLogAdmissionLedger?: AppLogAdmissionLedger; @@ -120,17 +101,8 @@ export type RequestRouterDeps = { export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { const { logPath, - stateDir, - ownedProcessRecords, token, - androidAdbProvider, - appleRunnerProvider, - appleRunnerScreenRecordingTransport, - appleToolProvider, - linuxToolProvider, - vegaToolProvider, - webProvider, - appleSimulatorScreenRecordingTransport, + requestPlatformProviders = EMPTY_REQUEST_PLATFORM_PROVIDERS, deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger = createAppLogAdmissionLedger(), @@ -244,29 +216,19 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { : await runLockedRequest(); }; - return inheritedProviderScope - ? await executeLocked(inheritedProviderScope) - : await withRequestPlatformProviderScope( - { - req: lockedScope.req, - existingSession: lockedScope.existingSession, - providers: { - androidAdbProvider, - appleRunnerProvider, - appleRunnerScreenRecordingTransport, - appleToolProvider, - linuxToolProvider, - vegaToolProvider, - webProvider: - webProvider ?? - (shouldUseDefaultWebProvider(lockedScope) - ? createDefaultWebProvider(stateDir, sessionStore, ownedProcessRecords) - : undefined), - appleSimulatorScreenRecordingTransport, - }, - }, - executeLocked, - ); + if (inheritedProviderScope) return await executeLocked(inheritedProviderScope); + const useDefaultWebProvider = shouldUseDefaultWebProvider(lockedScope); + if (!requestPlatformProviders.hasConfiguredResolvers && !useDefaultWebProvider) { + return await executeLocked({}); + } + const context = await resolvePlatformProviderRequestContext({ + req: lockedScope.req, + existingSession: lockedScope.existingSession, + useDefaultWebProvider, + }); + return context + ? await requestPlatformProviders.run(context, executeLocked) + : await executeLocked({}); }; return inheritedProviderScope ? await scope.runAdmitted(run) : await scope.runLocked(run); @@ -361,19 +323,10 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { return handleRequest; } -const createDefaultWebProvider = - ( - stateDir: string | undefined, - sessionStore: SessionStore, - ownedProcessRecords: OwnedProcessRecordStore | undefined, - ): WebProviderResolver => - ({ req, session }) => - createAgentBrowserWebProvider({ - session: session?.name ?? req.session, - stateDir, - openWebSessionNames: () => openWebSessionNames(sessionStore), - ownedProcessRecords, - }); +const EMPTY_REQUEST_PLATFORM_PROVIDERS: RequestPlatformProviders = Object.freeze({ + hasConfiguredResolvers: false, + run: async (_context, task) => await task({}), +}); function shouldUseDefaultWebProvider(scope: LockedRequestScope): boolean { return ( diff --git a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts index 4cceba3f8..39702dc99 100644 --- a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts @@ -5,6 +5,9 @@ import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; const lifecycleEvents = vi.hoisted(() => [] as string[]); vi.mock('../../platform-runtime.ts', () => ({ + createRequestPlatformProviders: () => ({ + run: async (_context: unknown, task: () => Promise) => await task(), + }), createPlatformRuntimeGateway: () => ({ applicationLifecycle: { recoverStartupResources: async () => {}, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 22138aac8..4b9b6ef62 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -8,6 +8,7 @@ import { createProviderDeviceRuntimeRequestProviders } from '../../provider-devi import { createPlatformRuntimeGateway, createPlatformDeviceInventoryGateways, + createRequestPlatformProviders, } from '../../platform-runtime.ts'; import { createHostDiagnostics } from '../../platform-runtime-host-diagnostics.ts'; import { @@ -285,6 +286,18 @@ export async function startDaemonRuntime( providerDeviceRuntimes, { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, ); + const requestPlatformProviders = createRequestPlatformProviders({ + providers: { + appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, + appleRunnerScreenRecordingTransport: + providerRuntimeProviders.appleRunnerScreenRecordingTransport, + }, + defaultWebProvider: { + stateDir: baseDir, + openWebSessionNames: () => openWebSessionNames(sessionStore), + ownedProcessRecords, + }, + }); const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, @@ -309,8 +322,6 @@ export async function startDaemonRuntime( const dispatchRequest = createRequestHandler({ logPath, - stateDir: baseDir, - ownedProcessRecords, token, sessionStore, leaseRegistry, @@ -323,9 +334,7 @@ export async function startDaemonRuntime( perfCaptureAdmissionLedger, hostDiagnostics, screenRecordingAdmissionLedger, - appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, - appleRunnerScreenRecordingTransport: - providerRuntimeProviders.appleRunnerScreenRecordingTransport, + requestPlatformProviders, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index e66387ecf..c4526735a 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -14,6 +14,11 @@ import { createPlatformModuleRegistry, } from '@agent-device/contracts/platform-module'; import type { DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; +import type { + PlatformProviderRequestContext, + RequestPlatformProviderScope, + RequestPlatformProviders, +} from '@agent-device/contracts/platform-providers'; import type { PlatformRuntimeModule, PlatformRuntimeOperations, @@ -51,6 +56,13 @@ import { type PlatformRuntimeProviderRegistration, } from './platform-runtime-gateway.ts'; import { createComposedDeviceInventoryGateways } from './platform-runtime-device-inventory.ts'; +import type { RequestPlatformProviderOptions } from './platform-runtime/request-providers.ts'; + +export type { + AppleRunnerProviderResolver, + AppleRunnerScreenRecordingTransportResolver, + PlatformProviderResolvers, +} from './platform-runtime/request-providers.ts'; export async function readAndroidAppStateWithHost( host: AppStateRuntimeHost['android'], @@ -145,6 +157,31 @@ export function createPlatformRuntimeGateway( }); } +/** + * The canonical root owns request-provider composition as well as device-runtime composition. + * Its private submodule stays unevaluated until a request actually enters a provider scope, so + * importing the runtime registry does not load provider or plugin implementations eagerly. + */ +export function createRequestPlatformProviders( + options: RequestPlatformProviderOptions = {}, +): RequestPlatformProviders { + let composed: Promise | undefined; + const resolveComposed = (): Promise => { + composed ??= import('./platform-runtime/request-providers.ts').then( + ({ createComposedRequestPlatformProviders }) => + createComposedRequestPlatformProviders(options), + ); + return composed; + }; + return Object.freeze({ + hasConfiguredResolvers: Object.values(options.providers ?? {}).some(Boolean), + run: async ( + context: PlatformProviderRequestContext, + task: (scope: RequestPlatformProviderScope) => Promise, + ): Promise => await (await resolveComposed()).run(context, task), + }); +} + function configuredValues(...values: Array): string[] { return values.flatMap((value) => { const configured = value?.trim(); diff --git a/src/daemon/request-platform-providers.ts b/src/platform-runtime/request-providers.ts similarity index 59% rename from src/daemon/request-platform-providers.ts rename to src/platform-runtime/request-providers.ts index 2efd3812d..c4c43b6f3 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/platform-runtime/request-providers.ts @@ -1,31 +1,27 @@ -import type { PlatformGatedProviderResolverKey } from '@agent-device/contracts/platform-providers'; -import { resolveTargetDevice } from '../core/dispatch-resolve.ts'; -import { registerBuiltinPlatformPlugins } from '../core/interactors/register-builtins.ts'; -import { tryGetPlugin } from '../core/platform-plugin-registry.ts'; +import type { + PlatformGatedProviderResolverKey, + PlatformProviderRequestContext, + RequestPlatformProviderScope, + RequestPlatformProviders, +} from '@agent-device/contracts/platform-providers'; import type { AndroidAdbExecutor, AndroidAdbProvider } from '../platforms/android/adb-executor.ts'; import type { AppleRunnerCommandExecutor, AppleRunnerProvider, } from '@agent-device/platform-apple/runner'; +import type { WebProvider } from '../platforms/web/provider.ts'; import type { AppleToolProvider } from '../platforms/apple/core/tool-provider.ts'; import type { LinuxToolProvider } from '../platforms/linux/tool-provider.ts'; import type { VegaToolProvider } from '../platforms/vega/tool-provider.ts'; -import { withWebProvider, type WebProvider } from '../platforms/web/provider.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppleSimulatorScreenRecordingTransport } from '../platform-runtime-screen-recording-apple-transport.ts'; import type { AppleRunnerScreenRecordingTransport } from '../platform-runtime-screen-recording-apple-runner-transport.ts'; -import { hasDeviceSelectionInput, hasExplicitDeviceSelector } from './device-selector-intent.ts'; -import { buildOpenTargetDeviceResolutionOptions } from './open-device-selection.ts'; -import type { DaemonRequest, SessionState } from './types.ts'; -import { resolveProviderDeviceResolutionIntent } from './daemon-command-registry.ts'; - -export type PlatformProviderRequestSession = Pick< - SessionState, - 'name' | 'device' | 'appBundleId' | 'appName' | 'surface' ->; +import type { OwnedProcessRecordStore } from '../utils/owned-process-record.ts'; +import { tryGetPlugin } from '../core/platform-plugin-registry.ts'; +import { registerBuiltinPlatformPlugins } from '../core/interactors/register-builtins.ts'; export type PlatformProviderResolver = ( - params: RequestPlatformProviderResolverContext, + context: PlatformProviderRequestContext, ) => TResult; export type AndroidAdbProviderResolver = PlatformProviderResolver< @@ -63,47 +59,16 @@ export type PlatformProviderResolvers = { appleSimulatorScreenRecordingTransport?: AppleSimulatorScreenRecordingTransportResolver; }; -// Compile-time: every gated key is a real resolver key (so the facet can never name a -// resolver the daemon does not compose). -type AssertTrue = T; -/** Exported only so `noUnusedLocals` keeps the guard alive. */ -export type GatedKeysAreResolverKeys = AssertTrue< - [PlatformGatedProviderResolverKey] extends [keyof PlatformProviderResolvers] ? true : false ->; +export type DefaultWebProviderOptions = Readonly<{ + stateDir?: string; + openWebSessionNames: () => readonly string[]; + ownedProcessRecords?: OwnedProcessRecordStore; +}>; -// The plugin registry backs `platformGatedResolverApplies`; register the builtin -// plugins on load so the lookup is populated (idempotent, mirrors app-log.ts). -registerBuiltinPlatformPlugins(); - -/** - * Whether the platform-gated resolver `key` applies to `device`, per the owning - * family's PlatformPlugin `providers` facet. A device on a platform with no plugin, or - * a family that does not list `key`, resolves to `false` — byte-identical to the former - * hand `device.platform === …` gate (which also excluded every other platform). Pinned - * by the providers-plugin routing parity test. - */ -function platformGatedResolverApplies( - key: PlatformGatedProviderResolverKey, - device: DeviceInfo, -): boolean { - return tryGetPlugin(device.platform)?.providers?.platformGatedResolvers.includes(key) ?? false; -} - -export type RequestPlatformProviderScope = { - androidAdbExecutor?: AndroidAdbExecutor; -}; - -type RequestPlatformProviderParams = { - req: DaemonRequest; - existingSession: SessionState | undefined; - providers: PlatformProviderResolvers; -}; - -type RequestPlatformProviderResolverContext = { - req: DaemonRequest; - device: DeviceInfo; - session?: PlatformProviderRequestSession; -}; +export type RequestPlatformProviderOptions = Readonly<{ + providers?: PlatformProviderResolvers; + defaultWebProvider?: DefaultWebProviderOptions; +}>; type ResolvedRequestPlatformProviders = { androidAdb?: { @@ -116,24 +81,12 @@ type ResolvedRequestPlatformProviders = { deviceId?: string; requestId?: string; }; - appleTool?: { - provider?: AppleToolProvider; - }; - linuxTool?: { - provider?: LinuxToolProvider; - }; - vegaTool?: { - provider?: VegaToolProvider; - }; - web?: { - provider?: WebProvider; - }; - appleSimulatorScreenRecording?: { - provider?: AppleSimulatorScreenRecordingTransport; - }; - appleRunnerScreenRecording?: { - provider?: AppleRunnerScreenRecordingTransport; - }; + appleTool?: { provider?: AppleToolProvider }; + linuxTool?: { provider?: LinuxToolProvider }; + vegaTool?: { provider?: VegaToolProvider }; + web?: { provider?: WebProvider }; + appleSimulatorScreenRecording?: { provider?: AppleSimulatorScreenRecordingTransport }; + appleRunnerScreenRecording?: { provider?: AppleRunnerScreenRecordingTransport }; }; type RequestPlatformProviderScopeWrapper = (task: () => Promise) => Promise; @@ -142,7 +95,7 @@ type RequestPlatformProviderDescriptor = { resolverKey: keyof PlatformProviderResolvers; resolve: ( providers: PlatformProviderResolvers, - context: RequestPlatformProviderResolverContext, + context: PlatformProviderRequestContext, ) => ResolvedRequestPlatformProviders; appendWrapper: ( scopedProviders: ResolvedRequestPlatformProviders, @@ -150,17 +103,67 @@ type RequestPlatformProviderDescriptor = { ) => Promise; }; +/** + * Root-owned provider composition. Device selection is intentionally absent: the daemon resolves + * the provider device first and supplies only this neutral context. Concrete providers are loaded + * lazily at the moment their request scope is actually needed. + */ +export function createComposedRequestPlatformProviders( + options: RequestPlatformProviderOptions = {}, +): RequestPlatformProviders { + const providers = options.providers ?? {}; + const hasConfiguredResolvers = hasPlatformProviderResolvers(providers); + return Object.freeze({ + hasConfiguredResolvers, + run: async ( + context: PlatformProviderRequestContext, + task: (scope: RequestPlatformProviderScope) => Promise, + ): Promise => { + const effectiveProviders = await providersForContext( + providers, + options.defaultWebProvider, + context, + ); + const scopedProviders = resolveRequestPlatformProviders(effectiveProviders, context); + const scope: RequestPlatformProviderScope = { + androidAdbExecutor: scopedProviders.androidAdb?.executor, + }; + const wrappers = await requestPlatformProviderScopeWrappers(scopedProviders); + return await runRequestPlatformProviderScopes(wrappers, async () => await task(scope)); + }, + }); +} + +async function providersForContext( + providers: PlatformProviderResolvers, + defaultWebProvider: DefaultWebProviderOptions | undefined, + context: PlatformProviderRequestContext, +): Promise { + if (providers.webProvider || !context.useDefaultWebProvider || !defaultWebProvider) { + return providers; + } + const { createAgentBrowserWebProvider } = + await import('../platforms/web/agent-browser-provider.ts'); + return { + ...providers, + webProvider: ({ requestedSession, session }) => + createAgentBrowserWebProvider({ + session: session?.name ?? requestedSession, + stateDir: defaultWebProvider.stateDir, + openWebSessionNames: defaultWebProvider.openWebSessionNames, + ownedProcessRecords: defaultWebProvider.ownedProcessRecords, + }), + }; +} + const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ { resolverKey: 'androidAdbProvider', resolve(providers, context) { - const androidAdbProvider = providers.androidAdbProvider; - if ( - !androidAdbProvider || - !platformGatedResolverApplies('androidAdbProvider', context.device) - ) + const resolver = providers.androidAdbProvider; + if (!resolver || !platformGatedResolverApplies('androidAdbProvider', context.device)) return {}; - const provider = androidAdbProvider(context); + const provider = resolver(context); const executor = typeof provider === 'function' ? provider : provider?.exec; return { androidAdb: { provider, executor, serial: context.device.id } }; }, @@ -179,18 +182,15 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ { resolverKey: 'appleRunnerProvider', resolve(providers, context) { - const appleRunnerProvider = providers.appleRunnerProvider; - if ( - !appleRunnerProvider || - !platformGatedResolverApplies('appleRunnerProvider', context.device) - ) + const resolver = providers.appleRunnerProvider; + if (!resolver || !platformGatedResolverApplies('appleRunnerProvider', context.device)) return {}; - const provider = appleRunnerProvider(context); + const provider = resolver(context); return { appleRunner: { provider, deviceId: context.device.id, - requestId: context.req.meta?.requestId, + requestId: context.requestId, }, }; }, @@ -214,10 +214,10 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ { resolverKey: 'appleToolProvider', resolve(providers, context) { - const appleToolProvider = providers.appleToolProvider; - if (!appleToolProvider || !platformGatedResolverApplies('appleToolProvider', context.device)) + const resolver = providers.appleToolProvider; + if (!resolver || !platformGatedResolverApplies('appleToolProvider', context.device)) return {}; - return { appleTool: { provider: appleToolProvider(context) } }; + return { appleTool: { provider: resolver(context) } }; }, async appendWrapper(scopedProviders, wrappers) { if (!scopedProviders.appleTool?.provider) return; @@ -228,10 +228,9 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ { resolverKey: 'vegaToolProvider', resolve(providers, context) { - const vegaToolProvider = providers.vegaToolProvider; - if (!vegaToolProvider || !platformGatedResolverApplies('vegaToolProvider', context.device)) - return {}; - return { vegaTool: { provider: vegaToolProvider(context) } }; + const resolver = providers.vegaToolProvider; + if (!resolver || !platformGatedResolverApplies('vegaToolProvider', context.device)) return {}; + return { vegaTool: { provider: resolver(context) } }; }, async appendWrapper(scopedProviders, wrappers) { if (!scopedProviders.vegaTool?.provider) return; @@ -242,10 +241,10 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ { resolverKey: 'linuxToolProvider', resolve(providers, context) { - const linuxToolProvider = providers.linuxToolProvider; - if (!linuxToolProvider || !platformGatedResolverApplies('linuxToolProvider', context.device)) + const resolver = providers.linuxToolProvider; + if (!resolver || !platformGatedResolverApplies('linuxToolProvider', context.device)) return {}; - return { linuxTool: { provider: linuxToolProvider(context) } }; + return { linuxTool: { provider: resolver(context) } }; }, async appendWrapper(scopedProviders, wrappers) { if (!scopedProviders.linuxTool?.provider) return; @@ -256,12 +255,13 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ { resolverKey: 'webProvider', resolve(providers, context) { - const webProvider = providers.webProvider; - if (!webProvider || !platformGatedResolverApplies('webProvider', context.device)) return {}; - return { web: { provider: webProvider(context) } }; + const resolver = providers.webProvider; + if (!resolver || !platformGatedResolverApplies('webProvider', context.device)) return {}; + return { web: { provider: resolver(context) } }; }, async appendWrapper(scopedProviders, wrappers) { if (!scopedProviders.web?.provider) return; + const { withWebProvider } = await import('../platforms/web/provider.ts'); appendRequestProviderWrapper(wrappers, scopedProviders.web, withWebProvider); }, }, @@ -299,31 +299,12 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [ }, ] satisfies RequestPlatformProviderDescriptor[]; -export async function withRequestPlatformProviderScope( - params: RequestPlatformProviderParams, - task: (scope: RequestPlatformProviderScope) => Promise, -): Promise { - const scopedProviders = await resolveRequestPlatformProviders(params); - const scope: RequestPlatformProviderScope = { - androidAdbExecutor: scopedProviders.androidAdb?.executor, - }; - const wrappers = await requestPlatformProviderScopeWrappers(scopedProviders); - - return await runRequestPlatformProviderScopes(wrappers, async () => await task(scope)); -} - -async function resolveRequestPlatformProviders( - params: RequestPlatformProviderParams, -): Promise { - if (!hasPlatformProviderResolvers(params.providers)) return {}; - const device = await resolveScopedProviderDevice(params.req, params.existingSession); - if (!device) return {}; - const context = requestProviderResolverContext(params, device); +function resolveRequestPlatformProviders( + providers: PlatformProviderResolvers, + context: PlatformProviderRequestContext, +): ResolvedRequestPlatformProviders { return REQUEST_PLATFORM_PROVIDER_DESCRIPTORS.reduce( - (resolved, descriptor) => ({ - ...resolved, - ...descriptor.resolve(params.providers, context), - }), + (resolved, descriptor) => ({ ...resolved, ...descriptor.resolve(providers, context) }), {}, ); } @@ -334,51 +315,15 @@ function hasPlatformProviderResolvers(providers: PlatformProviderResolvers): boo ); } -function requestProviderResolverContext( - params: RequestPlatformProviderParams, +function platformGatedResolverApplies( + key: PlatformGatedProviderResolverKey, device: DeviceInfo, -): RequestPlatformProviderResolverContext { - return { - req: params.req, - device, - session: params.existingSession, - }; -} - -async function resolveScopedProviderDevice( - req: DaemonRequest, - existingSession: SessionState | undefined, -): Promise { - const intent = resolveProviderDeviceResolutionIntent(req, { - hasExistingSession: Boolean(existingSession), - hasExplicitDeviceIdentity: hasExplicitDeviceSelector(req.flags), - hasDeviceSelectionInput: hasDeviceSelectionInput(req.flags), - }); - switch (intent) { - case 'existing-session': - return existingSession?.device; - case 'explicit-device': - case 'sessionless-default-device': - // Provider-scope plumbing only: an unresolvable device means "no provider - // scope for this request", never a failed request — the command's own - // device resolution still reports its errors downstream. - try { - return await resolveProviderTargetDevice(req); - } catch { - return undefined; - } - case 'skip': - return undefined; - } +): boolean { + // The registry is intentionally loaded by the root composition, not by the daemon request path. + return tryGetPlugin(device.platform)?.providers?.platformGatedResolvers.includes(key) ?? false; } -async function resolveProviderTargetDevice(req: DaemonRequest): Promise { - const options = - req.command === 'open' - ? buildOpenTargetDeviceResolutionOptions(req.positionals?.[0]) - : undefined; - return await resolveTargetDevice(req.flags ?? {}, options); -} +registerBuiltinPlatformPlugins(); async function requestPlatformProviderScopeWrappers( scopedProviders: ResolvedRequestPlatformProviders, diff --git a/src/platforms/android/install-artifact.ts b/src/platforms/android/install-artifact.ts index d7c91478b..1b5fbb114 100644 --- a/src/platforms/android/install-artifact.ts +++ b/src/platforms/android/install-artifact.ts @@ -1,9 +1,6 @@ import path from 'node:path'; -import { - isTrustedInstallSourceUrl, - materializeInstallablePath, - type MaterializeInstallSource, -} from '../install-source.ts'; +import type { LocalInstallSource } from '@agent-device/kernel/contracts'; +import { isTrustedInstallSourceUrl, materializeInstallablePath } from '../install-source.ts'; import * as manifest from './manifest.ts'; export type PreparedAndroidInstallArtifact = { @@ -14,7 +11,7 @@ export type PreparedAndroidInstallArtifact = { }; export async function prepareAndroidInstallArtifact( - source: MaterializeInstallSource, + source: LocalInstallSource, options?: { signal?: AbortSignal; resolveIdentity?: boolean }, ): Promise { const trustedUrlSource = source.kind === 'url' && isTrustedInstallSourceUrl(source.url); diff --git a/src/platforms/apple/core/install-artifact.ts b/src/platforms/apple/core/install-artifact.ts index 9d9349a9a..db3caa9d5 100644 --- a/src/platforms/apple/core/install-artifact.ts +++ b/src/platforms/apple/core/install-artifact.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { LocalInstallSource } from '@agent-device/kernel/contracts'; import { readInfoPlistString } from './plist.ts'; import { AppError } from '@agent-device/kernel/errors'; import { extractArchiveSafely } from '../../../utils/archive-extraction.ts'; @@ -11,11 +12,7 @@ import { noteInstallArtifactArchiveDepth, withInstallArtifactArchiveScope, } from '../../install-artifact-archive-context.ts'; -import { - isTrustedInstallSourceUrl, - materializeInstallablePath, - type MaterializeInstallSource, -} from '../../install-source.ts'; +import { isTrustedInstallSourceUrl, materializeInstallablePath } from '../../install-source.ts'; type InstallIosArtifactOptions = { appIdentifierHint?: string; @@ -38,7 +35,7 @@ export type PreparedIosInstallArtifact = { }; export async function prepareIosInstallArtifact( - source: MaterializeInstallSource, + source: LocalInstallSource, options?: InstallIosArtifactOptions, ): Promise { return await withInstallArtifactArchiveScope( @@ -47,7 +44,7 @@ export async function prepareIosInstallArtifact( } async function prepareIosInstallArtifactInScope( - source: MaterializeInstallSource, + source: LocalInstallSource, options?: InstallIosArtifactOptions, ): Promise { if (source.kind === 'url' && !isTrustedInstallSourceUrl(source.url)) { diff --git a/src/platforms/boot-diagnostics.ts b/src/platforms/boot-diagnostics.ts index a1b7d4ace..6fbbb8749 100644 --- a/src/platforms/boot-diagnostics.ts +++ b/src/platforms/boot-diagnostics.ts @@ -1,36 +1,19 @@ import { asAppError } from '@agent-device/kernel/errors'; +import type { InfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure'; +export { isInfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure'; +export type { InfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure'; export type BootFailureReason = - | 'IOS_BOOT_TIMEOUT' - | 'IOS_RUNNER_CONNECT_TIMEOUT' - | 'IOS_RUNNER_OWNED_BY_OTHER_DAEMON' + | InfrastructureBootFailureReason | 'IOS_RUNNER_DEVICE_NOT_PROVISIONED' - | 'IOS_TOOL_MISSING' - | 'ANDROID_BOOT_TIMEOUT' - | 'ADB_TRANSPORT_UNAVAILABLE' - | 'CI_RESOURCE_STARVATION_SUSPECTED' | 'BOOT_COMMAND_FAILED' | 'UNKNOWN'; -const INFRASTRUCTURE_BOOT_FAILURE_REASONS = new Set([ - 'IOS_BOOT_TIMEOUT', - 'IOS_RUNNER_CONNECT_TIMEOUT', - 'IOS_RUNNER_OWNED_BY_OTHER_DAEMON', - 'IOS_TOOL_MISSING', - 'ANDROID_BOOT_TIMEOUT', - 'ADB_TRANSPORT_UNAVAILABLE', - 'CI_RESOURCE_STARVATION_SUSPECTED', -]); - type BootDiagnosticContext = { platform?: 'ios' | 'android'; phase?: 'boot' | 'connect' | 'transport'; }; -export function isInfrastructureBootFailureReason(reason: string): boolean { - return INFRASTRUCTURE_BOOT_FAILURE_REASONS.has(reason.toUpperCase() as BootFailureReason); -} - export function classifyBootFailure(input: { error?: unknown; message?: string; diff --git a/src/platforms/install-source.ts b/src/platforms/install-source.ts index a01c9de56..53fd4c930 100644 --- a/src/platforms/install-source.ts +++ b/src/platforms/install-source.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { LocalInstallSource } from '@agent-device/kernel/contracts'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { expandUserHomePath } from '../utils/path-resolution.ts'; import { ArchiveBudget } from '../utils/archive-safety.ts'; @@ -12,24 +13,13 @@ import { import { approveDownloadSourceUrl } from './install-source-network.ts'; import { downloadInstallSource } from './install-source-download.ts'; -export type MaterializeInstallSource = - | { - kind: 'url'; - url: string; - headers?: Record; - } - | { - kind: 'path'; - path: string; - }; - type MaterializeLocalSourceResult = { localPath: string; cleanup: () => Promise; }; export type MaterializeInstallableOptions = { - source: MaterializeInstallSource; + source: LocalInstallSource; isInstallablePath: ( candidatePath: string, stat: { isFile(): boolean; isDirectory(): boolean }, @@ -94,7 +84,7 @@ function expandSourcePath(inputPath: string): string { } async function materializeLocalSource( - source: MaterializeInstallSource, + source: LocalInstallSource, options?: { signal?: AbortSignal; downloadTimeoutMs?: number }, ): Promise { if (source.kind === 'path') { diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 302bb856c..dde9afa82 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -18,7 +18,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { AppleRunnerProviderResolver, AppleRunnerScreenRecordingTransportResolver, -} from './daemon/request-platform-providers.ts'; +} from './platform-runtime.ts'; import type { AppleRunnerScreenRecordingTransport } from './platform-runtime-screen-recording-apple-runner-transport.ts'; import type { AppleRunnerCommandExecutor, diff --git a/src/sdk/install-source.ts b/src/sdk/install-source.ts index 2b732f951..923eefad6 100644 --- a/src/sdk/install-source.ts +++ b/src/sdk/install-source.ts @@ -4,4 +4,4 @@ export { validateDownloadSourceUrl, } from '../platforms/install-source.ts'; -export type { MaterializeInstallSource } from '../platforms/install-source.ts'; +export type { LocalInstallSource as MaterializeInstallSource } from '@agent-device/kernel/contracts'; diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 65e80c3e2..80789003a 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -10,6 +10,11 @@ import { createRequestHandler, type RequestRouterDeps, } from '../../../src/daemon/request-router.ts'; +import { + createPlatformRuntimeGateway, + createRequestPlatformProviders, + type PlatformProviderResolvers, +} from '../../../src/platform-runtime.ts'; import type { AppleSimulatorScreenRecordingProcess } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; import { trackDownloadableArtifact } from '../../../src/daemon/artifact-tracking.ts'; import { LeaseRegistry } from '../../../src/daemon/lease-registry.ts'; @@ -26,12 +31,12 @@ import { createTestDeviceInventoryGateways, createTestDeviceInventoryGatewaysFromProvider, } from '../../../src/__tests__/test-utils/device-inventory-gateways.ts'; -import { createPlatformRuntimeGateway } from '../../../src/platform-runtime.ts'; import { createHostDiagnostics } from '../../../src/platform-runtime-host-diagnostics.ts'; import type { PlatformRuntimeProviderRegistration } from '../../../src/platform-runtime-gateway.ts'; import { createProviderPlatformRuntimeRegistrations } from '../../../src/provider-device-runtimes.ts'; import { unavailableDeviceRuntimeGateway } from '../../../src/daemon/__tests__/test-device-runtime-gateway.ts'; import { createOwnedProcessRecordStore } from '../../../src/utils/owned-process-record.ts'; +import { openWebSessionNames } from '../../../src/daemon/web-session-names.ts'; const PROVIDER_SCENARIO_TOKEN = 'provider-scenario-token'; const PROVIDER_SCENARIO_TEMP_REMOVE_OPTIONS = { @@ -75,6 +80,7 @@ export type ProviderScenarioPlatformRuntime = export async function createProviderScenarioHarness( deps: Partial> & + Partial & ( | { deviceInventoryProvider: DeviceInventoryProvider; deviceInventorySource?: never } | { deviceInventorySource: ProviderDeviceInventorySource; deviceInventoryProvider?: never } @@ -102,6 +108,15 @@ export async function createProviderScenarioHarness( deviceRuntimeGateway: configuredDeviceRuntimeGateway, platformRuntime = true, providerRuntimes, + requestPlatformProviders: configuredRequestPlatformProviders, + androidAdbProvider, + appleRunnerProvider, + appleRunnerScreenRecordingTransport, + appleToolProvider, + linuxToolProvider, + vegaToolProvider, + webProvider, + appleSimulatorScreenRecordingTransport, ...routerDeps } = deps; const platformRuntimeOptions = @@ -131,7 +146,6 @@ export async function createProviderScenarioHarness( logPath: path.join(os.tmpdir(), 'agent-device-provider-scenario-daemon.log'), token: PROVIDER_SCENARIO_TOKEN, sessionStore, - ownedProcessRecords, leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: deviceInventorySource ? createTestDeviceInventoryGateways({ provider: deviceInventorySource }) @@ -141,6 +155,25 @@ export async function createProviderScenarioHarness( // Match daemon composition (src/daemon/server/daemon-runtime.ts): doctor's host-scoped // diagnostics are injected at the root, so the harness composes them the same way. hostDiagnostics: createHostDiagnostics(), + requestPlatformProviders: + configuredRequestPlatformProviders ?? + createRequestPlatformProviders({ + providers: { + androidAdbProvider, + appleRunnerProvider, + appleRunnerScreenRecordingTransport, + appleToolProvider, + linuxToolProvider, + vegaToolProvider, + webProvider, + appleSimulatorScreenRecordingTransport, + }, + defaultWebProvider: { + stateDir: path.dirname(sessionDir), + openWebSessionNames: () => openWebSessionNames(sessionStore), + ownedProcessRecords, + }, + }), ...routerDeps, }); const handleRequest: typeof requestHandler = async (request) => {