Files
callstack__agent-device/scripts/ios-ax-bridge-spike/run.ts
T
Michał Pierzchała 3f0a4c7a89 perf(ios): reject public AX and reopen guest bridge evaluation (#2209)
* perf(ios): add bounded Simulator AX bridge spike

* fix(ios): use snapshot planning subpath in AX spike

* docs(ios): record AX bridge spike NO-GO evidence

* docs(ios): add fixture stop diagnostics

* docs(ios): refresh prerequisite provenance

* docs(ios): record current prerequisite head

* docs(ios): record current prerequisite head

* ci: refresh exact-head pull request checks

* fix(ios-ax-bridge): preserve raw control node types

* docs(ios): refresh prerequisite provenance

* docs(ios): refresh prerequisite provenance

* fix: make iOS AX spike evidence executable

* fix: scope Simulator AX evidence to device content

* perf: compact AX spike evidence payloads

* perf: keep AX spike reports compact

* fix: keep AX evidence claims literal

* docs: replace blocked AX spike with live evidence

* chore: preserve raw AX evidence format

* fix: make simulator AX spike evidence decisive

* docs: record clean simulator AX spike run

* fix: close AX spike measurement gaps

* fix: initialize preference evidence owner

* fix: distinguish transient AX probe readiness

* docs: refresh review-corrected AX evidence

* fix: preserve preference failure evidence

* fix: close final AX evidence edge cases

* docs: publish final simulator AX decision evidence

* fix(ios): keep AX spike out of npm package

* docs(ios): correct AX bridge viability verdict
2026-09-01 21:39:29 +02:00

319 lines
10 KiB
TypeScript

import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
createPrivateCoreSimulatorAxAdapter,
createPublicMacOsAxAdapter,
createXCTestControlAdapter,
} from './adapter.ts';
import { parseConfig, type SpikeConfig } from './config.ts';
import { decideSpike } from './decision.ts';
import { runLifecycleProbes } from './lifecycle.ts';
import { markdownPath, writeSpikeReport } from './report.ts';
import { corpusCoverage } from './corpus-coverage.ts';
import {
initialPreferenceEvidence,
primeFixtureApps,
runPreferenceExperiment,
} from './preference-experiment.ts';
import { createAdapterOptions, runSpikeCells } from './runner.ts';
import { readGitRevision, readTarget, readToolchain } from '../ios-snapshot-benchmark/host.ts';
import { runDeepButtonControls } from '../ios-snapshot-benchmark/deep-control.ts';
import { deepButtonFixtureEvidence } from '../ios-snapshot-benchmark/deep-button.ts';
import { bootSimulator, shutdownSimulator } from '../ios-snapshot-benchmark/lifecycle.ts';
import type { AcquisitionAdapter } from './adapter.ts';
import { SPIKE_ISSUE, SPIKE_PARENT, SPIKE_PREREQUISITES, SPIKE_SCHEMA_VERSION } from './types.ts';
import type {
PreferenceEvidence,
ProtocolProbeLog,
SpikeReport,
SpikeRequest,
SpikeResponse,
Toolchain,
} from './types.ts';
if (isMainModule()) void main(process.argv.slice(2)).catch(reportFailure);
function reportFailure(error: unknown): void {
process.stderr.write(
`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exitCode = 1;
}
async function main(argv: readonly string[]): Promise<void> {
const config = parseConfig(argv);
const metadata = readMetadata(config);
const runConfig = { ...config, targetWindowName: metadata.target.name };
const lifecycle = await runLifecycleProbes();
const evidence = await executeSpikeRun(runConfig);
const decision = decideSpike(
evidence.cells,
lifecycle,
evidence.preferenceEvidence,
config.limits,
evidence.status,
evidence.protocolProbes,
);
const report = createReport(runConfig, metadata, lifecycle, evidence, decision);
writeSpikeReport(config.outputPath, report);
process.stdout.write(
`Decision: ${report.decision}\nRaw: ${config.outputPath}\nMarkdown: ${markdownPath(config.outputPath)}\n`,
);
if (evidence.status === 'stopped') process.exitCode = 2;
}
type SpikeRunEvidence = Readonly<{
status: SpikeReport['status'];
stop?: SpikeReport['stop'];
cells: Awaited<ReturnType<typeof runSpikeCells>>;
protocolProbes: SpikeResponse[];
protocolProbeLogs: SpikeReport['protocolProbeLogs'];
preferenceEvidence: PreferenceEvidence;
positiveControl: SpikeReport['positiveControl'];
}>;
async function executeSpikeRun(config: SpikeConfig): Promise<SpikeRunEvidence> {
return collectSpikeEvidence(config);
}
type CollectedSpikeEvidence = SpikeRunEvidence;
async function collectSpikeEvidence(config: SpikeConfig): Promise<CollectedSpikeEvidence> {
let preferenceEvidence = initialPreferenceEvidence(config.udid);
let cells: Awaited<ReturnType<typeof runSpikeCells>> = [];
let protocolProbes: SpikeResponse[] = [];
let protocolProbeLogs: SpikeReport['protocolProbeLogs'] = [];
let positiveControl = deepButtonFixtureEvidence();
try {
positiveControl = runDeepButtonControls(config.repoRoot);
preferenceEvidence = runPreferenceExperiment(config);
assertPreferenceRestored(config, preferenceEvidence);
const adapters = createAdapters(config);
bootSimulator(config.udid);
primeFixtureApps(config);
const probes = await runProtocolProbes(config, adapters);
protocolProbes = probes.responses;
protocolProbeLogs = probes.logs;
cells = await runSpikeCells(config, supportedAdapters(adapters, protocolProbes));
return collectedEvidence(
'completed',
cells,
protocolProbes,
protocolProbeLogs,
preferenceEvidence,
positiveControl,
);
} catch (error) {
return {
...collectedEvidence(
'stopped',
cells,
protocolProbes,
protocolProbeLogs,
preferenceEvidence,
positiveControl,
),
stop: stopForError(error),
};
} finally {
cleanupDevice(config);
}
}
function collectedEvidence(
status: SpikeReport['status'],
cells: SpikeReport['cells'],
protocolProbes: SpikeResponse[],
protocolProbeLogs: SpikeReport['protocolProbeLogs'],
preferenceEvidence: PreferenceEvidence,
positiveControl: SpikeReport['positiveControl'],
): CollectedSpikeEvidence {
return {
status,
cells,
protocolProbes,
protocolProbeLogs,
preferenceEvidence,
positiveControl,
};
}
function assertPreferenceRestored(config: SpikeConfig, evidence: PreferenceEvidence): void {
if (config.applyPreferences && !evidence.restored) {
throw new Error('The task-owned Simulator preference experiment was not restored.');
}
}
function cleanupDevice(config: SpikeConfig): void {
if (!config.keepDevice) shutdownSimulator(config.udid);
}
function stopForError(error: unknown): SpikeReport['stop'] {
return {
category: isConfigurationError(error) ? 'configuration' : 'infrastructure',
message: error instanceof Error ? error.message : String(error),
...(errorCommand(error) ? { command: errorCommand(error) } : {}),
};
}
function createReport(
config: SpikeConfig,
metadata: { target: SpikeReport['target']; toolchain: Toolchain },
lifecycle: SpikeReport['lifecycle'],
evidence: SpikeRunEvidence,
decision: { decision: SpikeReport['decision']; reasons: string[] },
): SpikeReport {
return {
schemaVersion: SPIKE_SCHEMA_VERSION,
issue: SPIKE_ISSUE,
parent: SPIKE_PARENT,
prerequisites: SPIKE_PREREQUISITES,
generatedAt: new Date().toISOString(),
revision: readGitRevision(config.repoRoot),
toolchain: metadata.toolchain,
target: metadata.target,
limits: config.limits,
candidates: config.candidates,
config: {
states: config.states,
screens: config.screens,
requestedSamples: config.samples,
},
protocolProbes: evidence.protocolProbes,
protocolProbeLogs: evidence.protocolProbeLogs,
preferenceEvidence: evidence.preferenceEvidence,
lifecycle,
positiveControl: evidence.positiveControl,
status: evidence.status,
corpusCoverage: corpusCoverage(
config.states,
config.screens,
evidence.cells,
config.candidates,
),
cells: evidence.cells,
decision: decision.decision,
decisionReasons: decision.reasons,
nextInterface:
'Keep any future bridge behind the #2190 acquisition adapter and preserve raw facts until a separate GO evidence run proves fidelity, lifecycle, and latency.',
...(evidence.stop ? { stop: evidence.stop } : {}),
};
}
function createAdapters(config: SpikeConfig) {
const options = createAdapterOptions(config);
const adapters = config.candidates.flatMap((candidate) => {
if (candidate === 'public-macos-ax') return [createPublicMacOsAxAdapter(options)];
if (candidate === 'private-coresimulator-ax')
return [createPrivateCoreSimulatorAxAdapter(options)];
return [
createXCTestControlAdapter((request) => ({
repoRoot: config.repoRoot,
stateDir: config.stateDir,
session: `ax-spike-xctest-control-${request.state ?? 'state'}-${request.screen}`,
udid: request.simulatorUdid,
derivedPath: path.join(config.derivedPath, 'xctest-control', request.screen),
})),
];
});
return adapters;
}
async function runProtocolProbes(
config: SpikeConfig,
adapters: readonly AcquisitionAdapter[],
): Promise<{ responses: SpikeResponse[]; logs: SpikeReport['protocolProbeLogs'] }> {
const responses: SpikeResponse[] = [];
const logs: ProtocolProbeLog[] = [];
for (const adapter of adapters.filter(isBridgeAdapter)) {
const request = protocolProbeRequest(config, adapter.candidate);
const result = await adapter.acquireBatch([request]);
responses.push(...result.responses.slice(0, 1));
logs.push({ candidate: adapter.candidate, id: request.id, stderr: result.stderr });
}
return { responses, logs };
}
function isBridgeAdapter(adapter: AcquisitionAdapter): adapter is AcquisitionAdapter & {
candidate: Exclude<SpikeRequest['candidate'], 'xctest-control'>;
} {
return adapter.candidate !== 'xctest-control';
}
function protocolProbeRequest(
config: SpikeConfig,
candidate: Exclude<SpikeRequest['candidate'], 'xctest-control'>,
): SpikeRequest {
return {
version: 1,
id: `protocol-probe:${candidate}`,
candidate,
simulatorUdid: config.udid,
state: 'warm',
screen: 'unprepared-surface',
appBundleId: config.appBundleId,
...(config.targetWindowName === undefined ? {} : { targetWindowName: config.targetWindowName }),
...(config.targetProcessId === undefined ? {} : { targetProcessId: config.targetProcessId }),
limits: config.limits,
};
}
function readMetadata(config: SpikeConfig): {
target: SpikeReport['target'];
toolchain: Toolchain;
} {
const target = readTarget(config.udid, 'com.callstack.agentdevicelab');
const base = readToolchain();
return {
target: { udid: target.udid, name: target.name, runtime: target.runtime },
toolchain: { ...base, swift: commandText('swift', ['--version']) },
};
}
function supportedAdapters(
adapters: readonly AcquisitionAdapter[],
probes: readonly SpikeResponse[],
): readonly AcquisitionAdapter[] {
return adapters.filter((adapter) => {
if (adapter.candidate === 'xctest-control') return true;
const probe = probes.find((candidate) => candidate.candidate === adapter.candidate);
return ![
'private-tool-unavailable',
'host-accessibility-permission',
'candidate-not-supported',
].includes(probe?.failure?.code ?? '');
});
}
function commandText(command: string, args: readonly string[]): string {
try {
return execFileSync(command, [...args], {
encoding: 'utf8',
timeout: 30_000,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return 'unavailable';
}
}
function isConfigurationError(error: unknown): boolean {
return error instanceof Error && error.name === 'SpikeConfigurationError';
}
function errorCommand(error: unknown): string | undefined {
if (error instanceof Error && 'command' in error) {
const command = error.command;
return typeof command === 'string' ? command : undefined;
}
return undefined;
}
function isMainModule(): boolean {
return Boolean(
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)),
);
}