Skip to content

Commit ae9c659

Browse files
committed
refactor: move platform provider composition out of daemon
1 parent 71214e1 commit ae9c659

33 files changed

Lines changed: 514 additions & 384 deletions

packages/contracts/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@
6767
"types": "./src/apple-multitouch-support.ts",
6868
"default": "./src/apple-multitouch-support.ts"
6969
},
70+
"./apple-runner-request": {
71+
"types": "./src/apple-runner-request.ts",
72+
"default": "./src/apple-runner-request.ts"
73+
},
7074
"./application-lifecycle-interaction": {
7175
"types": "./src/application-lifecycle-interaction.ts",
7276
"default": "./src/application-lifecycle-interaction.ts"
@@ -111,6 +115,10 @@
111115
"types": "./src/back-runtime.ts",
112116
"default": "./src/back-runtime.ts"
113117
},
118+
"./boot-failure": {
119+
"types": "./src/boot-failure.ts",
120+
"default": "./src/boot-failure.ts"
121+
},
114122
"./capture": {
115123
"types": "./src/facades/capture.ts",
116124
"default": "./src/facades/capture.ts"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts';
2+
3+
/**
4+
* Request-scoped options shared across daemon routing and Apple runner adapters.
5+
*
6+
* The Apple runner owns its lifecycle and command options; this is only the
7+
* neutral request vocabulary that a caller may pass to an adapter.
8+
*/
9+
export type AppleRunnerRequestOptions = Readonly<{
10+
verbose?: boolean;
11+
logPath?: string;
12+
traceLogPath?: string;
13+
requestId?: string;
14+
runnerLeaseContext?: RunnerLogicalLeaseContext;
15+
iosXctestrunFile?: string;
16+
iosXctestDerivedDataPath?: string;
17+
iosXctestEnvDir?: string;
18+
}>;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const INFRASTRUCTURE_BOOT_FAILURE_REASONS = [
2+
'IOS_BOOT_TIMEOUT',
3+
'IOS_RUNNER_CONNECT_TIMEOUT',
4+
'IOS_RUNNER_OWNED_BY_OTHER_DAEMON',
5+
'IOS_TOOL_MISSING',
6+
'ANDROID_BOOT_TIMEOUT',
7+
'ADB_TRANSPORT_UNAVAILABLE',
8+
'CI_RESOURCE_STARVATION_SUSPECTED',
9+
] as const;
10+
11+
export type InfrastructureBootFailureReason = (typeof INFRASTRUCTURE_BOOT_FAILURE_REASONS)[number];
12+
13+
const infrastructureBootFailureReasons: ReadonlySet<InfrastructureBootFailureReason> = new Set(
14+
INFRASTRUCTURE_BOOT_FAILURE_REASONS,
15+
);
16+
17+
/** True when a boot failure can be retried by changing host/transport conditions. */
18+
export function isInfrastructureBootFailureReason(
19+
reason: string,
20+
): reason is InfrastructureBootFailureReason {
21+
return infrastructureBootFailureReasons.has(
22+
reason.toUpperCase() as InfrastructureBootFailureReason,
23+
);
24+
}

packages/contracts/src/platform-plugin.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ type CapabilityBucket = 'apple' | 'android' | 'harmonyos' | 'vega' | 'linux' | '
1818
* `import()` inside `createInteractor`, preserving the
1919
* CLI cold-start laziness that today's `getInteractor` switch relies on.
2020
*
21-
* Daemon-owned columns (step b.3, issue #974): each is declared ONLY once it is
21+
* Root-composed columns (step b.3, issue #974): each is declared ONLY once it is
2222
* populated by wrapping the existing daemon branch AND pinned by a table-equivalence
2323
* parity test before a real call-site routes through it. A facet's type stays
24-
* PLATFORM-NEUTRAL and daemon-owned (never the iOS-simulator-shaped provider seam):
24+
* PLATFORM-NEUTRAL and composition-owned (never the iOS-simulator-shaped provider seam):
2525
* {@link PlatformPlugin.providers} carries the per-family platform-gated request
2626
* provider resolver list (replaces the hand `device.platform === …` gate in
2727
* `request-platform-providers.ts`, pinned by the providers routing parity test). The
@@ -59,15 +59,15 @@ export type PlatformPlugin = {
5959
>;
6060
};
6161
/**
62-
* The daemon request-scope provider facet (issue #974). `platformGatedResolvers`
62+
* The request-scope provider facet (issue #974). `platformGatedResolvers`
6363
* declares which PLATFORM-GATED request provider resolvers apply to this family's
6464
* devices — the DATA that replaces the hand `device.platform === …` gate formerly
6565
* open-coded inside each descriptor's `resolve` in
66-
* src/daemon/request-platform-providers.ts. The daemon still OWNS the resolver
66+
* src/platform-runtime-request-providers.ts. The root composition owns the resolver
6767
* functions, their wrapper composition, and the request-scope concurrency isolation;
6868
* this facet supplies only the per-family gate (a plain string list, the keys
6969
* type-only in the plugin). Focused command transports that are not family-gated
70-
* are intentionally NOT part of the facet and stay ungated in the daemon.
70+
* are intentionally NOT part of the facet and stay ungated in the composition.
7171
* Every family carries this facet (each
7272
* owns at least one platform-specific resolver); a device on an unregistered platform
7373
* resolves to no gated resolvers, matching the former hand gate. Pinned by the

packages/contracts/src/platform-providers.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,59 @@
1-
// Vocabulary for the platform-plugin provider facet.
1+
// Vocabulary for the platform-plugin provider facet and the request-scoped provider seam.
22
//
3-
// `core/platform-plugin/plugin.ts` declares which daemon provider resolvers a platform
4-
// family gates. The daemon owns the resolvers themselves and asserts at compile time, in
5-
// `daemon/request-platform-providers.ts`, that every key named here is a real resolver —
6-
// so the facet can never name a resolver the daemon does not compose.
3+
// `core/platform-plugin/plugin.ts` declares which provider resolvers a platform family gates.
4+
// The concrete resolver table and wrapper composition live in the root composition module; the
5+
// daemon only supplies this neutral request context and consumes the one capability it currently
6+
// needs from the resulting scope.
7+
8+
import type { DeviceInfo } from '@agent-device/kernel/device';
9+
import type { SessionSurface } from './session-surface.ts';
10+
11+
export type PlatformProviderRequestSession = Readonly<{
12+
name: string;
13+
device: DeviceInfo;
14+
appBundleId?: string;
15+
appName?: string;
16+
surface?: SessionSurface;
17+
}>;
18+
19+
/** Request data shared with a root-composed platform provider resolver. */
20+
export type PlatformProviderRequestContext = Readonly<{
21+
device: DeviceInfo;
22+
session?: PlatformProviderRequestSession;
23+
requestedSession?: string;
24+
requestId?: string;
25+
/** Daemon policy says that the root may construct its managed Web provider for this request. */
26+
useDefaultWebProvider?: boolean;
27+
}>;
28+
29+
/** The only request-scoped platform value currently consumed by daemon handlers.
30+
*
31+
* Its concrete Android executor type remains owned by the Android package. The daemon handlers
32+
* already pass this value through as an opaque capability, so duplicating that package type here
33+
* would make the seam another declaration site rather than a neutral contract.
34+
*/
35+
export type RequestPlatformProviderScope = Readonly<{
36+
androidAdbExecutor?: unknown;
37+
}>;
38+
39+
/** Root-composed provider wrappers; device selection remains a daemon policy. */
40+
export type RequestPlatformProviders = Readonly<{
41+
/** Avoid resolving a daemon device when no resolver or default Web provider is configured. */
42+
hasConfiguredResolvers: boolean;
43+
run<T>(
44+
context: PlatformProviderRequestContext,
45+
task: (scope: RequestPlatformProviderScope) => Promise<T>,
46+
): Promise<T>;
47+
}>;
748

849
/**
950
* The request provider resolvers whose application is PLATFORM-GATED — each ran behind
1051
* a hand `device.platform === …` predicate inside its descriptor's `resolve`. The
1152
* PlatformPlugin `providers` facet (issue #974) declares, per family, which of these
1253
* apply to that family's devices (data-only: a plain string list, type-only in the
13-
* plugin), and `platformGatedResolverApplies` routes the gate through it. The daemon
14-
* still OWNS the resolver invocation, wrapper composition, and request-scope
15-
* concurrency isolation — only the platform GATE moved to data.
54+
* plugin), and the root composition routes the gate through it. Resolver invocation,
55+
* wrapper composition, and request-scope concurrency isolation live with the concrete
56+
* provider composition, not in daemon request code.
1657
*
1758
* App-log and screen-recording transports are deliberately ABSENT: they carry no
1859
* platform gate (they apply on every platform), so they stay ungated in the daemon and

packages/kernel/src/contracts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export type DaemonInstallSource =
3939
}
4040
));
4141

42+
/** Install sources that can be materialized by a local daemon. */
43+
export type LocalInstallSource = Extract<DaemonInstallSource, { kind: 'url' | 'path' }>;
44+
4245
const DAEMON_LOCK_POLICIES = ['reject', 'strip'] as const;
4346
export type DaemonLockPolicy = (typeof DAEMON_LOCK_POLICIES)[number];
4447
const LEASE_BACKENDS = ['ios-simulator', 'ios-instance', 'android-instance'] as const;

packages/platform-apple/src/runner/host.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ChildProcess } from 'node:child_process';
22
import type { RequestProgressEvent } from '@agent-device/contracts/progress';
33
import type { DeviceInfo } from '@agent-device/kernel/device';
4+
import type { InfrastructureBootFailureReason } from '@agent-device/contracts/boot-failure';
45
import type { XmlNode } from '@agent-device/xml';
56

67
/**
@@ -123,14 +124,8 @@ export type TtlMemoOptions = {
123124
export type DefinedEnvMap = Record<string, string>;
124125

125126
export type BootFailureReason =
126-
| 'IOS_BOOT_TIMEOUT'
127-
| 'IOS_RUNNER_CONNECT_TIMEOUT'
128-
| 'IOS_RUNNER_OWNED_BY_OTHER_DAEMON'
127+
| InfrastructureBootFailureReason
129128
| 'IOS_RUNNER_DEVICE_NOT_PROVISIONED'
130-
| 'IOS_TOOL_MISSING'
131-
| 'ANDROID_BOOT_TIMEOUT'
132-
| 'ADB_TRANSPORT_UNAVAILABLE'
133-
| 'CI_RESOURCE_STARVATION_SUSPECTED'
134129
| 'BOOT_COMMAND_FAILED'
135130
| 'UNKNOWN';
136131

packages/platform-apple/src/runner/runner-provider.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
import { AsyncLocalStorage } from 'node:async_hooks';
2-
import type { RunnerLogicalLeaseContext } from '@agent-device/contracts/runner-lease-context';
2+
import type { AppleRunnerRequestOptions } from '@agent-device/contracts/apple-runner-request';
33
import type { DeviceInfo } from '@agent-device/kernel/device';
44
import type { Deadline } from './host.ts';
55
import type { RunnerCommand } from './runner-contract.ts';
6-
import type {
7-
RunnerXctestrunArtifactState,
8-
RunnerXctestrunCacheKind,
9-
ExternalXctestRunnerOptions,
10-
} from './runner-xctestrun.ts';
6+
import type { RunnerXctestrunArtifactState, RunnerXctestrunCacheKind } from './runner-xctestrun.ts';
117

12-
export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & {
8+
export type AppleRunnerCommandOptions = AppleRunnerRequestOptions & {
139
signal?: AbortSignal;
14-
verbose?: boolean;
15-
logPath?: string;
16-
traceLogPath?: string;
1710
cleanStaleBundles?: boolean;
1811
startupTimeoutMs?: number;
19-
requestId?: string;
20-
runnerLeaseContext?: RunnerLogicalLeaseContext;
2112
/**
2213
* Restricts a command to the already-owned durable runner session. Exact
2314
* cleanup must never start, adopt, or dispatch to a replacement session.

scripts/layering/daemon-modularity.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ test('daemon modularity baseline records the measured R7 ownership pressure', ()
7373
Object.values(SESSION_STATE_FIELD_OWNERS).reduce((sum, owners) => sum + owners.length, 0),
7474
DAEMON_MODULARITY_BASELINE.sessionState.ownerFileClaims,
7575
);
76-
assert.equal(TYPE_CYCLE_BASELINE, 18);
77-
assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 11);
76+
assert.equal(TYPE_CYCLE_BASELINE, 16);
77+
assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 10);
7878
assert.equal('daemon' in DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers, false);
7979
});
8080

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

258258
assert.equal(violations.length, 1);
259259
assert.match(violations[0]!.rule, /^R9 /);
260-
assert.match(violations[0]!.message, /dropped to 17 files \(baseline 18\)/);
260+
assert.match(violations[0]!.message, /dropped to 15 files \(baseline 16\)/);
261261
assert.match(violations[0]!.message, /Lower LARGEST_TYPE_CYCLE_ZONE_CEILINGS by the same 1/);
262262
});

scripts/layering/daemon-modularity.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { targetDagZone, type LayeringViolation, type ResolvedImportEdge } from '
33
import { SESSION_STATE_FIELD_OWNERS } from './session-state.ts';
44

55
const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly<Record<string, number>> = {
6-
'(root)': 2,
6+
// Request provider composition now consumes the neutral contracts context instead of importing
7+
// daemon request/session types, removing the root provider seam from this component.
8+
'(root)': 1,
79
// R58 retired the legacy command dispatcher, taking `core/dispatch.ts` and the
810
// `core/interactors.ts` registry it pulled in out of the cycle with it. R64 removes the
911
// legacy perf projection and lowers the remaining core component by one more file.
@@ -13,7 +15,8 @@ const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly<Record<string, number>> = {
1315
// `interaction-outcome-policy.ts` and `deferred-interaction-outcome.ts` both left the cycle.
1416
// R63 then deleted `session-install-capability-projection.ts` outright — the general
1517
// fact-owned projection subsumes it — taking a third member with it.
16-
'daemon-server': 11,
18+
// The daemon side of that seam no longer imports the concrete provider resolver table.
19+
'daemon-server': 10,
1720
// R64 deletes the last perf support closure from `apple/plugin.ts`, taking the final
1821
// platform-owned member out of the type cycle.
1922
platforms: 0,

0 commit comments

Comments
 (0)