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
40 changes: 26 additions & 14 deletions docs/adr/0022-daemon-platform-runtime-coupling.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ Amended 2026-10-11 by #3431: the daemon-runtime composition edges this
ADR classified (the gateway/diagnostics/resource-cleanup/lifecycle edges, the provider-runtime
hubs, and the platform-services and claim-recovery dynamic edges) moved out of `src/daemon/` into
the process-root composition module `src/daemon-composition.ts`, which R76 does not see because it
is no longer a daemon-zone importer. The R76 inventory now carries only the leaked
selector-query edge (`src/daemon/direct-ios-selector.ts`); the section-2 outcomes below remain the
record of how each was classified.
is no longer a daemon-zone importer. #3430 then routed the leaked selector-query edge
(`src/daemon/direct-ios-selector.ts`) through the request-bound runtime binding, so the R76
inventory is empty; with nothing left to classify, the same change collapsed R76 to a plain ban on
the coupling and deleted the classification machinery, per the rule's own kill criterion. The
section-2 outcomes below remain the record of how each was classified.

## 1. Context

Expand All @@ -30,19 +32,24 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`.
## 2. Decision

1. **Every daemon import of a root `src/platform-runtime-*.ts` module is classified as exactly
one of three categories**, and the classification is owned by the machine-readable inventory
one of three categories** (retired by the 2026-10-12 amendment below: the inventory
`DAEMON_PLATFORM_RUNTIME_EDGES` in
[`scripts/layering/daemon-platform-runtime-inventory.ts`](../../scripts/layering/daemon-platform-runtime-inventory.ts):
[`scripts/layering/daemon-platform-runtime-inventory.ts`](../../scripts/layering/daemon-platform-runtime-inventory.ts)
is now empty, so the classification table and its symbol-drift and staleness passes are deleted
and R76 bans the coupling outright — any production `src/daemon/**` import of the family or of a
root hub that reaches it is a violation. The categories and outcomes below remain the record of
how each edge was judged and retired):
- **Composition-essential** — assembling neutral runtime implementations at the process root.
- **Daemon-policy-essential** — request admission, session ownership, conflicts, locking,
cancellation, teardown ordering, artifact publication, or response/event semantics.
- **Leaked platform mechanics** — platform-family operations or state that should be owned
behind a deeper runtime interface or adapter; only this category creates implementation
work (child issues below).
The gate rule **R76 `daemon-platform-runtime-inventory`** (in `scripts/layering/check.ts`)
fails the layering check on any unclassified edge, on symbol-set drift between a file and its
inventory entry, and on stale entries. Observed red against a planted unclassified import
before acceptance.
originally failed the layering check on any unclassified edge, on symbol-set drift between a
file and its inventory entry, and on stale entries; observed red against a planted unclassified
import before acceptance. Since the amendment it fails on any production daemon import of the
family or a hub that reaches it, in any static or dynamic form.

2. **Per-edge outcomes at `27a97ee619`.** Four edges are composition-essential and stay
(`daemon-runtime.ts` → `platform-runtime.ts` and → `platform-runtime-host-diagnostics.ts`;
Expand Down Expand Up @@ -70,9 +77,11 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`.
relaunch-target policy, `session-selector-dispatch.ts` consumes the app-bundle-identity
resolver; both edges are daemon-policy-essential, and the resolver stays the one
construction path for the open plan.
- **#2273/#2274** (existing) — `direct-ios-selector.ts` → `queryAppleRuntimeSelector` is the
selector seam those issues own; coordination was posted there rather than opening a second
selector producer.
- **#2273/#2274 (done)** — the `direct-ios-selector.ts` → `queryAppleRuntimeSelector` edge is
retired. The live selector read is now the `querySelector` runtime operation on the
request-bound owner (`SelectorObservationRuntimeOperations`, declared CONDITIONAL on the
captured touch and gesture uses), so the off-screen rescue (#1542) executes on the binding
the command was already admitted for; no second selector producer was added.

3. **Session state and store authority are measured as a symbol-level overlay and ratcheted
on the handler-owned slice.** At `27a97ee619` the overlay is 112 `SessionState` shape edges
Expand Down Expand Up @@ -129,8 +138,11 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`.

## 4. Enforced by

- R76 `daemon-platform-runtime-inventory` and R75 `session-authority-overlay` in
`scripts/layering/check.ts` (both observed red against planted violations before acceptance).
- R76 `daemon-platform-runtime-inventory` — since the 2026-10-12 amendment a plain ban on the
coupling rather than a classification table (the inventory is empty and its machinery is
deleted) — and R75 `session-authority-overlay` in `scripts/layering/check.ts` (both observed red
against planted violations before acceptance).
- R7 `session-state-ownership` and the R10 merge-base ratchet for the owning-module slice.
- R65 for the concrete-platform-import ban this audit builds on.
- Child issues #2333 and #2334 landed (see §2.2); #2273/#2274 remain for the selector seam.
- Child issues #2333 and #2334 landed (see §2.2); #2273/#2274 landed with the `querySelector`
runtime operation (see §2.2).
5 changes: 2 additions & 3 deletions docs/adr/0032-layering-graph-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ whose production tree is `7dda0c2bf`'s; the harness lives only at that commit
`resolveImportEdges`. Do not enforce a subset of them from a second, disagreeing import graph.
- The model's implementation remains replaceable. Besides these eight rules, it must supply
resolved edges with value, type-only and dynamic kinds to R9/R10, R19, R73–R76 and
`scripts/depgraph/`; named symbols and the binding residue of dynamic imports to R76; and raw
specifier sites to R11 and R13. Eleven other registry entries read source ASTs or text only, and
one reads paths only.
`scripts/depgraph/`; named symbols to R75; and raw specifier sites to R11 and R13. Eleven other
registry entries read source ASTs or text only, and one reads paths only.
- Fix the demonstrated parser gap in #3293: `parseImports` misses TypeScript `import('x').T` type
positions (9 file pairs). It already parses an OXC AST for dynamic imports but scans static
imports with regexes; read both from that AST, `TSImportType` included, keeping symbol and
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
| [0019 Request-Bound Platform Runtime](0019-request-bound-platform-runtime.md) | platform-package boundaries/composition, device discovery, runtime facts/facets, request binding, provider ownership, platform-shaped session resources, durable reattachment, daemon-handler migration |
| [0020 Composable Recorded Fragments (Proposed)](0020-composable-recorded-fragments.md) | lifecycle-free recorded fragment capture/composition, entry guards, fragment-local addresses/digests, staleness, and native `.ad`/Maestro composition |
| [0021 Simlock-Leased Devices Through Ordinary Local Runtimes](0021-host-simlock-managed-device-allocation.md) | driving a Simlock-leased simulator or emulator, why agent-device has no allocator integration, and the withdrawn in-process Host design |
| [0022 Daemon — Platform Runtime Coupling Audit and Ownership Ratchets](0022-daemon-platform-runtime-coupling.md) | daemon imports of root `platform-runtime-*` modules, the R76 edge classification inventory, R75 session-authority ratchet, entry-to-platform hop routes and roles |
| [0022 Daemon — Platform Runtime Coupling Audit and Ownership Ratchets](0022-daemon-platform-runtime-coupling.md) | daemon imports of root `platform-runtime-*` modules, the retired R76 edge classification now collapsed to a coupling ban, R75 session-authority ratchet, entry-to-platform hop routes and roles |
| [0023 End-State Entry-to-Platform Hop Trace](0023-end-state-hop-trace.md) | the hop counting definition, the ordered `press`/Android and dual-arm `snapshot`/iOS chains, per-hop roles, and the deletion test behind the numbers ADR 0019 and ADR 0022 quote |
| [0024 Screen Recording — One Coordinator, Two Facts (Proposed)](0024-screen-recording-provable-signal.md) | `record start`/`stop` lifecycle, the export-exists / recorder-stopped split, backend stop-and-collect contract, recovery through the coordinator, the ADR 0019 §5 amendment and the failed-finish cleanup fix |
| [0025 Foldable Apple Panels — Capture the Lit Panel](0025-foldable-apple-panels.md) | iPhone Duo outer/inner panels, CoreDevice display info as the only screen authority, why `simctl`'s implicit display default captures a black panel, derived (never asserted) pose, the `fold` pose control, and the Android emulator posture amendment |
Expand Down
18 changes: 16 additions & 2 deletions packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,24 +144,32 @@ export const perfRuntimePlanUses = Object.freeze([
perfProfileReportUse,
] as const);
export const tapPointUse = defineUse({ required: ['tapPoint'] });
// `querySelector` is the CONDITIONAL off-screen rescue read (#1542): the acting rows refuse an
// off-screen target from the required capture, and an owner that can answer with a live,
// tree-independent read gets one chance to overturn a false refusal. Both sides are complete:
// an owner without it refuses on the capture's verdict unchanged (ADR 0019 §9 parity), which is
// exactly the pre-rescue behavior every non-Apple owner always had.
export const capturedTapUse = defineUse({
required: ['captureSnapshot', 'tapPoint'],
preferred: ['tapRef'],
conditional: ['tapElementSelector'],
conditional: ['tapElementSelector', 'querySelector'],
});
export const longPressPointUse = defineUse({ required: ['longPressPoint'] });
export const capturedLongPressUse = defineUse({
required: ['captureSnapshot', 'longPressPoint'],
conditional: ['querySelector'],
});
export const hoverPointUse = defineUse({ required: ['hoverPoint'] });
export const capturedHoverUse = defineUse({
required: ['captureSnapshot', 'hoverPoint'],
preferred: ['hoverRef'],
conditional: ['querySelector'],
});
export const fillPointUse = defineUse({ required: ['fillPoint'] });
export const capturedFillUse = defineUse({
required: ['captureSnapshot', 'fillPoint'],
preferred: ['fillRef'],
conditional: ['querySelector'],
});

export const clickRuntimeUses = Object.freeze([tapPointUse, capturedTapUse] as const);
Expand Down Expand Up @@ -223,23 +231,29 @@ export function resolveTouchRuntimePlan(
* handler binds once — for the whole `swipe --count N` series as much as for one `gesture pinch`.
*
* Every tier binds snapshot capture with its action because capture is the fallback when the owner
* has no direct viewport read. `gestureViewport` remains preferred.
* has no direct viewport read. `gestureViewport` remains preferred. `querySelector` is the
* CONDITIONAL off-screen rescue read the gesture rows' endpoints resolve against (same parity
* argument as `capturedTapUse`): an owner without it refuses on the capture's verdict unchanged.
*/
const gesturePlanUse = defineUse({
required: ['performGesturePlan', 'captureSnapshot'],
preferred: ['gestureViewport'],
conditional: ['querySelector'],
});
const gestureDirectionalFlingUse = defineUse({
required: ['performDirectionalFlingPlan', 'captureSnapshot'],
preferred: ['gestureViewport'],
conditional: ['querySelector'],
});
const gestureMultiTouchUse = defineUse({
required: ['performMultiTouchGesturePlan', 'captureSnapshot'],
preferred: ['gestureViewport'],
conditional: ['querySelector'],
});
const gestureTargetAuthoredDragUse = defineUse({
required: ['performTargetAuthoredDrag', 'captureSnapshot'],
preferred: ['gestureViewport'],
conditional: ['querySelector'],
});

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,11 @@ export function createUnavailablePlatformRuntimeFacts(
}),
// The preferred text reading starts unavailable for every family, on the same sentinel as
// capture: an owner that has a native reading declares it explicitly, and one that does not
// sends every text wait to the canonical tree.
// sends every text wait to the canonical tree. The live selector read starts unavailable for
// the same reason: an owner without it refuses on its bulk-tree verdict unchanged.
...selectorObservationRuntimeOperationFacts({
findText: frozen.snapshot,
querySelector: frozen.snapshot,
}),
...viewportRuntimeOperationFacts({ setViewport: frozen.viewport }),
...focusRuntimeOperationFacts({ focus: frozen.focus }),
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/runtime-operation-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const RUNTIME_OPERATION_NAMES = [
'performTargetAuthoredDrag',
'prepareAppleRunner',
'prepareApplicationOpen',
'querySelector',
'readAlert',
'readClipboard',
'readSetting',
Expand Down
31 changes: 30 additions & 1 deletion packages/contracts/src/selector-observation-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Rect } from '@agent-device/kernel/snapshot';
import type { ElementSelectorKey } from './interactor-types.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
import type { SessionSurface } from './session-surface.ts';
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';
Expand All @@ -10,6 +12,9 @@ type ObservationInput = Readonly<{

export type FindTextInput = ObservationInput & Readonly<{ text: string }>;

export type QuerySelectorInput = ObservationInput &
Readonly<{ selector: Readonly<{ key: ElementSelectorKey; value: string }> }>;

/**
* Positive native observations are authoritative and preserve matches that an advertising owner's
* bulk capture may omit. A negative observation means only "not proven by this owner"; the caller
Expand All @@ -19,17 +24,41 @@ export type FindTextInput = ObservationInput & Readonly<{ text: string }>;
export type SelectorObservationResult = Readonly<{ found: boolean }>;
export type FindTextResult = SelectorObservationResult;

/**
* The live geometry the owner read for the matched node, projected to the two fields the
* off-screen rescue consumes. Owners without a node-scoped live read report the operation
* unavailable; the caller then refuses on its bulk-tree verdict unchanged.
*/
export type QuerySelectorNodeReading = Readonly<{
rect: Rect;
hittable: boolean;
}>;

export type QuerySelectorResult = SelectorObservationResult &
Readonly<{
node?: QuerySelectorNodeReading;
}>;

export type SelectorObservationRuntimeOperations = Readonly<{
findText(input: FindTextInput): Promise<FindTextResult>;
querySelector(input: QuerySelectorInput): Promise<QuerySelectorResult>;
}>;
export type FindTextRuntimeOperations = Pick<SelectorObservationRuntimeOperations, 'findText'>;
export type QuerySelectorRuntimeOperations = Pick<
SelectorObservationRuntimeOperations,
'querySelector'
>;

export type SelectorObservationRuntimeOperationFacts = Readonly<{
findText: RuntimeOperationFact;
querySelector: RuntimeOperationFact;
}>;

export function selectorObservationRuntimeOperationFacts(
input: SelectorObservationRuntimeOperationFacts,
): SelectorObservationRuntimeOperationFacts {
return Object.freeze({ findText: input.findText });
return Object.freeze({
findText: input.findText,
querySelector: input.querySelector,
});
}
4 changes: 3 additions & 1 deletion packages/platform-android/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,11 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
...screenshotRuntimeOperationFacts({
capture: device.kind === 'simulator' ? screenshotKindUnavailable : available,
}),
// No native text reading: every text wait on this owner polls the canonical tree.
// No native text reading: every text wait on this owner polls the canonical tree. No live
// selector read either: the off-screen guard refuses on the capture's verdict unchanged.
...selectorObservationRuntimeOperationFacts({
findText: snapshotKindUnavailable,
querySelector: snapshotKindUnavailable,
}),
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
...focusRuntimeOperationFacts({ focus: androidTouchFact(device) }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { IOS_SIMULATOR } from '../__tests__/device-fixtures.ts';

const { queryAppleRunnerSelector } = vi.hoisted(() => ({
queryAppleRunnerSelector: vi.fn(async (): Promise<Record<string, unknown>> => ({
found: true,
})),
}));

vi.mock('../core/runner-selector-query.ts', () => ({ queryAppleRunnerSelector }));

import { bindAppleQuerySelectorRuntime } from '../runtime-selector-query.ts';

beforeEach(() => {
queryAppleRunnerSelector.mockReset();
queryAppleRunnerSelector.mockResolvedValue({ found: true });
});

const APP = 'com.example.app';

function bind(signal?: AbortSignal) {
return bindAppleQuerySelectorRuntime({
device: IOS_SIMULATOR,
signal: signal ?? new AbortController().signal,
});
}

test('forwards the selector, app scope, and the full runner-execution projection', async () => {
await bind().querySelector({
selector: { key: 'id', value: 'submit-order' },
options: { appBundleId: APP },
execution: {
Comment thread
thymikee marked this conversation as resolved.
requestId: 'req-9',
logPath: '/tmp/runner.log',
traceLogPath: '/tmp/trace.log',
verbose: true,
iosXctestrunFile: '/tmp/run.xctestrun',
},
});

expect(queryAppleRunnerSelector).toHaveBeenCalledWith(
IOS_SIMULATOR,
{ key: 'id', value: 'submit-order' },
APP,
expect.objectContaining({
requestId: 'req-9',
logPath: '/tmp/runner.log',
traceLogPath: '/tmp/trace.log',
verbose: true,
iosXctestrunFile: '/tmp/run.xctestrun',
}),
);
});

test('refuses an already-aborted binding signal without querying the runner', async () => {
const controller = new AbortController();
controller.abort();

await expect(
bind(controller.signal).querySelector({ selector: { key: 'label', value: 'Go' } }),
Comment thread
thymikee marked this conversation as resolved.
).rejects.toMatchObject({ name: 'AbortError' });
expect(queryAppleRunnerSelector).not.toHaveBeenCalled();
});

test('projects the matched node to its rect and hittability', async () => {
queryAppleRunnerSelector.mockResolvedValue({
found: true,
nodes: [{ index: 0, rect: { x: 1, y: 2, width: 3, height: 4 }, hittable: true }],
});

await expect(bind().querySelector({ selector: { key: 'label', value: 'Go' } })).resolves.toEqual({
found: true,
node: { rect: { x: 1, y: 2, width: 3, height: 4 }, hittable: true },
});
});

test('a miss answers found:false with no node', async () => {
queryAppleRunnerSelector.mockResolvedValue({ found: false, nodes: [] });

await expect(
bind().querySelector({ selector: { key: 'label', value: 'Gone' } }),
).resolves.toEqual({ found: false });
});

test('a node without a usable rect answers found without a node (the rescue stays fail-closed)', async () => {
queryAppleRunnerSelector.mockResolvedValue({ found: true, nodes: [{ index: 0 }] });

await expect(bind().querySelector({ selector: { key: 'label', value: 'Go' } })).resolves.toEqual({
found: true,
});
});
1 change: 1 addition & 0 deletions packages/platform-apple/src/runner-demand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly<
captureSnapshotWithCustomActions: 'runner',
captureScreenshot: 'simulator',
findText: 'runner',
querySelector: 'runner',
readTextAtPoint: 'runner',
// Every interaction and runner-driven capture.
tapPoint: 'runner',
Expand Down
1 change: 0 additions & 1 deletion packages/platform-apple/src/runner-operations-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export {
verifyLeaseRunnerPidIdentity,
writeRunnerCacheMetadataForArtifacts,
} from './core/runner-client.ts';
export { queryAppleRunnerSelector } from './core/runner-selector-query.ts';

export async function cleanupRunnerLeasesForOwner(
owner: Parameters<(typeof import('./core/runner-client.ts'))['cleanupRunnerLeasesForOwner']>[0],
Expand Down
Loading
Loading