Files
callstack__agent-device/scripts/layering/runtime-command-cutover-lifecycle.ts
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

267 lines
8.9 KiB
TypeScript

import { parseSync } from 'oxc-parser';
import { visitAst } from './cutover-policy-ast.ts';
import { retiredDispatchProjectionViolations } from './runtime-command-cutover-descriptor.ts';
import {
callName,
countNamedCalls,
lineOf,
namedFunction,
type CutoverAstNode,
} from './runtime-command-cutover-ast.ts';
import type { UnruledViolation } from './runtime-command-cutover-model.ts';
const RUNTIME_ADMISSION_MODULE = 'src/daemon/runtime-admission.ts';
const APPLICATION_RESOURCES_FILE = 'src/platform-runtime-application-resources.ts';
const RUNTIME_ADMISSION_HELPERS = ['admitRuntimeUse', 'admitRuntimeOperations'];
type LifecycleAdmissionRule = Readonly<{ functionName: string; file?: string }>;
type LifecycleHandlerRule = Readonly<{
command: 'open' | 'prepare' | 'close' | 'runtime';
file: string;
admissions: readonly LifecycleAdmissionRule[];
forbiddenCalls: readonly string[];
}>;
const RAW_RUNTIME_GATEWAY_CALLS = ['inspectFacts', 'bindDevice'];
const LIFECYCLE_HANDLER_RULES = {
open: {
command: 'open',
file: 'src/daemon/handlers/session-open.ts',
admissions: [{ functionName: 'admitOpenRuntime' }],
forbiddenCalls: [
...RAW_RUNTIME_GATEWAY_CALLS,
'dispatchCommand',
'resolveSoleForegroundIosApp',
'runAndroidAdb',
'runXcrun',
'shutdownDeviceTarget',
],
},
prepare: {
command: 'prepare',
file: 'src/daemon/handlers/session-prepare.ts',
admissions: [{ functionName: 'admitPrepareRuntime' }],
forbiddenCalls: [
...RAW_RUNTIME_GATEWAY_CALLS,
'dispatchCommand',
'prepareIosRunner',
'runXcrun',
'shutdownDeviceTarget',
],
},
close: {
command: 'close',
file: 'src/daemon/handlers/session-close-runtime-admission.ts',
admissions: [{ functionName: 'admitCloseRuntime' }],
forbiddenCalls: [
...RAW_RUNTIME_GATEWAY_CALLS,
'dispatchCommand',
'shutdownDeviceTarget',
'runAndroidAdb',
'runXcrun',
],
},
runtime: {
command: 'runtime',
file: 'src/daemon/handlers/session-runtime-command.ts',
admissions: [
{ functionName: 'admitClearRuntime' },
{
functionName: 'handlePortReverseCommand',
file: 'src/daemon/handlers/session-runtime-port-reverse.ts',
},
],
forbiddenCalls: [
...RAW_RUNTIME_GATEWAY_CALLS,
'dispatchCommand',
'shutdownDeviceTarget',
'runAndroidAdb',
'runXcrun',
],
},
} satisfies Record<'open' | 'prepare' | 'close' | 'runtime', LifecycleHandlerRule>;
export const openLifecycleRouteBindingViolations = (sources: ReadonlyMap<string, string>) =>
lifecycleHandlerRouteBindingViolations(sources, LIFECYCLE_HANDLER_RULES.open);
export const prepareLifecycleRouteBindingViolations = (sources: ReadonlyMap<string, string>) =>
lifecycleHandlerRouteBindingViolations(sources, LIFECYCLE_HANDLER_RULES.prepare);
export const closeLifecycleRouteBindingViolations = (sources: ReadonlyMap<string, string>) => [
...lifecycleHandlerRouteBindingViolations(sources, LIFECYCLE_HANDLER_RULES.close),
...retiredDispatchProjectionViolations(sources, 'close'),
];
export const runtimeLifecycleRouteBindingViolations = (sources: ReadonlyMap<string, string>) =>
lifecycleHandlerRouteBindingViolations(sources, LIFECYCLE_HANDLER_RULES.runtime);
function lifecycleHandlerRouteBindingViolations(
sources: ReadonlyMap<string, string>,
rule: LifecycleHandlerRule,
): UnruledViolation[] {
const source = sources.get(rule.file);
if (source === undefined) {
return [
{
file: rule.file,
line: 1,
message: `${rule.command} lifecycle handler module is missing`,
},
];
}
const violations: UnruledViolation[] = [];
for (const admission of rule.admissions) {
const admissionFile = admission.file ?? rule.file;
const admissionSource = sources.get(admissionFile);
if (admissionSource === undefined) {
violations.push({
file: admissionFile,
line: 1,
message: `${rule.command} lifecycle handler module is missing`,
});
continue;
}
const program = parseSync(admissionFile, admissionSource).program as CutoverAstNode;
const functionNode = namedFunction(program, admission.functionName);
if (functionNode === undefined) {
violations.push({
file: admissionFile,
line: 1,
message: `${rule.command} must retain its ${admission.functionName} admission seam`,
});
continue;
}
const admissionCalls = RUNTIME_ADMISSION_HELPERS.reduce(
(total, helper) => total + countNamedCalls(functionNode, helper),
0,
);
if (admissionCalls !== 1) {
violations.push({
file: admissionFile,
line: lineOf(admissionSource, functionNode),
message: `${rule.command} ${admission.functionName} must make one shared runtime admission call (found ${admissionCalls})`,
});
}
}
const routeFiles = new Set([rule.file, ...rule.admissions.flatMap(({ file }) => file ?? [])]);
for (const routeFile of routeFiles) {
const routeSource = sources.get(routeFile);
if (routeSource === undefined) continue;
const program = parseSync(routeFile, routeSource).program as CutoverAstNode;
visitAst(program, (node) => {
const call = callName(node);
if (call === undefined || !rule.forbiddenCalls.includes(call)) return;
violations.push({
file: routeFile,
line: lineOf(routeSource, node),
message: `${rule.command} handler reaches legacy or local platform call ${call} outside its bound runtime`,
});
});
}
violations.push(...sharedRuntimeAdmissionViolations(sources));
return violations;
}
function sharedRuntimeAdmissionViolations(
sources: ReadonlyMap<string, string>,
): UnruledViolation[] {
const source = sources.get(RUNTIME_ADMISSION_MODULE);
if (source === undefined) {
return [
{
file: RUNTIME_ADMISSION_MODULE,
line: 1,
message: 'shared runtime admission module is missing',
},
];
}
const program = parseSync(RUNTIME_ADMISSION_MODULE, source).program as CutoverAstNode;
const admit = namedFunction(program, 'admitRuntimeOperations');
if (admit === undefined) {
return [
{
file: RUNTIME_ADMISSION_MODULE,
line: 1,
message: 'shared runtime admission must expose admitRuntimeOperations',
},
];
}
const violations: UnruledViolation[] = [];
for (const [helper, role] of [
['requireFactsInspection', 'facts inspection'],
['requireDeviceBinding', 'binding'],
] as const) {
const calls = countNamedCalls(admit, helper);
if (calls !== 1) {
violations.push({
file: RUNTIME_ADMISSION_MODULE,
line: lineOf(source, admit),
message: `shared runtime admission must make one ${role} call (found ${calls})`,
});
}
}
return violations;
}
/** Durable lifecycle evidence shared by the four descriptor rows. */
export function applicationLifecycleDurableResourceViolations(
sources: ReadonlyMap<string, string>,
): UnruledViolation[] {
const violations: UnruledViolation[] = [];
const gateway = sources.get('src/platform-runtime-gateway.ts');
if (
gateway === undefined ||
!gateway.includes('runStartupRecoveryFence') ||
gateway.includes('androidApplications') ||
gateway.includes('appleApplications')
) {
violations.push({
file: 'src/platform-runtime-gateway.ts',
line: 1,
message:
'the composed runtime gateway must register the startup fence and name no platform-specific durable owner',
});
}
const resources = sources.get(APPLICATION_RESOURCES_FILE);
if (
resources === undefined ||
resources.indexOf('hasTestImeRecoveryEvidence') < 0 ||
resources.indexOf('hasTestImeRecoveryEvidence') > resources.indexOf('recoverTestImeStartup')
) {
violations.push({
file: APPLICATION_RESOURCES_FILE,
line: 1,
message:
'durable startup recovery must gate lazy Android test-IME recovery behind its marker evidence',
});
}
const ime = sources.get('src/platforms/android/ime-lifecycle.ts');
if (
ime === undefined ||
ime.indexOf('await waitForStartupRecoveryFence') < 0 ||
ime.indexOf('await waitForStartupRecoveryFence') >
ime.indexOf('const adb = resolveAndroidAdbExecutor')
) {
violations.push({
file: 'src/platforms/android/ime-lifecycle.ts',
line: 1,
message:
'Android test-IME mutation must wait for startup recovery before loading ADB mechanics',
});
}
const captureKitUnavailable = sources.get(
'packages/capture-kit/src/platform-runtime-unavailable.ts',
);
const captureKitIndex = sources.get('packages/capture-kit/src/index.ts');
if (
captureKitUnavailable !== undefined ||
captureKitIndex?.includes('createUnavailablePlatformRuntime') === true
) {
violations.push({
file: 'packages/capture-kit/src/index.ts',
line: 1,
message: 'capture-kit must not own a generic platform-runtime lifecycle role',
});
}
return violations;
}