Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,13 @@ axis and stages a deeper platform-runtime seam, one abandonment-safe command cut
that earned ADR 0019's staged platform-runtime migration. Its substrate plus complete
`devices`/`logs`/`network` checkpoint must validate the seam before any further command migration.
These are ratchets, not permission to scaffold façades before a real seam has two adapters.
- Platform package boundary (R13). ADR 0019 has exactly six private
`@agent-device/platform-*` package façades and one root composition file,
`src/platform-runtime.ts`. R13 pins that total registration, forbids contracts-to-platform,
sibling-platform, root/daemon, and raw-process edges in every import form (including tests), and
keeps package façades metadata-eager but inventory/runtime mechanics lazy. Composition cannot probe tools, prepare
assets, or construct helpers. Each rule has a planted-red structural case; R11 still owns the
general workspace exports/dependency boundary.
- Zero-dep CI jobs (R8). Some jobs run scripts straight from a checkout with `install-deps: false`,
so they have no `node_modules`. Nothing local can feel that constraint — every dev machine has
`node_modules` sitting right there — so a script grows a package import, passes locally, and fails
Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ app-log disposal contract without changing `close`'s legacy platform-execution o
command's platform adapter must change, this Status must name its complete unit before substrate work
begins. Broader migration requires this Status to record **continue**.

During the `devices` unit, doctor discovery, replay-test sharding, Apple simulator hints, and Android
emulator lifecycle keep their existing command execution owners while consuming the same injected,
neutral inventory capability. That coexistence moves discovery mechanics once; it does not migrate
those descriptors or authorize a second local/provider chooser.

## Rules at a glance

- Daemon device-execution code depends on platform-neutral contracts. Concrete device mechanics
Expand Down
7 changes: 4 additions & 3 deletions docs/agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ hand-maintained path map:
left to CI by `--run` so a root checkout never installs Expo dependencies
implicitly.
- **Always-on gates** (`lint`, `typecheck`, `layering`, `fallow`, `format`) fire
for their input categories and are never silently skipped. Platform source
also selects the provider-integration and coverage gates required by the
Testing Matrix.
for their input categories and are never silently skipped. Legacy
`src/platforms/` source also selects provider-integration and coverage.
`packages/platform-*` source selects the shared runtime-contract unit lane,
provider-integration, and coverage so a package move cannot narrow its evidence.
- **Commands** are resolved from real `package.json` scripts, so a renamed
script fails loudly instead of dropping a gate.
- A **small explicit build-ownership layer** covers the paths whose owning build
Expand Down
5 changes: 0 additions & 5 deletions fallow-baselines/health.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,6 @@
"count": 1
}
},
"src/platforms/apple/core/devices.ts": {
"crap_moderate": {
"count": 1
}
},
"src/platforms/apple/core/perf.ts": {
"complexity_high": {
"count": 1
Expand Down
14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@
"fallow:baseline": "fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary && fallow health --report-only --save-baseline fallow-baselines/health.json --summary",
"check:fallow": "fallow audit",
"check:affected": "node --experimental-strip-types scripts/check-affected/run.ts",
"check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts",
"check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/run.test.ts",
"check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts",
"check:coverage-changed:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts",
"check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts",
"check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts",
"depgraph": "node --experimental-strip-types scripts/depgraph/build.ts",
"depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts",
"check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues",
Expand All @@ -149,7 +149,7 @@
"check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"test-app:install": "pnpm install --dir examples/test-app",
"test-app:start": "pnpm --dir examples/test-app start",
"test-app:ios": "pnpm --dir examples/test-app ios",
Expand All @@ -173,7 +173,7 @@
"test:output-economy": "vitest run --project output-economy",
"test:smoke:web": "pnpm build && node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-web-platform.test.ts",
"test:smoke": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-*.test.ts",
"test:integration:node": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/*.test.ts",
"test:integration:node": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test --test-concurrency=1 test/integration/*.test.ts",
"test:integration": "pnpm test:integration:node && pnpm test:integration:provider",
"test:concurrency-torture": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/nightly/concurrency-torture.test.ts",
"test:replay:ios": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator",
Expand Down Expand Up @@ -257,6 +257,12 @@
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*",
"@agent-device/maestro": "workspace:*",
"@agent-device/platform-android": "workspace:*",
"@agent-device/platform-apple": "workspace:*",
"@agent-device/platform-harmonyos": "workspace:*",
"@agent-device/platform-linux": "workspace:*",
"@agent-device/platform-vega": "workspace:*",
"@agent-device/platform-web": "workspace:*",
"@agent-device/provider-limrun": "workspace:*",
"@agent-device/provider-webdriver": "workspace:*",
"@agent-device/replay-test": "workspace:*",
Expand Down
16 changes: 16 additions & 0 deletions packages/contracts/src/android-adb-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { classifyAndroidAdbFailure } from './android-adb-failure.ts';

test('Android ADB failures keep transport on stderr and install verdicts on stdout', () => {
assert.equal(
classifyAndroidAdbFailure("adb server version (40) doesn't match this client (41); killing...")
?.reason,
'server_version_mismatch',
);
assert.equal(classifyAndroidAdbFailure('', 'log line: device offline detected'), undefined);
assert.equal(
classifyAndroidAdbFailure('', 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]')?.reason,
'install_update_incompatible',
);
});
117 changes: 117 additions & 0 deletions packages/contracts/src/android-adb-failure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
type AndroidAdbFailureReason =
| 'timeout'
| 'device_offline'
| 'device_unauthorized'
| 'device_not_found'
| 'multiple_devices'
| 'no_devices'
| 'connection_dropped'
| 'server_version_mismatch'
| 'install_insufficient_storage'
| 'install_update_incompatible'
| 'install_version_downgrade'
| 'install_failed';

export type AndroidAdbFailureClassification = Readonly<{
/** Machine-readable failure family attached to error details as `adbFailure`. */
reason: AndroidAdbFailureReason;
hint: string;
/** Present only when an unchanged retry can succeed. */
retriable?: boolean;
}>;

type AndroidAdbFailureMatcher = AndroidAdbFailureClassification &
Readonly<{
pattern: RegExp;
/** Android package-manager install verdicts may be emitted on stdout. */
matchStdout?: boolean;
}>;

const ANDROID_ADB_FAILURE_MATCHERS: readonly AndroidAdbFailureMatcher[] = [
{
reason: 'device_unauthorized',
pattern: /device unauthorized|device still authorizing/,
hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.',
},
{
reason: 'device_offline',
pattern: /device offline/,
hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.',
retriable: true,
},
{
reason: 'multiple_devices',
pattern: /more than one (?:device\/emulator|device and emulator)/,
hint: 'Multiple Android devices are connected — pass --serial <serial> (see adb devices) to select one.',
},
{
reason: 'no_devices',
pattern: /no devices\/emulators found|no devices found/,
hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.',
},
{
reason: 'device_not_found',
pattern: /device (?:'[^']*' )?not found/,
hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.',
retriable: true,
},
{
reason: 'server_version_mismatch',
pattern: /adb server version \(\d+\) doesn't match this client/,
hint: 'Multiple adb installs conflict — adb restarts its server automatically, so retry; align PATH to a single adb to stop recurrences.',
retriable: true,
},
{
reason: 'connection_dropped',
pattern: /transport error|connection reset|broken pipe|protocol fault/,
hint: 'The adb connection dropped — retry; if it persists, run adb kill-server and reconnect the device.',
retriable: true,
},
{
reason: 'install_insufficient_storage',
pattern: /install_failed_insufficient_storage/,
hint: 'The device is out of storage — free up space or uninstall unused apps, then retry the install.',
matchStdout: true,
},
{
reason: 'install_update_incompatible',
pattern: /install_failed_update_incompatible/,
hint: 'The installed app has an incompatible signature — uninstall the existing app first, then retry the install.',
matchStdout: true,
},
{
reason: 'install_version_downgrade',
pattern: /install_failed_version_downgrade/,
hint: 'The APK is older than the installed app — uninstall the app first (or install with downgrade allowed), then retry.',
matchStdout: true,
},
{
reason: 'install_failed',
pattern: /install_failed_\w+|install_parse_failed_\w+/,
hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.',
matchStdout: true,
},
];

export const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.freeze({
reason: 'timeout',
hint: 'adb timed out — the adb server may be wedged. Run adb kill-server && adb start-server, check adb devices, then retry.',
});

/**
* Classifies transport failures from stderr. Package-manager install verdicts
* additionally inspect stdout because adb emits those semantic results there.
*/
export function classifyAndroidAdbFailure(
stderr: string,
stdout = '',
): AndroidAdbFailureClassification | undefined {
const stderrText = stderr.toLowerCase();
const stdoutText = stdout.toLowerCase();
for (const { pattern, matchStdout, ...classification } of ANDROID_ADB_FAILURE_MATCHERS) {
if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) {
return classification;
}
}
return undefined;
}
9 changes: 9 additions & 0 deletions packages/contracts/src/android-device.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { isAndroidEmulatorSerial, normalizeAndroidDeviceName } from './android-device.ts';

test('Android device identity normalizes AVD names and recognizes running emulator serials', () => {
assert.equal(normalizeAndroidDeviceName(' Pixel_9 Pro '), 'pixel 9 pro');
assert.equal(isAndroidEmulatorSerial('emulator-5554'), true);
assert.equal(isAndroidEmulatorSerial('R58M123ABC'), false);
});
9 changes: 9 additions & 0 deletions packages/contracts/src/android-device.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const ANDROID_EMULATOR_SERIAL_PREFIX = 'emulator-';

export function isAndroidEmulatorSerial(serial: string): boolean {
return serial.startsWith(ANDROID_EMULATOR_SERIAL_PREFIX);
}

export function normalizeAndroidDeviceName(value: string): string {
return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
}
25 changes: 25 additions & 0 deletions packages/contracts/src/command-platform-execution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { inventoryUse } from './platform-module.ts';
import { assertCommandPlatformExecution } from './command-platform-execution.ts';

describe('command platform execution declaration', () => {
test.each([
{ kind: 'legacy' },
{ kind: 'inventory', use: inventoryUse },
{ kind: 'device-runtime', use: { required: ['capture'], preferred: ['inspect'] } },
])('accepts one closed execution shape: %j', (value) => {
expect(() => assertCommandPlatformExecution(value)).not.toThrow();
});

test.each([
{},
{ kind: 'legacy', use: inventoryUse },
{ kind: 'inventory' },
{ kind: 'inventory', use: inventoryUse, legacy: true },
{ kind: 'device-runtime', use: { required: [], preferred: [] }, inventory: true },
{ kind: 'device-runtime', use: { required: ['capture', 'capture'], preferred: [] } },
{ kind: 'device-runtime', use: { required: ['capture'], preferred: ['capture'] } },
])('rejects neither, mixed, widened, duplicate, or overlapping declarations: %j', (value) => {
expect(() => assertCommandPlatformExecution(value)).toThrow(/exactly one/);
});
});
79 changes: 79 additions & 0 deletions packages/contracts/src/command-platform-execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { InventoryUse } from './platform-module.ts';

/** Serializable operation requirements owned by a runtime-backed command descriptor. */
export type RuntimeUseDeclaration = Readonly<{
required: readonly string[];
preferred: readonly string[];
}>;

export type CommandPlatformExecution =
| Readonly<{ kind: 'legacy' }>
| Readonly<{ kind: 'inventory'; use: InventoryUse }>
| Readonly<{ kind: 'device-runtime'; use: RuntimeUseDeclaration }>;

// The discriminated union cannot prove uniqueness or required/preferred disjointness inside
// readonly arrays. Validate those declaration invariants where descriptors enter the registry.
export function assertCommandPlatformExecution(
value: unknown,
): asserts value is CommandPlatformExecution {
if (value === null || typeof value !== 'object') throw invalidPlatformExecution();
const declaration = value as Record<string, unknown>;
const keys = Object.keys(declaration).sort();
if (declaration['kind'] === 'legacy' && sameKeys(keys, ['kind'])) return;
if (
declaration['kind'] === 'inventory' &&
sameKeys(keys, ['kind', 'use']) &&
hasExactInventoryUse(declaration['use'])
) {
return;
}
if (
declaration['kind'] === 'device-runtime' &&
sameKeys(keys, ['kind', 'use']) &&
hasRuntimeUseDeclaration(declaration['use'])
) {
return;
}
throw invalidPlatformExecution();
}

function hasExactInventoryUse(value: unknown): boolean {
if (value === null || typeof value !== 'object') return false;
const use = value as Record<string, unknown>;
return use['kind'] === 'device-inventory' && sameKeys(Object.keys(use).sort(), ['kind']);
}

function hasRuntimeUseDeclaration(value: unknown): boolean {
if (value === null || typeof value !== 'object') return false;
const use = value as Record<string, unknown>;
const required = stringArray(use['required']);
const preferred = stringArray(use['preferred']);
if (!required || !preferred) return false;
if (!hasUniqueValues(required) || !hasUniqueValues(preferred)) return false;
if (!areDisjoint(required, preferred)) return false;
return sameKeys(Object.keys(use).sort(), ['preferred', 'required']);
}

function stringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
return value.every((key): key is string => typeof key === 'string') ? value : null;
}

function hasUniqueValues(values: readonly string[]): boolean {
return new Set(values).size === values.length;
}

function areDisjoint(left: readonly string[], right: readonly string[]): boolean {
const leftValues = new Set(left);
return right.every((value) => !leftValues.has(value));
}

function sameKeys(actual: readonly string[], expected: readonly string[]): boolean {
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}

function invalidPlatformExecution(): TypeError {
return new TypeError(
'Command platform execution must declare exactly one of legacy, inventory, or device-runtime',
);
}
Loading
Loading