Files
callstack__agent-device/src/provider-device-runtime.ts
T
Michał Pierzchała d8a7d03faf refactor: route application lifecycle through runtime facts (#1759)
* refactor: route application lifecycle through runtime facts

Moves the canonical `open`, `prepare`, `close` and internal `runtime` descriptors
behind package-owned lifecycle bindings admitted from device runtime facts, while
daemon request/session policy and public response construction stay put.

Based on main, which already carries the boot unit, the parametrized cutover gate
and the apps unit. Readiness is package-owned there, so the Apple and Android
bindings call ensureAppleReady/ensureAndroidReady rather than a root readiness
bag; ensureAppleReady gained an onColdBootStart hook so open keeps warming the
runner cache in parallel with a cold boot, and a narrow markBooted port publishes
readiness' fresh observation so a flow still makes one simctl listing.

Cutover rows take R24-R27, clear of the accepted catalog and the sibling install
stack, and cutoverTableDefects rejects a duplicate rule id.

Two defects this unit introduced are fixed here rather than shipped:
`open <app> <url>` dropped the URL on a first open, and test-IME activation was
first fatal on an unobtainable helper and then over-caught. Helper unavailability
is a typed non-activation outcome now; fence, lock and post-record failures
propagate.

The duplication the unit had accumulated is gone: one runtime-admission module
instead of five per-command copies, one direct-lifecycle binding factory instead
of six hand-rolled packages, one transport-hint predicate, one session
finalization path, and no identity-wrapper module.

* fix: allocate lifecycle cutover rows after deployment

* chore: preserve lifecycle union reconstruction

* fix: reconcile lifecycle runtime stack

* refactor: tighten lifecycle runtime topology

* refactor: remove superseded runtime adapters

* fix: preserve stacked runtime cutovers

* test: preserve migrated runtime ownership

* test: move Android deployment retry ownership

* test: extract runtime hint fixtures

* fix: preserve lifecycle stack invariants

* fix: complete lifecycle runtime cutover

* fix: remove lifecycle cutover residue
2026-08-16 15:13:10 +02:00

276 lines
9.7 KiB
TypeScript

import type {
ProviderDeviceInventorySource,
DeviceLease,
LeaseLifecycleContext,
LeaseLifecycleProvider,
ProviderDeviceRuntime,
ProviderExpiredLeaseRecovery,
} from '@agent-device/contracts/device';
import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction';
import type {
CloudArtifactProvider,
CloudArtifactsQuery,
CloudArtifactsResult,
} from '@agent-device/contracts/observability';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import { AsyncLocalStorage } from 'node:async_hooks';
import type {
AppleRunnerProviderResolver,
AppleRunnerScreenRecordingTransportResolver,
} from './daemon/request-platform-providers.ts';
import type { AppleRunnerScreenRecordingTransport } from './platform-runtime-screen-recording-apple-runner-transport.ts';
import type {
AppleRunnerCommandExecutor,
AppleRunnerProvider,
} from './platforms/apple/core/runner/runner-provider.ts';
type AppleRunnerRuntimeExtension = ProviderDeviceRuntime & {
getAppleRunnerProvider(
device: DeviceInfo,
): AppleRunnerProvider | AppleRunnerCommandExecutor | undefined;
};
type AppleRunnerScreenRecordingRuntimeExtension = ProviderDeviceRuntime & {
getAppleRunnerScreenRecordingTransport(
device: DeviceInfo,
): AppleRunnerScreenRecordingTransport | undefined;
};
export type ProviderDeviceRuntimeRequestProviders = {
/** Eager provider ownership metadata for the platform-runtime composition boundary. */
providerRuntimes: readonly ProviderDeviceRuntime[];
providerRuntimeIds: readonly string[];
providerRuntimeRequiredIds: readonly string[];
recoverableProviderIds: readonly string[];
leaseLifecycleProvider?: LeaseLifecycleProvider;
recoverExpiredLease?: ProviderExpiredLeaseRecovery;
cloudArtifactProvider?: CloudArtifactProvider;
deviceInventorySource?: ProviderDeviceInventorySource;
appleRunnerProvider?: AppleRunnerProviderResolver;
appleRunnerScreenRecordingTransport?: AppleRunnerScreenRecordingTransportResolver;
providerDeviceRuntimeScope?: <T>(task: () => Promise<T>) => Promise<T>;
};
let activeProviderDeviceRuntimes: ProviderDeviceRuntime[] = [];
const providerDeviceRuntimeScope = new AsyncLocalStorage<ProviderDeviceRuntime[]>();
/**
* @internal Test isolation hook for the active provider runtime scope.
*/
export function setActiveProviderDeviceRuntimes(runtimes: ProviderDeviceRuntime[]): void {
activeProviderDeviceRuntimes = [...runtimes];
}
async function withProviderDeviceRuntimeScope<T>(
runtimes: ProviderDeviceRuntime[],
task: () => Promise<T>,
): Promise<T> {
return await providerDeviceRuntimeScope.run([...runtimes], task);
}
export function getProviderDeviceInteractor(
device: DeviceInfo,
runnerContext?: RunnerContext,
): Interactor | undefined {
for (const runtime of getActiveProviderDeviceRuntimes()) {
if (!runtime.ownsDevice(device)) continue;
const interactor = runtime.getInteractor(device, runnerContext);
if (interactor) return interactor;
}
return undefined;
}
export function isActiveProviderDevice(device: DeviceInfo): boolean {
return getActiveProviderDeviceRuntimes().some((runtime) => runtime.ownsDevice(device));
}
function getActiveProviderDeviceRuntimes(): ProviderDeviceRuntime[] {
return providerDeviceRuntimeScope.getStore() ?? activeProviderDeviceRuntimes;
}
export function createProviderDeviceRuntimeRequestProviders(
runtimes: ProviderDeviceRuntime[],
options: { providerRuntimeRequiredIds?: readonly string[] } = {},
): ProviderDeviceRuntimeRequestProviders {
assertUniqueProviderRuntimeIds(runtimes);
const providerRuntimeIds = runtimes.map((runtime) => runtime.provider);
return {
providerRuntimes: Object.freeze([...runtimes]),
providerRuntimeIds,
providerRuntimeRequiredIds: uniqueProviderIds([
...providerRuntimeIds,
...(options.providerRuntimeRequiredIds ?? []),
]),
leaseLifecycleProvider: composeLeaseProvider(runtimes),
recoverableProviderIds: runtimes
.filter((runtime) => runtime.recoverExpiredLease !== undefined)
.map((runtime) => runtime.provider),
recoverExpiredLease: composeExpiredLeaseRecovery(runtimes),
cloudArtifactProvider: composeCloudArtifactProvider(runtimes),
deviceInventorySource: composeDeviceInventorySource(runtimes),
appleRunnerProvider: composeAppleRunnerProviderResolver(runtimes),
appleRunnerScreenRecordingTransport:
composeAppleRunnerScreenRecordingTransportResolver(runtimes),
providerDeviceRuntimeScope: async (task) =>
await withProviderDeviceRuntimeScope(runtimes, task),
};
}
function composeAppleRunnerScreenRecordingTransportResolver(
runtimes: ProviderDeviceRuntime[],
): AppleRunnerScreenRecordingTransportResolver | undefined {
if (!runtimes.some(hasAppleRunnerScreenRecordingTransport)) return undefined;
return (context) => {
for (const runtime of runtimes) {
if (!hasAppleRunnerScreenRecordingTransport(runtime) || !runtime.ownsDevice(context.device)) {
continue;
}
const transport = runtime.getAppleRunnerScreenRecordingTransport(context.device);
if (transport) return transport;
}
return undefined;
};
}
function composeAppleRunnerProviderResolver(
runtimes: ProviderDeviceRuntime[],
): AppleRunnerProviderResolver | undefined {
if (!runtimes.some(hasAppleRunnerProvider)) return undefined;
return (context) => {
for (const runtime of runtimes) {
if (!hasAppleRunnerProvider(runtime) || !runtime.ownsDevice(context.device)) continue;
const provider = runtime.getAppleRunnerProvider(context.device);
if (provider) return provider;
}
return undefined;
};
}
function hasAppleRunnerProvider(
runtime: ProviderDeviceRuntime,
): runtime is AppleRunnerRuntimeExtension {
return (
'getAppleRunnerProvider' in runtime && typeof runtime.getAppleRunnerProvider === 'function'
);
}
function hasAppleRunnerScreenRecordingTransport(
runtime: ProviderDeviceRuntime,
): runtime is AppleRunnerScreenRecordingRuntimeExtension {
return (
'getAppleRunnerScreenRecordingTransport' in runtime &&
typeof runtime.getAppleRunnerScreenRecordingTransport === 'function'
);
}
function composeExpiredLeaseRecovery(
runtimes: ProviderDeviceRuntime[],
): ProviderExpiredLeaseRecovery | undefined {
if (!runtimes.some((runtime) => runtime.recoverExpiredLease !== undefined)) return undefined;
return async (lease) => {
const runtime = runtimes.find((candidate) =>
runtimeMatchesProvider(candidate, lease.leaseProvider),
);
if (!runtime?.recoverExpiredLease) {
throw new AppError(
'UNSUPPORTED_OPERATION',
`Provider ${lease.leaseProvider ?? 'unknown'} cannot recover an expired lease.`,
{ provider: lease.leaseProvider, leaseId: lease.leaseId },
);
}
await runtime.recoverExpiredLease(lease);
};
}
function uniqueProviderIds(providerIds: readonly string[]): string[] {
return [...new Set(providerIds)];
}
function composeLeaseProvider(
runtimes: ProviderDeviceRuntime[],
): LeaseLifecycleProvider | undefined {
if (runtimes.length === 0) return undefined;
return {
allocate: async (lease, context) =>
await firstProviderResult(runtimes, 'allocate', lease, context),
heartbeat: async (lease, context) =>
await firstProviderResult(runtimes, 'heartbeat', lease, context),
release: async (lease, context) =>
await firstProviderResult(runtimes, 'release', lease, context),
};
}
function composeCloudArtifactProvider(
runtimes: ProviderDeviceRuntime[],
): CloudArtifactProvider | undefined {
if (runtimes.length === 0) return undefined;
return {
listCloudArtifacts: async (query) => await firstCloudArtifactsResult(runtimes, query),
};
}
function composeDeviceInventorySource(
runtimes: ProviderDeviceRuntime[],
): ProviderDeviceInventorySource | undefined {
if (runtimes.length === 0) return undefined;
return {
discover: async (request, signal) => {
signal.throwIfAborted();
for (const runtime of runtimes) {
if (!runtimeMatchesProvider(runtime, request.leaseProvider)) continue;
const devices = await runtime.deviceInventoryProvider(request, signal);
signal.throwIfAborted();
if (devices !== null && devices !== undefined) {
return { kind: 'inventory', devices };
}
}
return { kind: 'declined' };
},
};
}
function assertUniqueProviderRuntimeIds(runtimes: readonly ProviderDeviceRuntime[]): void {
const seen = new Set<string>();
for (const runtime of runtimes) {
if (seen.has(runtime.provider)) {
throw new TypeError(`Duplicate provider device runtime: ${runtime.provider}`);
}
seen.add(runtime.provider);
}
}
async function firstCloudArtifactsResult(
runtimes: ProviderDeviceRuntime[],
query: CloudArtifactsQuery,
): Promise<CloudArtifactsResult | undefined> {
for (const runtime of runtimes) {
if (!runtimeMatchesProvider(runtime, query.provider)) continue;
const result = await runtime.cloudArtifacts?.listCloudArtifacts?.(query);
if (result) return result;
}
return undefined;
}
async function firstProviderResult(
runtimes: ProviderDeviceRuntime[],
method: keyof LeaseLifecycleProvider,
lease: DeviceLease,
context?: LeaseLifecycleContext,
): Promise<Record<string, unknown> | undefined> {
for (const runtime of runtimes) {
if (!runtimeMatchesProvider(runtime, lease.leaseProvider)) continue;
const handler = runtime.leaseLifecycle[method];
const result = handler ? await handler(lease, context) : undefined;
if (result) return result;
}
return undefined;
}
function runtimeMatchesProvider(
runtime: ProviderDeviceRuntime,
provider: string | undefined,
): boolean {
return runtime.provider === provider;
}