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
14 changes: 10 additions & 4 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,15 @@ never daemon orchestration, command policy, or cross-family defaults.

The root composition module, `src/platform-runtime.ts`, constructs immutable
inventory/runtime registries and injects a composed inventory gateway plus provider-first runtime
gateway into daemon request execution. Daemon device-execution modules import runtime contracts only.
gateway into daemon request execution. Its private
`src/platform-runtime/request-providers.ts` implementation submodule owns the cross-family request
provider resolver table and wrapper ordering; only the canonical root may load it, and it remains
lazy until a request enters a provider scope. Daemon device-execution modules import the canonical
root interface or runtime contracts only.
Shared runtime interfaces and neutral data types live in `@agent-device/contracts`. In production,
only that composition module may import a concrete platform package; reusable types do not leak
through type-only platform imports. Platform packages may import contracts, kernel/domain packages,
only that composition module or its one R13-governed private implementation submodule may import a
concrete platform package; reusable types do not leak through type-only platform imports. Platform
packages may import contracts, kernel/domain packages,
and explicitly injected host capabilities; they may not import daemon requests or responses, mutable
session state, command catalogs/grammar, root implementation files, sibling platform packages, or raw
process primitives outside the shared host-command port. R13 applies these rules to static, type-only,
Expand Down Expand Up @@ -615,7 +620,8 @@ The final gates passed:
integration-progress checks.
- `pnpm check:layering` passed 131 structural/model tests and scanned 1,157 production source files.
R11 owns 17 workspace packages behind 39 exported subpaths with no root back-imports; R13 keeps six
private implementation-lazy platform packages above capture-kit behind one composition root; R14
private implementation-lazy platform packages above capture-kit behind one canonical composition
root and its single private provider-composition implementation submodule; R14
and R15 retain one typed route for `logs` and `network` with no legacy route.
- Six local inventory/runtime owners, all enumerated Apple leaf/kind cells, and the production
BrowserStack, AWS Device Farm, and Limrun provider modes remain covered. Provider ownership and
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
"types": "./src/apple-multitouch-support.ts",
"default": "./src/apple-multitouch-support.ts"
},
"./apple-runner-request": {
"types": "./src/apple-runner-request.ts",
"default": "./src/apple-runner-request.ts"
},
"./application-lifecycle-interaction": {
"types": "./src/application-lifecycle-interaction.ts",
"default": "./src/application-lifecycle-interaction.ts"
Expand Down Expand Up @@ -111,6 +115,10 @@
"types": "./src/back-runtime.ts",
"default": "./src/back-runtime.ts"
},
"./boot-failure": {
"types": "./src/boot-failure.ts",
"default": "./src/boot-failure.ts"
},
"./capture": {
"types": "./src/facades/capture.ts",
"default": "./src/facades/capture.ts"
Expand Down
18 changes: 18 additions & 0 deletions packages/contracts/src/apple-runner-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts';

/**
* Request-scoped options shared across daemon routing and Apple runner adapters.
*
* The Apple runner owns its lifecycle and command options; this is only the
* neutral request vocabulary that a caller may pass to an adapter.
*/
export type AppleRunnerRequestOptions = Readonly<{
verbose?: boolean;
logPath?: string;
traceLogPath?: string;
requestId?: string;
runnerLeaseContext?: RunnerLogicalLeaseContext;
iosXctestrunFile?: string;
iosXctestDerivedDataPath?: string;
iosXctestEnvDir?: string;
}>;
24 changes: 24 additions & 0 deletions packages/contracts/src/boot-failure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const INFRASTRUCTURE_BOOT_FAILURE_REASONS = [
'IOS_BOOT_TIMEOUT',
'IOS_RUNNER_CONNECT_TIMEOUT',
'IOS_RUNNER_OWNED_BY_OTHER_DAEMON',
'IOS_TOOL_MISSING',
'ANDROID_BOOT_TIMEOUT',
'ADB_TRANSPORT_UNAVAILABLE',
'CI_RESOURCE_STARVATION_SUSPECTED',
] as const;

export type InfrastructureBootFailureReason = (typeof INFRASTRUCTURE_BOOT_FAILURE_REASONS)[number];

const infrastructureBootFailureReasons: ReadonlySet<InfrastructureBootFailureReason> = new Set(
INFRASTRUCTURE_BOOT_FAILURE_REASONS,
);

/** True when a boot failure can be retried by changing host/transport conditions. */
export function isInfrastructureBootFailureReason(
reason: string,
): reason is InfrastructureBootFailureReason {
return infrastructureBootFailureReasons.has(
reason.toUpperCase() as InfrastructureBootFailureReason,
);
}
10 changes: 5 additions & 5 deletions packages/contracts/src/platform-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ type CapabilityBucket = 'apple' | 'android' | 'harmonyos' | 'vega' | 'linux' | '
* `import()` inside `createInteractor`, preserving the
* CLI cold-start laziness that today's `getInteractor` switch relies on.
*
* Daemon-owned columns (step b.3, issue #974): each is declared ONLY once it is
* Root-composed columns (step b.3, issue #974): each is declared ONLY once it is
* populated by wrapping the existing daemon branch AND pinned by a table-equivalence
* parity test before a real call-site routes through it. A facet's type stays
* PLATFORM-NEUTRAL and daemon-owned (never the iOS-simulator-shaped provider seam):
* PLATFORM-NEUTRAL and composition-owned (never the iOS-simulator-shaped provider seam):
* {@link PlatformPlugin.providers} carries the per-family platform-gated request
* provider resolver list (replaces the hand `device.platform === …` gate in
* `request-platform-providers.ts`, pinned by the providers routing parity test). The
Expand Down Expand Up @@ -59,15 +59,15 @@ export type PlatformPlugin = {
>;
};
/**
* The daemon request-scope provider facet (issue #974). `platformGatedResolvers`
* The request-scope provider facet (issue #974). `platformGatedResolvers`
* declares which PLATFORM-GATED request provider resolvers apply to this family's
* devices — the DATA that replaces the hand `device.platform === …` gate formerly
* open-coded inside each descriptor's `resolve` in
* src/daemon/request-platform-providers.ts. The daemon still OWNS the resolver
* src/platform-runtime/request-providers.ts. The canonical root composition owns the resolver
* functions, their wrapper composition, and the request-scope concurrency isolation;
* this facet supplies only the per-family gate (a plain string list, the keys
* type-only in the plugin). Focused command transports that are not family-gated
* are intentionally NOT part of the facet and stay ungated in the daemon.
* are intentionally NOT part of the facet and stay ungated in the composition.
* Every family carries this facet (each
* owns at least one platform-specific resolver); a device on an unregistered platform
* resolves to no gated resolvers, matching the former hand gate. Pinned by the
Expand Down
57 changes: 49 additions & 8 deletions packages/contracts/src/platform-providers.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,59 @@
// Vocabulary for the platform-plugin provider facet.
// Vocabulary for the platform-plugin provider facet and the request-scoped provider seam.
//
// `core/platform-plugin/plugin.ts` declares which daemon provider resolvers a platform
// family gates. The daemon owns the resolvers themselves and asserts at compile time, in
// `daemon/request-platform-providers.ts`, that every key named here is a real resolver —
// so the facet can never name a resolver the daemon does not compose.
// `core/platform-plugin/plugin.ts` declares which provider resolvers a platform family gates.
// The concrete resolver table and wrapper composition live in the root composition module; the
// daemon only supplies this neutral request context and consumes the one capability it currently
// needs from the resulting scope.

import type { DeviceInfo } from '@agent-device/kernel/device';
import type { SessionSurface } from './session-surface.ts';

export type PlatformProviderRequestSession = Readonly<{
name: string;
device: DeviceInfo;
appBundleId?: string;
appName?: string;
surface?: SessionSurface;
}>;

/** Request data shared with a root-composed platform provider resolver. */
export type PlatformProviderRequestContext = Readonly<{
device: DeviceInfo;
session?: PlatformProviderRequestSession;
requestedSession?: string;
requestId?: string;
/** Daemon policy says that the root may construct its managed Web provider for this request. */
useDefaultWebProvider?: boolean;
}>;

/** The only request-scoped platform value currently consumed by daemon handlers.
*
* Its concrete Android executor type remains owned by the Android package. The daemon handlers
* already pass this value through as an opaque capability, so duplicating that package type here
* would make the seam another declaration site rather than a neutral contract.
*/
export type RequestPlatformProviderScope = Readonly<{
androidAdbExecutor?: unknown;
}>;

/** Root-composed provider wrappers; device selection remains a daemon policy. */
export type RequestPlatformProviders = Readonly<{
/** Avoid resolving a daemon device when no resolver or default Web provider is configured. */
hasConfiguredResolvers: boolean;
run<T>(
context: PlatformProviderRequestContext,
task: (scope: RequestPlatformProviderScope) => Promise<T>,
): Promise<T>;
}>;

/**
* The request provider resolvers whose application is PLATFORM-GATED — each ran behind
* a hand `device.platform === …` predicate inside its descriptor's `resolve`. The
* PlatformPlugin `providers` facet (issue #974) declares, per family, which of these
* apply to that family's devices (data-only: a plain string list, type-only in the
* plugin), and `platformGatedResolverApplies` routes the gate through it. The daemon
* still OWNS the resolver invocation, wrapper composition, and request-scope
* concurrency isolation — only the platform GATE moved to data.
* plugin), and the root composition routes the gate through it. Resolver invocation,
* wrapper composition, and request-scope concurrency isolation live with the concrete
* provider composition, not in daemon request code.
*
* App-log and screen-recording transports are deliberately ABSENT: they carry no
* platform gate (they apply on every platform), so they stay ungated in the daemon and
Expand Down
3 changes: 3 additions & 0 deletions packages/kernel/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export type DaemonInstallSource =
}
));

/** Install sources that can be materialized by a local daemon. */
export type LocalInstallSource = Extract<DaemonInstallSource, { kind: 'url' | 'path' }>;

const DAEMON_LOCK_POLICIES = ['reject', 'strip'] as const;
export type DaemonLockPolicy = (typeof DAEMON_LOCK_POLICIES)[number];
const LEASE_BACKENDS = ['ios-simulator', 'ios-instance', 'android-instance'] as const;
Expand Down
9 changes: 2 additions & 7 deletions packages/platform-apple/src/runner/host.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ChildProcess } from 'node:child_process';
import type { RequestProgressEvent } from '@agent-device/contracts/progress';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { InfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure';
import type { XmlNode } from '@agent-device/xml';

/**
Expand Down Expand Up @@ -123,14 +124,8 @@ export type TtlMemoOptions = {
export type DefinedEnvMap = Record<string, string>;

export type BootFailureReason =
| 'IOS_BOOT_TIMEOUT'
| 'IOS_RUNNER_CONNECT_TIMEOUT'
| 'IOS_RUNNER_OWNED_BY_OTHER_DAEMON'
| InfrastructureBootFailureReason
| 'IOS_RUNNER_DEVICE_NOT_PROVISIONED'
| 'IOS_TOOL_MISSING'
| 'ANDROID_BOOT_TIMEOUT'
| 'ADB_TRANSPORT_UNAVAILABLE'
| 'CI_RESOURCE_STARVATION_SUSPECTED'
| 'BOOT_COMMAND_FAILED'
| 'UNKNOWN';

Expand Down
15 changes: 3 additions & 12 deletions packages/platform-apple/src/runner/runner-provider.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context';
import type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { Deadline } from './host.ts';
import type { RunnerCommand } from './runner-contract.ts';
import type {
RunnerXctestrunArtifactState,
RunnerXctestrunCacheKind,
ExternalXctestRunnerOptions,
} from './runner-xctestrun.ts';
import type { RunnerXctestrunArtifactState, RunnerXctestrunCacheKind } from './runner-xctestrun.ts';

export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & {
export type AppleRunnerCommandOptions = AppleRunnerRequestOptions & {
signal?: AbortSignal;
verbose?: boolean;
logPath?: string;
traceLogPath?: string;
cleanStaleBundles?: boolean;
startupTimeoutMs?: number;
requestId?: string;
runnerLeaseContext?: RunnerLogicalLeaseContext;
/**
* Restricts a command to the already-owned durable runner session. Exact
* cleanup must never start, adopt, or dispatch to a replacement session.
Expand Down
8 changes: 4 additions & 4 deletions scripts/layering/daemon-modularity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ test('daemon modularity baseline records the measured R7 ownership pressure', ()
Object.values(SESSION_STATE_FIELD_OWNERS).reduce((sum, owners) => sum + owners.length, 0),
DAEMON_MODULARITY_BASELINE.sessionState.ownerFileClaims,
);
assert.equal(TYPE_CYCLE_BASELINE, 18);
assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 11);
assert.equal(TYPE_CYCLE_BASELINE, 16);
assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 10);
assert.equal('daemon' in DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers, false);
});

Expand Down Expand Up @@ -239,7 +239,7 @@ test('R10 zone overflow lists the whole zone so the joining member is visible',
const [violation] = violations;
assert.equal(violation!.rule, 'R10 daemon-modularity');
assert.equal(violation!.file, 'scripts/layering/daemon-modularity.ts');
assert.match(violation!.message, /contains 12 daemon-server file\(s\) \(baseline 11\)/);
assert.match(violation!.message, /contains 11 daemon-server file\(s\) \(baseline 10\)/);
for (const member of daemonMembers) {
assert.ok(violation!.message.includes(member), `${member} missing from: ${violation!.message}`);
}
Expand All @@ -257,6 +257,6 @@ test('R9 rejects a baseline left above the measured cycle', () => {

assert.equal(violations.length, 1);
assert.match(violations[0]!.rule, /^R9 /);
assert.match(violations[0]!.message, /dropped to 17 files \(baseline 18\)/);
assert.match(violations[0]!.message, /dropped to 15 files \(baseline 16\)/);
assert.match(violations[0]!.message, /Lower LARGEST_TYPE_CYCLE_ZONE_CEILINGS by the same 1/);
});
7 changes: 5 additions & 2 deletions scripts/layering/daemon-modularity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { targetDagZone, type LayeringViolation, type ResolvedImportEdge } from '
import { SESSION_STATE_FIELD_OWNERS } from './session-state.ts';

const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly<Record<string, number>> = {
'(root)': 2,
// Request provider composition now consumes the neutral contracts context instead of importing
// daemon request/session types, removing the root provider seam from this component.
'(root)': 1,
// R58 retired the legacy command dispatcher, taking `core/dispatch.ts` and the
// `core/interactors.ts` registry it pulled in out of the cycle with it. R64 removes the
// legacy perf projection and lowers the remaining core component by one more file.
Expand All @@ -13,7 +15,8 @@ const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly<Record<string, number>> = {
// `interaction-outcome-policy.ts` and `deferred-interaction-outcome.ts` both left the cycle.
// R63 then deleted `session-install-capability-projection.ts` outright — the general
// fact-owned projection subsumes it — taking a third member with it.
'daemon-server': 11,
// The daemon side of that seam no longer imports the concrete provider resolver table.
'daemon-server': 10,
// R64 deletes the last perf support closure from `apple/plugin.ts`, taking the final
// platform-owned member out of the type cycle.
platforms: 0,
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ test('classifyZone separates the ranked spine from intentionally-unranked zones'
assert.equal(classifyZone('contracts'), 'ranked');
assert.equal(classifyZone('daemon-server'), 'ranked');
assert.equal(classifyZone('(root)'), 'unranked');
assert.equal(classifyZone('platform-runtime'), 'unranked');
assert.equal(classifyZone('utils'), 'ranked');
// Every satellite zone joined the spine; only the composition root stays out, because R2
// forbids daemon/ from importing commands/ so the files that wire them cannot be ranked.
Expand Down
3 changes: 3 additions & 0 deletions scripts/layering/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export function zoneRank(zone: string): number | null {
// exact-family/composition/laziness policy.
export const UNRANKED_ZONES: ReadonlySet<string> = new Set([
'(root)',
// Private implementation submodules of the canonical root composition. R13 owns their exact
// importer and concrete-platform authority; giving them a spine rank would duplicate that seam.
'platform-runtime',
'kernel',
'capture-kit',
'platform-apple',
Expand Down
2 changes: 2 additions & 0 deletions scripts/layering/package-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const CONTRACT_EXPORTS = [
'@agent-device/contracts/app-state-runtime',
'@agent-device/contracts/app-switcher-runtime',
'@agent-device/contracts/apple-multitouch-support',
'@agent-device/contracts/apple-runner-request',
'@agent-device/contracts/application-lifecycle-interaction',
'@agent-device/contracts/application-lifecycle-runtime',
'@agent-device/contracts/application-lifecycle-runtime-plan',
Expand All @@ -74,6 +75,7 @@ const CONTRACT_EXPORTS = [
'@agent-device/contracts/audio-runtime-plan',
'@agent-device/contracts/back-mode',
'@agent-device/contracts/back-runtime',
'@agent-device/contracts/boot-failure',
'@agent-device/contracts/capture',
'@agent-device/contracts/click-button',
'@agent-device/contracts/client',
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/platform-composition-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function isAllowedCompositionImport(specifier: string): boolean {
specifier === './platform-runtime-app-state-host.ts' ||
specifier === './platform-runtime-device-inventory.ts' ||
specifier === './platform-runtime-host.ts' ||
specifier === './platform-runtime/request-providers.ts' ||
specifier.startsWith('./platform-runtime-host/')
);
}
Expand Down
Loading
Loading