Skip to content

Commit deda2a5

Browse files
committed
refactor(platforms): break the four upward edges out of src/platforms (#2082 W2)
src/platforms carried four imports that point up into root src, each of which would become an R11 violation the moment its family moves into a platform package: - android/app-helpers.ts reached the composition root for the foreground parser. The parser is pure dumpsys vocabulary, so it moves to @agent-device/contracts/android-observation; the platform-android app-state module and app-helpers both consume it from there, and the composition wrapper plus the package facade's lazy re-export retire (R13 allows only the composition root to import platform packages, so vocabulary relocation is the inversion that stays legal). - web/provider.ts and web/agent-browser-network.ts type-imported the backend diagnostics/network-dump vocabulary from src/backend.ts. Those six types move to @agent-device/contracts/backend-diagnostics; backend.ts re-exports them for its SDK consumers. - snapshot/snapshot-desktop-surface.ts split three ways: the pure projection (scope/interactive/depth) moves to @agent-device/contracts/snapshot-desktop-projection, the per-family captures move to platforms/linux/surface-snapshot.ts and platforms/apple/os/macos/surface-snapshot.ts beside the code they dispatch to, and the root file keeps only the device-dispatching runtime host behind R3-tolerated dynamic imports. apple/interactor now reaches macOS surface capture family-internally instead of through root. src/platforms -> root src is now zero edges. Contracts grows two entries (pinned, budgeted); the android foreground-parser tests move beside the parser. Left for a later pass: app-parsers.ts shares the marker-walk loop shape with the contracts parser but parameterizes it for blocking-dialog parsing - generalizing that is a design change, not a move. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH
1 parent fccf8c1 commit deda2a5

22 files changed

Lines changed: 272 additions & 244 deletions

‎packages/contracts/package.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@
127127
"types": "./src/back-runtime.ts",
128128
"default": "./src/back-runtime.ts"
129129
},
130+
"./backend-diagnostics": {
131+
"types": "./src/backend-diagnostics.ts",
132+
"default": "./src/backend-diagnostics.ts"
133+
},
130134
"./boot-failure": {
131135
"types": "./src/boot-failure.ts",
132136
"default": "./src/boot-failure.ts"
@@ -415,6 +419,10 @@
415419
"types": "./src/facades/snapshot.ts",
416420
"default": "./src/facades/snapshot.ts"
417421
},
422+
"./snapshot-desktop-projection": {
423+
"types": "./src/snapshot-desktop-projection.ts",
424+
"default": "./src/snapshot-desktop-projection.ts"
425+
},
418426
"./snapshot-presentation": {
419427
"types": "./src/snapshot-presentation.ts",
420428
"default": "./src/snapshot-presentation.ts"

packages/platform-android/src/app-state.test.ts renamed to packages/contracts/src/android-observation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest';
2-
import { parseAndroidForegroundApp } from './app-state.ts';
2+
import { parseAndroidForegroundApp } from './android-observation.ts';
33

44
test('parses Android window and activity foreground markers', () => {
55
expect(

‎packages/contracts/src/android-observation.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,29 @@ export type AndroidObservationAdapter = Readonly<{
4848
readScreenSize(device: DeviceInfo): Promise<Readonly<{ width: number; height: number }>>;
4949
isPermissionPackage(packageName: string): Promise<boolean>;
5050
}>;
51+
52+
const ANDROID_FOCUS_MARKERS = [
53+
'mCurrentFocus=Window{',
54+
'mFocusedApp=AppWindowToken{',
55+
'mResumedActivity:',
56+
'ResumedActivity:',
57+
] as const;
58+
59+
/** Extracts the foreground package/activity from `dumpsys window`/`activity` output. */
60+
export function parseAndroidForegroundApp(text: string): AppStateRuntimeResult | null {
61+
const lines = text.split('\n');
62+
for (const marker of ANDROID_FOCUS_MARKERS) {
63+
for (const line of lines) {
64+
const markerIndex = line.indexOf(marker);
65+
if (markerIndex === -1) continue;
66+
const parsed = parseAndroidComponentFromSegment(line.slice(markerIndex + marker.length));
67+
if (parsed) return parsed;
68+
}
69+
}
70+
return null;
71+
}
72+
73+
function parseAndroidComponentFromSegment(segment: string): AppStateRuntimeResult | null {
74+
const match = segment.match(/\b([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)\/([A-Za-z0-9_.$]+)/);
75+
return match?.[1] && match[2] ? { package: match[1], activity: match[2] } : null;
76+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { NetworkIncludeMode } from '@agent-device/kernel/contracts';
2+
3+
export type BackendDiagnosticsTimeWindow = {
4+
since?: string;
5+
until?: string;
6+
};
7+
8+
export type BackendDiagnosticsPageOptions = BackendDiagnosticsTimeWindow & {
9+
cursor?: string;
10+
limit?: number;
11+
};
12+
13+
export type BackendNetworkIncludeMode = NetworkIncludeMode;
14+
15+
export type BackendNetworkEntry = {
16+
timestamp?: string;
17+
method?: string;
18+
url?: string;
19+
status?: number;
20+
durationMs?: number;
21+
requestHeaders?: Record<string, string>;
22+
responseHeaders?: Record<string, string>;
23+
requestBody?: string;
24+
responseBody?: string;
25+
metadata?: Record<string, unknown>;
26+
};
27+
28+
export type BackendDumpNetworkOptions = BackendDiagnosticsPageOptions & {
29+
include?: BackendNetworkIncludeMode;
30+
};
31+
32+
export type BackendDumpNetworkResult = {
33+
entries: readonly BackendNetworkEntry[];
34+
nextCursor?: string;
35+
timeWindow?: BackendDiagnosticsTimeWindow;
36+
backend?: string;
37+
redacted?: boolean;
38+
notes?: readonly string[];
39+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { expect, test } from 'vitest';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { scopeSnapshotNodes } from './snapshot-desktop-projection.ts';
5+
6+
// Golden scope-policy leg (#1832 C2): the post-wire pass must agree with
7+
// contracts/fixtures/snapshot-scope-policy.json — the same table the Android projection and the
8+
// shared predicate are asserted against. Fixture position rides in `rect.x` so the check is
9+
// text-independent.
10+
test('scopeSnapshotNodes agrees with every golden scope-policy table case', () => {
11+
const cases = JSON.parse(
12+
fs.readFileSync(
13+
path.resolve(import.meta.dirname, '../../../contracts/fixtures/snapshot-scope-policy.json'),
14+
'utf8',
15+
),
16+
) as Array<{
17+
name: string;
18+
scope: string;
19+
nodes: Array<{
20+
depth: number;
21+
label?: string;
22+
value?: string;
23+
identifier?: string;
24+
presented?: boolean;
25+
}>;
26+
expectedSubtreeIndexes: number[];
27+
}>;
28+
expect(cases.length).toBeGreaterThan(0);
29+
for (const fixture of cases) {
30+
const parents: number[] = [];
31+
const nodes = fixture.nodes.map((node, index) => {
32+
parents.length = node.depth;
33+
const parentIndex = node.depth > 0 ? parents[node.depth - 1] : undefined;
34+
parents[node.depth] = index;
35+
return { ...node, index, parentIndex, rect: { x: index, y: 0, width: 1, height: 1 } };
36+
});
37+
const scoped = scopeSnapshotNodes(nodes, fixture.scope, (range) =>
38+
nodes.slice(range.start, range.end).some((node) => node.presented !== false),
39+
);
40+
expect(
41+
scoped.map((node) => node.rect?.x),
42+
fixture.name,
43+
).toEqual(fixture.expectedSubtreeIndexes);
44+
if (scoped.length > 0) expect(scoped[0]?.depth, fixture.name).toBe(0);
45+
}
46+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
2+
import type { SnapshotOptions, SnapshotResult } from './interactor-types.ts';
3+
import {
4+
findSnapshotScopeRange,
5+
normalizeSnapshotScope,
6+
reindexSnapshotNodes,
7+
} from './snapshot-scope.ts';
8+
9+
const INTERACTIVE_ROLE_TOKENS = [
10+
'button',
11+
'menu',
12+
'textfield',
13+
'searchfield',
14+
'checkbox',
15+
'radio',
16+
'switch',
17+
] as const;
18+
19+
/** Applies the legacy desktop-surface projection once for both runtime hosts and legacy capture. */
20+
export function shapeDesktopSurfaceSnapshot(
21+
data: SnapshotResult,
22+
options: Pick<SnapshotOptions, 'depth' | 'interactiveOnly' | 'scope'>,
23+
): SnapshotResult {
24+
let nodes = data.nodes ?? [];
25+
if (options.scope) {
26+
nodes = scopeSnapshotNodes(nodes, options.scope, (range) =>
27+
options.interactiveOnly
28+
? nodes.slice(range.start, range.end).some(isInteractiveSnapshotNode)
29+
: true,
30+
);
31+
}
32+
if (options.interactiveOnly) nodes = filterInteractiveSnapshotNodes(nodes);
33+
if (typeof options.depth === 'number') nodes = filterSnapshotNodesByDepth(nodes, options.depth);
34+
return { ...data, nodes };
35+
}
36+
37+
/** The shared scope specification applied post-wire (`./snapshot-scope.ts`). */
38+
export function scopeSnapshotNodes(
39+
nodes: RawSnapshotNode[],
40+
scope: string,
41+
subtreeContributes?: (range: { start: number; end: number }) => boolean,
42+
): RawSnapshotNode[] {
43+
const normalizedScope = normalizeSnapshotScope(scope);
44+
if (!normalizedScope) return reindexSnapshotNodes(nodes);
45+
const range = findSnapshotScopeRange(nodes, normalizedScope, subtreeContributes);
46+
if (!range) return [];
47+
const slice = nodes.slice(range.start, range.end);
48+
return reindexSnapshotNodes(slice, slice[0]?.depth ?? 0);
49+
}
50+
51+
function filterInteractiveSnapshotNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
52+
if (nodes.length === 0) return nodes;
53+
const byIndex = new Map(nodes.map((node) => [node.index, node]));
54+
const keepIndexes = new Set<number>();
55+
for (const node of nodes) {
56+
if (!isInteractiveSnapshotNode(node)) continue;
57+
let current: RawSnapshotNode | undefined = node;
58+
while (current) {
59+
if (keepIndexes.has(current.index)) break;
60+
keepIndexes.add(current.index);
61+
current =
62+
typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined;
63+
}
64+
}
65+
if (keepIndexes.size === 0) return nodes;
66+
return reindexSnapshotNodes(nodes.filter((node) => keepIndexes.has(node.index)));
67+
}
68+
69+
function filterSnapshotNodesByDepth(nodes: RawSnapshotNode[], maxDepth: number): RawSnapshotNode[] {
70+
return reindexSnapshotNodes(nodes.filter((node) => (node.depth ?? 0) <= maxDepth));
71+
}
72+
73+
function isInteractiveSnapshotNode(node: RawSnapshotNode): boolean {
74+
if ([node.focused, node.hittable, node.rect].some(Boolean)) return true;
75+
const role = `${node.type ?? ''} ${node.role ?? ''} ${node.subrole ?? ''}`.toLowerCase();
76+
return INTERACTIVE_ROLE_TOKENS.some((token) => role.includes(token));
77+
}

‎packages/platform-android/src/app-state.ts‎

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
AppStateRuntimeResult,
55
} from '@agent-device/contracts/app-state-runtime';
66
import type { DeviceInfo } from '@agent-device/kernel/device';
7+
import { parseAndroidForegroundApp } from '@agent-device/contracts/android-observation';
78

89
export type AndroidAppStateHost = Readonly<{
910
run(
@@ -21,12 +22,6 @@ const ACTIVITY_COMMANDS = [
2122
['shell', 'dumpsys', 'activity', 'activities'],
2223
['shell', 'dumpsys', 'activity'],
2324
] as const;
24-
const ANDROID_FOCUS_MARKERS = [
25-
'mCurrentFocus=Window{',
26-
'mFocusedApp=AppWindowToken{',
27-
'mResumedActivity:',
28-
'ResumedActivity:',
29-
] as const;
3025

3126
export async function readAndroidAppState(
3227
host: AndroidAppStateHost,
@@ -41,19 +36,6 @@ export async function readAndroidAppState(
4136
return {};
4237
}
4338

44-
export function parseAndroidForegroundApp(text: string): AppStateRuntimeResult | null {
45-
const lines = text.split('\n');
46-
for (const marker of ANDROID_FOCUS_MARKERS) {
47-
for (const line of lines) {
48-
const markerIndex = line.indexOf(marker);
49-
if (markerIndex === -1) continue;
50-
const parsed = parseAndroidComponentFromSegment(line.slice(markerIndex + marker.length));
51-
if (parsed) return parsed;
52-
}
53-
}
54-
return null;
55-
}
56-
5739
async function readAndroidFocus(
5840
host: AndroidAppStateHost,
5941
device: DeviceInfo,
@@ -69,8 +51,3 @@ async function readAndroidFocus(
6951
}
7052
return null;
7153
}
72-
73-
function parseAndroidComponentFromSegment(segment: string): AppStateRuntimeResult | null {
74-
const match = segment.match(/\b([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)\/([A-Za-z0-9_.$]+)/);
75-
return match?.[1] && match[2] ? { package: match[1], activity: match[2] } : null;
76-
}

‎packages/platform-android/src/index.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,6 @@ export async function readAndroidAppState(
5555
return await read(host, device, signal);
5656
}
5757

58-
export async function parseAndroidForegroundApp(
59-
text: string,
60-
): Promise<Readonly<{ package?: string; activity?: string }> | null> {
61-
const { parseAndroidForegroundApp: parse } = await import('./app-state.ts');
62-
return parse(text);
63-
}
64-
6558
export const runtimeModule = Object.freeze({
6659
...metadata,
6760
loadRuntime: async (host) => {

‎scripts/layering/package-boundaries.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const CONTRACT_EXPORTS = [
7575
'@agent-device/contracts/audio-probe-support',
7676
'@agent-device/contracts/audio-runtime-plan',
7777
'@agent-device/contracts/back-mode',
78+
'@agent-device/contracts/backend-diagnostics',
7879
'@agent-device/contracts/back-runtime',
7980
'@agent-device/contracts/boot-failure',
8081
'@agent-device/contracts/capture',
@@ -148,6 +149,7 @@ const CONTRACT_EXPORTS = [
148149
'@agent-device/contracts/settings',
149150
'@agent-device/contracts/settings-runtime',
150151
'@agent-device/contracts/snapshot',
152+
'@agent-device/contracts/snapshot-desktop-projection',
151153
'@agent-device/contracts/snapshot-presentation',
152154
'@agent-device/contracts/snapshot-runtime',
153155
'@agent-device/contracts/snapshot-timeout-evidence',

‎src/__tests__/eager-closure-budgets.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
169169
'packages/contracts/src/audio-probe-support.ts': 5,
170170
'packages/contracts/src/audio-runtime-plan.ts': 5,
171171
'packages/contracts/src/back-mode.ts': 1,
172+
'packages/contracts/src/backend-diagnostics.ts': 1,
172173
'packages/contracts/src/boot-failure.ts': 1,
173174
'packages/contracts/src/click-button.ts': 3,
174175
'packages/contracts/src/clipboard.ts': 1,
@@ -235,6 +236,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
235236
'packages/contracts/src/scroll-runtime.ts': 4,
236237
'packages/contracts/src/selector-observation-runtime.ts': 1,
237238
'packages/contracts/src/settings.ts': 3,
239+
'packages/contracts/src/snapshot-desktop-projection.ts': 2,
238240
'packages/contracts/src/snapshot-presentation.ts': 2,
239241
'packages/contracts/src/snapshot-runtime.ts': 3,
240242
'packages/contracts/src/snapshot-timeout-evidence.ts': 1,

0 commit comments

Comments
 (0)