Files
callstack__agent-device/src/provider-device-runtime.ts
T
Michał Pierzchała cd1551bf42 refactor: collapse public Platform ios/macos into apple (#979) (#1002)
* refactor: collapse public Platform ios/macos into apple (#979)

Phase 3 d.3: collapse the internal `Platform` union from `ios`/`macos` to a
single `apple` platform, with `appleOs` as the sole OS discriminant. Approach
(b) NON-BREAKING: the daemon still ACCEPTS the legacy `ios`/`macos` selectors on
every read path and still EMITS the leaf `ios`/`macos` strings on every output,
so machine consumers see no change.

Kernel (src/kernel/device.ts):
- PLATFORMS = ['apple','android','linux','web']; add PUBLIC_PLATFORMS (leaf) and
  PublicPlatform; PLATFORM_SELECTORS keeps legacy `ios`/`macos` as input aliases.
- New predicates: isMacOs (appleOs- or legacy-leaf-based), isIosFamily (the
  post-collapse equivalent of `platform === 'ios'`), publicPlatformString (output
  projection), deviceFieldsFromPublicPlatform (inverse), isPublicPlatform.
- isMobilePlatform and matchesPlatformSelector are now device-aware (appleOs).

Discovery now stamps `platform: 'apple'` (+ appleOs); ~125 internal
`device.platform === 'ios'|'macos'` branch sites migrated to the predicates,
behavior-preserving. Apple plugin owns `['apple']`; platformDescriptors collapse
to one `apple` row.

Output projection (approach b) emits the leaf via publicPlatformString at:
devices / session_list (session-inventory), boot / shutdown / appstate /
prepare-ios-runner (session-state, session), the selector/backend platform
(selector-runtime/screenshot-runtime/snapshot-runtime/interaction-runtime),
proxy device key, request-lock backfill, runtime-set binding, click-button
validation, and both `.ad` context-line writers.

Contracts/client keep leaf types (PublicPlatform); read paths (parsePlatform,
REPLAY_METADATA_PLATFORMS, matchesPlatformSelector) accept `apple` + legacy
leaves. Adds a parity test gate (platform-collapse-parity.test.ts).

Refs #979 (part of #972).

* fix: project platform to the public leaf at open/perf response sites (#979)

The Platform collapse left two daemon response builders emitting the raw
internal `device.platform` ('apple'), which the client normalizer rejects
(isPublicPlatform excludes 'apple') — dropping the resolved device from the
response:
- session-open-surface.ts: `open` result `platform`/device projection.
- session-perf.ts: the perf/frames/memory base response builders.
Both now go through `publicPlatformString(device)`, so output stays the leaf
`ios`/`macos` per approach (b). (The android/non-apple perf branches were
already leaf-safe.)

Also update macos-desktop provider test: the lifecycle mock observes the
INTERNAL DeviceInfo, which is now `platform:'apple'` (+ appleOs:'macos'), so the
recorded tag is `prepare:apple:desktop`.

Fixes the provider-integration assertions that blocked both the Integration
Tests and Coverage CI jobs (both run the provider-integration project).
Verified: provider-integration 82/82, coverage passes, tsc/oxlint/oxfmt/layering/
fallow green.

* fix: project platform to the public leaf at nested output sites (#979)

The Platform collapse (approach b) projects device.platform through
publicPlatformString at emit sites so machine consumers keep seeing the
leaf ios/macos and never the internal `apple`. Several nested output
fields were missed. Project them and narrow their emitted types to
PublicPlatform:

- Apple perf memory snapshot support (buildAppleMemorySnapshotSupport) —
  response.support.platform / artifact.support.platform, plus the
  sibling sampleAppleFramePerf error data.
- Apple xctrace perf capture/result platform surfaced in the perf
  cpu-profile started/stopped response data.
- snapshotDiagnostics.stats.platform (recordSnapshotTiming) surfaced in
  snapshot/test response data and the slow-snapshot warning string.
- doctor target-app evidence.platform + human summary, and doctor
  target-app-device evidence.booted[].platform.
- provider/cloud UNSUPPORTED_OPERATION error.data.platform for cloud
  Apple devices (reachable via deviceFieldsFromPublicPlatform).

Internal 'apple' emissions (selector-matching input, diagnostic
emitDiagnostic telemetry, session appLog state, replay .ad flags) are
left as-is. Adds focused tests pinning Apple perf memory support to the
leaf and a guard asserting no emitted platform field equals 'apple'.
2026-07-01 19:29:46 +02:00

269 lines
8.9 KiB
TypeScript

import { AsyncLocalStorage } from 'node:async_hooks';
import type {
CloudArtifactProvider,
CloudArtifactsQuery,
CloudArtifactsResult,
} from './cloud-artifacts.ts';
import type { Interactor } from './core/interactor-types.ts';
import type { DeviceInventoryProvider } from './core/dispatch-resolve.ts';
import type { LeaseLifecycleContext, LeaseLifecycleProvider } from './daemon/handlers/lease.ts';
import type { DeviceLease } from './daemon/lease-registry.ts';
import { publicPlatformString, type DeviceInfo } from './kernel/device.ts';
import { AppError } from './kernel/errors.ts';
export type ProviderDeviceInstallResult = {
bundleId?: string;
packageName?: string;
appName?: string;
launchTarget?: string;
};
export type ProviderDeviceInstallOptions = {
relaunch?: boolean;
appIdentifierHint?: string;
packageNameHint?: string;
};
export type ProviderDeviceRuntime = {
provider: string;
leaseLifecycle: LeaseLifecycleProvider;
cloudArtifacts?: CloudArtifactProvider;
deviceInventoryProvider: DeviceInventoryProvider;
ownsDevice(device: DeviceInfo): boolean;
getInteractor(device: DeviceInfo): Interactor | undefined;
installApp?(
device: DeviceInfo,
app: string,
appPath: string,
options?: ProviderDeviceInstallOptions,
): Promise<ProviderDeviceInstallResult | undefined>;
installInstallablePath?(
device: DeviceInfo,
installablePath: string,
options?: ProviderDeviceInstallOptions,
): Promise<ProviderDeviceInstallResult | undefined>;
configurePortReverse?(
options: ProviderPortReverseOptions,
): Promise<Record<string, unknown> | undefined>;
removePortReverse?(
options: ProviderPortReverseOptions,
): Promise<Record<string, unknown> | undefined>;
shutdown(): Promise<void>;
};
export type ProviderPortReverseOptions = {
leaseId: string;
provider?: string;
devicePort: number;
hostPort: number;
name: string;
};
export type ProviderDeviceRuntimeRequestProviders = {
leaseLifecycleProvider?: LeaseLifecycleProvider;
cloudArtifactProvider?: CloudArtifactProvider;
deviceInventoryProvider?: DeviceInventoryProvider;
providerDeviceRuntimeScope?: <T>(task: () => Promise<T>) => Promise<T>;
};
let activeProviderDeviceRuntimes: ProviderDeviceRuntime[] = [];
const providerDeviceRuntimeScope = new AsyncLocalStorage<ProviderDeviceRuntime[]>();
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): Interactor | undefined {
for (const runtime of getActiveProviderDeviceRuntimes()) {
if (!runtime.ownsDevice(device)) continue;
const interactor = runtime.getInteractor(device);
if (interactor) return interactor;
}
return undefined;
}
export function isActiveProviderDevice(device: DeviceInfo): boolean {
return getActiveProviderDeviceRuntimes().some((runtime) => runtime.ownsDevice(device));
}
export async function installProviderDeviceApp(
device: DeviceInfo,
app: string,
appPath: string,
options?: ProviderDeviceInstallOptions,
): Promise<ProviderDeviceInstallResult | undefined> {
for (const runtime of getActiveProviderDeviceRuntimes()) {
if (!runtime.ownsDevice(device)) continue;
if (!runtime.installApp) {
throw unsupportedProviderOperation(runtime, device, 'install');
}
const result = await runtime.installApp?.(device, app, appPath, options);
if (result) return result;
throw unsupportedProviderOperation(runtime, device, 'install');
}
return undefined;
}
export async function installProviderDeviceInstallablePath(
device: DeviceInfo,
installablePath: string,
options?: ProviderDeviceInstallOptions,
): Promise<ProviderDeviceInstallResult | undefined> {
for (const runtime of getActiveProviderDeviceRuntimes()) {
if (!runtime.ownsDevice(device)) continue;
if (!runtime.installInstallablePath) {
throw unsupportedProviderOperation(runtime, device, 'install_from_source');
}
const result = await runtime.installInstallablePath?.(device, installablePath, options);
if (result) return result;
throw unsupportedProviderOperation(runtime, device, 'install_from_source');
}
return undefined;
}
export async function configureProviderPortReverse(
options: ProviderPortReverseOptions,
): Promise<Record<string, unknown> | undefined> {
for (const runtime of getActiveProviderDeviceRuntimes()) {
if (!runtimeMatchesProvider(runtime, options.provider)) continue;
const result = await runtime.configurePortReverse?.(options);
if (result) return result;
}
return undefined;
}
export async function removeProviderPortReverse(
options: ProviderPortReverseOptions,
): Promise<Record<string, unknown> | undefined> {
for (const runtime of getActiveProviderDeviceRuntimes()) {
if (!runtimeMatchesProvider(runtime, options.provider)) continue;
const result = await runtime.removePortReverse?.(options);
if (result) return result;
}
return undefined;
}
function getActiveProviderDeviceRuntimes(): ProviderDeviceRuntime[] {
return providerDeviceRuntimeScope.getStore() ?? activeProviderDeviceRuntimes;
}
export function createProviderDeviceRuntimeRequestProviders(
runtimes: ProviderDeviceRuntime[],
): ProviderDeviceRuntimeRequestProviders {
return {
leaseLifecycleProvider: composeLeaseProvider(runtimes),
cloudArtifactProvider: composeCloudArtifactProvider(runtimes),
deviceInventoryProvider: composeDeviceInventoryProvider(runtimes),
providerDeviceRuntimeScope: async (task) =>
await withProviderDeviceRuntimeScope(runtimes, task),
};
}
export function composeCloudArtifactProviders(
...providers: Array<CloudArtifactProvider | undefined>
): CloudArtifactProvider | undefined {
const activeProviders = providers.filter(
(provider): provider is CloudArtifactProvider => provider !== undefined,
);
if (activeProviders.length === 0) return undefined;
return {
listCloudArtifacts: async (query) => {
for (const provider of activeProviders) {
const result = await provider.listCloudArtifacts?.(query);
if (result) return result;
}
return undefined;
},
};
}
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 composeDeviceInventoryProvider(
runtimes: ProviderDeviceRuntime[],
): DeviceInventoryProvider | undefined {
if (runtimes.length === 0) return undefined;
return async (request) => {
for (const runtime of runtimes) {
if (!runtimeMatchesProvider(runtime, request.leaseProvider)) continue;
const devices = await runtime.deviceInventoryProvider(request);
if (devices) return devices;
}
return null;
};
}
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;
}
function unsupportedProviderOperation(
runtime: ProviderDeviceRuntime,
device: DeviceInfo,
operation: string,
): never {
throw new AppError(
'UNSUPPORTED_OPERATION',
`Provider device runtime ${runtime.provider} does not support ${operation} for this device.`,
{ provider: runtime.provider, deviceId: device.id, platform: publicPlatformString(device) },
);
}