Skip to content

Commit aaf95bc

Browse files
committed
feat: route recording + provider platform gates through PlatformPlugin facets
Phase 3 step b.3 (issue #974): land the two deferred daemon-owned PlatformPlugin facets as DATA-ONLY seams, mirroring appLog/perf. recording facet: PlatformPlugin.recording.resolveBackendTag(device) returns a neutral RecordingBackendTag string ('web'|'android'|'macos'| 'ios-device'|'ios-simulator'|'unsupported', daemon-owned, type-only in the plugin). resolveRecordingBackendForDevice now maps the tag back to the daemon-owned backend instance via RECORDING_BACKENDS_BY_TAG, with a '?? unsupported' fallthrough for the factless linux family. Backend instances + RecordingBackend type stay in the daemon. providers facet: PlatformPlugin.providers.platformGatedResolvers declares, per family, which platform-gated request provider resolvers apply (the data that replaces each descriptor's hand 'device.platform === …' gate). The daemon still owns resolver invocation, wrapper composition, and request-scope concurrency isolation; only the gate moved to data. Ungated resolvers (appLog/recording providers) stay ungated in the daemon. Both facet types are type-only in the plugin (R3-clean, like LogBackend), so no platforms/->daemon value edge and no CLI cold-start regression. Pinned by two table-equivalence parity tests (independent verbatim oracle, full platform x kind x target matrix, facet-presence + fallthrough + end-to-end routing).
1 parent b01fc19 commit aaf95bc

7 files changed

Lines changed: 529 additions & 17 deletions

File tree

‎src/core/interactors/register-builtins.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ const androidPlugin = {
3232
// Wraps the Android arm of `supportsPlatformPerfMetrics`: every Android device
3333
// reports perf-metrics support.
3434
perf: { supportsMetrics: () => true },
35+
// Wraps the Android arm of `resolveRecordingBackendForDevice`: every Android device
36+
// resolves to the android recording backend.
37+
recording: { resolveBackendTag: () => 'android' },
38+
// Declares the platform-gated request provider resolver the Android family owns (the
39+
// adb provider, formerly gated by `device.platform === 'android'`).
40+
providers: { platformGatedResolvers: ['androidAdbProvider'] },
3541
createInteractor: async (device: DeviceInfo) => {
3642
const { createAndroidInteractor } = await import('./android.ts');
3743
return createAndroidInteractor(device);
@@ -50,6 +56,11 @@ const linuxPlugin = {
5056
id: 'linux',
5157
platforms: ['linux'],
5258
capability: { bucket: 'linux' },
59+
// No recording facet: linux historically fell through to the unsupported recording
60+
// backend; the daemon lookup preserves that (`?? 'unsupported'`).
61+
// Declares the platform-gated request provider resolver the linux family owns (the
62+
// linux tool provider, formerly gated by `device.platform === 'linux'`).
63+
providers: { platformGatedResolvers: ['linuxToolProvider'] },
5364
createInteractor: async () => {
5465
const { createLinuxInteractor } = await import('./linux.ts');
5566
return createLinuxInteractor();
@@ -64,6 +75,12 @@ const webPlugin = {
6475
id: 'web',
6576
platforms: ['web'],
6677
capability: { bucket: 'web' },
78+
// Wraps the web arm of `resolveRecordingBackendForDevice`: the web device resolves to
79+
// the web (agent-browser) recording backend.
80+
recording: { resolveBackendTag: () => 'web' },
81+
// Declares the platform-gated request provider resolver the web family owns (the web
82+
// provider, formerly gated by `device.platform === 'web'`).
83+
providers: { platformGatedResolvers: ['webProvider'] },
6784
createInteractor: async () => {
6885
const { createWebInteractor } = await import('./web.ts');
6986
return createWebInteractor();

‎src/core/platform-plugin/plugin.ts‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { AppError } from '../../kernel/errors.ts';
22
import type { DeviceInfo, Platform, PlatformSelector } from '../../kernel/device.ts';
33
import type { LogBackend } from '../../daemon/network-log.ts';
4+
import type { RecordingBackendTag } from '../../daemon/handlers/record-trace-recording-backends.ts';
5+
import type { PlatformGatedProviderResolverKey } from '../../daemon/request-platform-providers.ts';
46
import type { Interactor, RunnerContext } from '../interactor-types.ts';
57
import type { DeviceInventoryRequest } from '../platform-inventory.ts';
68
import type { CapabilityBucket } from '../platform-descriptor/types.ts';
@@ -27,10 +29,15 @@ import type { CapabilityBucket } from '../platform-descriptor/types.ts';
2729
* (wraps `resolveLogBackend`, pinned by the daemon app-log routing parity test);
2830
* {@link PlatformPlugin.perf} carries the neutral perf-metrics support predicate
2931
* (wraps `supportsPlatformPerfMetrics`, pinned by the daemon perf routing parity
30-
* test). The remaining columns (`providers` / `recording`) — and the rest of the
31-
* `perf` facet (the sampling body `buildPerfResponseData` and the Android-only
32-
* native-collector gate) — stay on their daemon branch as the source of truth
33-
* until each clears the same gate. See
32+
* test); {@link PlatformPlugin.recording} carries the neutral
33+
* {@link RecordingBackendTag} resolver (wraps the per-platform branch of
34+
* `resolveRecordingBackendForDevice`, pinned by the recording routing parity test);
35+
* {@link PlatformPlugin.providers} carries the per-family platform-gated request
36+
* provider resolver list (replaces the hand `device.platform === …` gate in
37+
* `request-platform-providers.ts`, pinned by the providers routing parity test). The
38+
* rest of the `perf` facet (the sampling body `buildPerfResponseData` and the
39+
* Android-only native-collector gate) stays on its daemon branch as the source of
40+
* truth until it clears the same gate. See
3441
* docs/adr/0009-apple-platform-consolidation.md (tracked in issue #974).
3542
*/
3643
export type PlatformPlugin = {
@@ -93,6 +100,39 @@ export type PlatformPlugin = {
93100
readonly perf?: {
94101
supportsMetrics(device: DeviceInfo): boolean;
95102
};
103+
/**
104+
* The daemon recording facet (issue #974). `resolveBackendTag` wraps the
105+
* per-platform branch of `resolveRecordingBackendForDevice`
106+
* (src/daemon/handlers/record-trace-recording-backends.ts), returning the neutral
107+
* {@link RecordingBackendTag} for `device` (a DATA-ONLY string, type-only in the
108+
* plugin — exactly like {@link appLog}'s {@link LogBackend}). The daemon maps the tag
109+
* back to its own {@link RecordingBackend} instance, so core/platforms never construct
110+
* the daemon-owned backend objects. Present on families with a recording backend
111+
* (Apple + Android + web); left `undefined` for linux, where the hand branch fell
112+
* through to the unsupported backend — the daemon lookup preserves that fallthrough
113+
* (`?? 'unsupported'`), and the recording routing parity test pins the equivalence.
114+
*/
115+
readonly recording?: {
116+
resolveBackendTag(device: DeviceInfo): RecordingBackendTag;
117+
};
118+
/**
119+
* The daemon request-scope provider facet (issue #974). `platformGatedResolvers`
120+
* declares which PLATFORM-GATED request provider resolvers apply to this family's
121+
* devices — the DATA that replaces the hand `device.platform === …` gate formerly
122+
* open-coded inside each descriptor's `resolve` in
123+
* src/daemon/request-platform-providers.ts. The daemon still OWNS the resolver
124+
* functions, their wrapper composition, and the request-scope concurrency isolation;
125+
* this facet supplies only the per-family gate (a plain string list, the keys
126+
* type-only in the plugin). The ungated resolvers (`appLogProvider` /
127+
* `recordingProvider`, which apply on every platform) are intentionally NOT part of
128+
* the facet and stay ungated in the daemon. Every family carries this facet (each
129+
* owns at least one platform-specific resolver); a device on an unregistered platform
130+
* resolves to no gated resolvers, matching the former hand gate. Pinned by the
131+
* providers routing parity test.
132+
*/
133+
readonly providers?: {
134+
readonly platformGatedResolvers: readonly PlatformGatedProviderResolverKey[];
135+
};
96136
};
97137

98138
// The single registry instance: leaf platform -> owning plugin. A family plugin
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import {
4+
isApplePlatform,
5+
DEVICE_TARGETS,
6+
PLATFORMS,
7+
type DeviceInfo,
8+
type DeviceKind,
9+
type DeviceTarget,
10+
} from '../../kernel/device.ts';
11+
import {
12+
ANDROID_EMULATOR,
13+
ANDROID_TV_DEVICE,
14+
IOS_DEVICE,
15+
IOS_SIMULATOR,
16+
IPADOS_SIMULATOR,
17+
LINUX_DEVICE,
18+
MACOS_DEVICE,
19+
makeSession,
20+
TVOS_SIMULATOR,
21+
VISIONOS_SIMULATOR,
22+
WEB_DESKTOP_DEVICE,
23+
} from '../../__tests__/test-utils/index.ts';
24+
import { getPlugin, tryGetPlugin } from '../../core/platform-plugin/plugin.ts';
25+
import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts';
26+
import {
27+
withRequestPlatformProviderScope,
28+
type PlatformGatedProviderResolverKey,
29+
type PlatformProviderResolvers,
30+
} from '../request-platform-providers.ts';
31+
import type { DaemonRequest } from '../types.ts';
32+
33+
// Phase 3 step b.3 (issue #974) parity gate for the daemon request-scope provider
34+
// facet. The per-platform GATE that each descriptor in `request-platform-providers.ts`
35+
// open-coded (`device.platform === 'android'`, `isApplePlatform(...)`, etc.) now flows
36+
// through the PlatformPlugin `providers.platformGatedResolvers` facet. The daemon still
37+
// OWNS the resolver invocation, wrapper composition, and concurrency isolation — only
38+
// the gate moved to data. An INDEPENDENT verbatim copy of the former gates below is the
39+
// BEFORE oracle, checked at the facet level AND end-to-end through
40+
// `withRequestPlatformProviderScope`.
41+
42+
registerBuiltinPlatformPlugins();
43+
44+
// The platform-gated resolver keys, and the two ungated resolvers (no platform gate;
45+
// they apply on every platform and are NOT part of the facet).
46+
const GATED_KEYS: PlatformGatedProviderResolverKey[] = [
47+
'androidAdbProvider',
48+
'appleRunnerProvider',
49+
'appleToolProvider',
50+
'linuxToolProvider',
51+
'webProvider',
52+
];
53+
const UNGATED_KEYS = ['appLogProvider', 'recordingProvider'] as const;
54+
55+
// --- INDEPENDENT verbatim copy of the former per-descriptor platform gates ---
56+
function gatedResolversByHand(device: DeviceInfo): Set<PlatformGatedProviderResolverKey> {
57+
const applies = new Set<PlatformGatedProviderResolverKey>();
58+
if (device.platform === 'android') applies.add('androidAdbProvider'); // was `!== 'android'`
59+
if (isApplePlatform(device.platform)) {
60+
applies.add('appleRunnerProvider'); // was `!isApplePlatform(...)`
61+
applies.add('appleToolProvider'); // was `!isApplePlatform(...)`
62+
}
63+
if (device.platform === 'linux') applies.add('linuxToolProvider'); // was `!== 'linux'`
64+
if (device.platform === 'web') applies.add('webProvider'); // was `!== 'web'`
65+
return applies;
66+
}
67+
68+
// --- the exhaustive synthetic device matrix (every platform x kind x target) ---
69+
const DEVICE_KINDS_ALL: DeviceKind[] = ['simulator', 'emulator', 'device'];
70+
const DEVICE_TARGETS_ALL: (DeviceTarget | undefined)[] = [undefined, ...DEVICE_TARGETS];
71+
72+
function buildDeviceMatrix(): DeviceInfo[] {
73+
const devices: DeviceInfo[] = [];
74+
for (const platform of PLATFORMS) {
75+
for (const kind of DEVICE_KINDS_ALL) {
76+
for (const target of DEVICE_TARGETS_ALL) {
77+
devices.push({
78+
platform,
79+
id: `${platform}-${kind}-${target ?? 'none'}`,
80+
name: `${platform} ${kind} ${target ?? 'none'}`,
81+
kind,
82+
...(target ? { target } : {}),
83+
booted: true,
84+
});
85+
}
86+
}
87+
}
88+
return devices;
89+
}
90+
91+
const SAMPLE_DEVICES: DeviceInfo[] = [
92+
ANDROID_EMULATOR,
93+
ANDROID_TV_DEVICE,
94+
IOS_DEVICE,
95+
IOS_SIMULATOR,
96+
IPADOS_SIMULATOR,
97+
LINUX_DEVICE,
98+
MACOS_DEVICE,
99+
TVOS_SIMULATOR,
100+
VISIONOS_SIMULATOR,
101+
WEB_DESKTOP_DEVICE,
102+
...buildDeviceMatrix(),
103+
];
104+
105+
test('providers.platformGatedResolvers facet is byte-identical to the former hand gate', () => {
106+
for (const device of SAMPLE_DEVICES) {
107+
const facet = tryGetPlugin(device.platform)?.providers;
108+
const expected = gatedResolversByHand(device);
109+
for (const key of GATED_KEYS) {
110+
const applies = facet?.platformGatedResolvers.includes(key) ?? false;
111+
assert.equal(applies, expected.has(key), `gate for ${key} on ${device.id}`);
112+
}
113+
}
114+
});
115+
116+
test('every family carries the providers facet with the resolvers it owns', () => {
117+
// Apple owns ios + macos (SAME plugin instance). Unlike appLog/perf, EVERY family
118+
// owns at least one platform-specific resolver, so all four carry the facet.
119+
assert.equal(getPlugin('apple'), getPlugin('apple'));
120+
assert.deepEqual([...getPlugin('apple').providers!.platformGatedResolvers].sort(), [
121+
'appleRunnerProvider',
122+
'appleToolProvider',
123+
]);
124+
assert.deepEqual(
125+
[...getPlugin('android').providers!.platformGatedResolvers],
126+
['androidAdbProvider'],
127+
);
128+
assert.deepEqual(
129+
[...getPlugin('linux').providers!.platformGatedResolvers],
130+
['linuxToolProvider'],
131+
);
132+
assert.deepEqual([...getPlugin('web').providers!.platformGatedResolvers], ['webProvider']);
133+
});
134+
135+
// End-to-end routing proof: drive the REAL `withRequestPlatformProviderScope` with a
136+
// spy for every resolver and assert exactly the gated resolvers the former hand gate
137+
// admitted are invoked (plus the two ungated resolvers, on every platform). Each spy
138+
// returns `undefined`, so no wrapper is composed — but the resolver is still called iff
139+
// its gate passed, which is precisely what the former `device.platform === …` branch
140+
// decided. Breaking the facet flips which resolvers run and fails this test.
141+
test('withRequestPlatformProviderScope invokes exactly the resolvers the former gate did', async () => {
142+
for (const device of SAMPLE_DEVICES) {
143+
const invoked = new Set<string>();
144+
const spy = (key: string) => (): undefined => {
145+
invoked.add(key);
146+
return undefined;
147+
};
148+
const providers: PlatformProviderResolvers = {
149+
androidAdbProvider: spy('androidAdbProvider'),
150+
appleRunnerProvider: spy('appleRunnerProvider'),
151+
appleToolProvider: spy('appleToolProvider'),
152+
linuxToolProvider: spy('linuxToolProvider'),
153+
webProvider: spy('webProvider'),
154+
appLogProvider: spy('appLogProvider'),
155+
recordingProvider: spy('recordingProvider'),
156+
};
157+
158+
await withRequestPlatformProviderScope(
159+
{
160+
req: request('snapshot'),
161+
existingSession: makeSession(`prov-${device.id}`, { device }),
162+
providers,
163+
},
164+
async () => {},
165+
);
166+
167+
const gatedInvoked = new Set(GATED_KEYS.filter((key) => invoked.has(key)));
168+
assert.deepEqual(
169+
[...gatedInvoked].sort(),
170+
[...gatedResolversByHand(device)].sort(),
171+
`gated resolvers invoked for ${device.id}`,
172+
);
173+
for (const key of UNGATED_KEYS) {
174+
assert.ok(invoked.has(key), `ungated resolver ${key} must run for ${device.id}`);
175+
}
176+
}
177+
});
178+
179+
function request(command: string): DaemonRequest {
180+
return {
181+
token: 'test-token',
182+
session: 'default',
183+
command,
184+
positionals: [],
185+
flags: {},
186+
meta: { requestId: `req-${command}` },
187+
};
188+
}

0 commit comments

Comments
 (0)