Files
Michał Pierzchała 0c8227e9b7 refactor(runtime): let platform runtimes list apps and read app state directly (#2295)
* refactor(runtime): let platform runtimes list apps and read app state directly

The root host carried two adapters, appInventory and appState, that only
forwarded a platform call back into that platform's own package. Each platform
runtime now performs its own listApps and appState call through a lazy import
inside its package, keeping the deferred load, the AbortSignal threading, and
the package/bundleId -> id rename. PlatformRuntimeHost loses both keys, so
Android, Apple and Harmony fixtures no longer stub the two platforms they do
not own.

Android is the one platform runtime whose package now reaches adb directly.
The adb host that adb mechanics require is bound by a module side effect that
only the root can perform, so the Android runtime-module registration binds it
before the module loads. loadAndroidMechanics keeps its own binding import for
the root host ports that reach mechanics without binding a runtime; neither
binder subsumes the other.

Android appstate now runs one foreground-focus loop instead of two. The host
shaped readAndroidAppState/AndroidAppStateHost pair is gone: limrun's adapter
already closes over its own adb executor, so it calls the executor variant
directly, and that variant took the per-attempt abort check the host variant
had. AppStateRuntimeCommand and AppStateRuntimeCommandResult described the
deleted host port and go with it.

Tests: the new ordering test in
src/platform-runtime-android-adb-binding.test.ts was seen red by deleting the
binding import from that registration (order came back
["android-runtime", "adb-host"]); the composed-gateway listApps test in the
same file was seen red by reverting the Android runtime's inlined listApps to a
host.appInventory lookup (TypeError reading 'android'); the new abort test in
packages/platform-android/src/app-state.test.ts was seen red by removing both
signal?.throwIfAborted() calls from readAndroidFocusWithExecutor (the second
dumpsys was issued and the call resolved). All green after.

* chore(gates): drop the retired app-inventory/app-state host allowances

The two PLATFORM_RUNTIME_HOST_FILES rows point at host files this change
deletes, and the ./platform-runtime-app-state-host.ts composition allowance has
no importer left.

* refactor(runtime): construct the Android runtime module with its adb host binding

The Android runtime now calls adb from inside its package for listApps and
appState, which needs the process-wide adb host port bound. That dependency
was hidden in a registry wrapper doing a side-effect import, with a paragraph
explaining why it and loadAndroidMechanics did not subsume each other and an
import-order test pinning the ordering. The package now declares the
dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding
before the runtime loads, and the composition root supplies the one binding
implementation (evaluating its adb host module). The wrapper, the paragraph
and the import-order test are gone; the routed listApps test stays and a
routed appState test joins it.
2026-09-05 22:40:42 +02:00

117 lines
4.7 KiB
TypeScript

// Catches: src/platform-runtime.ts, the one canonical composition root, wiring a platform
// package's implementation eagerly instead of through the lazy provider-composition seam —
// a startup-cost regression (every platform's code loading on every process start) that only
// shows up as a perf number, not a type error.
// Evidence: 03f0f408c2 (#2070) moved platform provider composition out of the daemon into this
// root; c7f42ccedc (#2117) moved the Android family behind package exports the composition
// file now targets.
// Cost: 103 LOC (no dedicated test file; exercised through platform-package-policy.test.ts);
// shares rule id R13 with platform-package-policy.ts (1030 LOC) and
// platform-package-source-policy.ts (230 LOC).
// Kill criterion: none enforced today; retire only by maintainer decision that lazy platform
// composition in src/platform-runtime.ts no longer matters. The build cannot tell an eager
// import from the lazy seam; both resolve and type-check identically.
import { parseSync } from 'oxc-parser';
import { parseImports, type LayeringViolation } from './model.ts';
const COMPOSITION_FILE = 'src/platform-runtime.ts';
const RULE = 'R13 platform-package-substrate';
function violation(line: number, message: string): LayeringViolation {
return { rule: RULE, file: COMPOSITION_FILE, line, message };
}
function lineOf(source: string, offset: number): number {
return source.slice(0, offset).split('\n').length;
}
function memberPropertyName(node: unknown): string | undefined {
if (node === null || typeof node !== 'object') return undefined;
const record = node as Record<string, unknown>;
if (record['type'] === 'ChainExpression') return memberPropertyName(record['expression']);
if (record['type'] !== 'MemberExpression') return undefined;
const property = record['property'] as Record<string, unknown> | undefined;
if (property?.['type'] === 'Identifier') return property['name'] as string | undefined;
const value = property?.['value'];
return typeof value === 'string' ? value : undefined;
}
function isFunction(node: Record<string, unknown>): boolean {
return (
node['type'] === 'FunctionDeclaration' ||
node['type'] === 'FunctionExpression' ||
node['type'] === 'ArrowFunctionExpression'
);
}
function eagerImplementationLoaderSites(source: string): number[] {
const sites: number[] = [];
const visit = (node: unknown): void => {
if (node === null || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const child of node) visit(child);
return;
}
const record = node as Record<string, unknown>;
if (isFunction(record)) return;
if (
record['type'] === 'CallExpression' &&
['loadInventory', 'loadRuntime'].includes(memberPropertyName(record['callee']) ?? '')
) {
sites.push(lineOf(source, (record['start'] as number | undefined) ?? 0));
return;
}
for (const value of Object.values(record)) visit(value);
};
visit(parseSync(COMPOSITION_FILE, source).program);
return sites;
}
function isAllowedCompositionImport(specifier: string): boolean {
return (
/^@agent-device\/contracts(?:\/|$)/.test(specifier) ||
/^@agent-device\/platform-[^/]+$/.test(specifier) ||
specifier === './platform-runtime-gateway.ts' ||
specifier === './platform-runtime-android-adb-host.ts' ||
specifier === './platform-runtime-android-observation-host.ts' ||
specifier === './platform-runtime-operation-host.ts' ||
specifier === './platform-runtime-device-inventory.ts' ||
specifier === './platform-runtime-host.ts' ||
specifier === './platform-runtime/request-providers.ts' ||
specifier.startsWith('./platform-runtime-host/')
);
}
export function checkPlatformComposition(source: string | undefined): LayeringViolation[] {
if (source === undefined) {
return [violation(1, 'the exact platform composition root is missing')];
}
const violations: LayeringViolation[] = [];
for (const site of parseImports(source)) {
if (!isAllowedCompositionImport(site.spec)) {
violations.push(
violation(
site.line,
`composition imports only runtime contracts, host-capability adapters, and concrete platform facades; found '${site.spec}'`,
),
);
}
if (/^@agent-device\/platform-/.test(site.spec) && (site.dynamic || site.typeOnly)) {
violations.push(
violation(site.line, 'platform inventory modules must be statically composed'),
);
}
}
for (const line of eagerImplementationLoaderSites(source)) {
violations.push(
violation(
line,
'composition may not invoke a platform implementation loader before selected use',
),
);
}
return violations;
}