Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ff9b56f
perf(ios): derive runner demand for Simulator opens and stop observat…
thymikee Sep 5, 2026
b2e405f
test(fixtures): share one inert audio-probe host across the platform …
thymikee Sep 5, 2026
15d71d6
fix(ios): answer runner liveness through the runner provider seam
thymikee Sep 5, 2026
1024887
perf(ios): select plan uses from step input and give young Simulator …
thymikee Sep 5, 2026
0673fcd
fix(ios): a registered runner session counts as live only once it has…
thymikee Sep 6, 2026
04b8381
refactor(ios): lift the bridge launch grace out of the snapshot route…
thymikee Sep 6, 2026
710e70a
test(descriptors): pin the snapshot, diff, and find step-use selectors
thymikee Sep 6, 2026
2cb58c1
test: stub runner operations in the replay test-runner suite and keep…
thymikee Sep 6, 2026
fea26bf
fix(plan): count only required operations and read find and snapshot …
thymikee Sep 6, 2026
9b00fe0
perf(ios): let open wait for the launched app to become observable, a…
thymikee Sep 6, 2026
c085401
docs(context): keep the runner-demand vocabulary within the guidance …
thymikee Sep 6, 2026
6cfc93e
refactor(contracts): name the runtime operation vocabulary below the …
thymikee Sep 6, 2026
646b2a5
fix(apple): reach runner liveness through the memoized operations loader
thymikee Sep 6, 2026
d654cc3
fix(ios): keep bridge-only behavior to iOS Simulators
thymikee Sep 6, 2026
60b5e35
bench(ios): add a first-interaction cell to the snapshot convergence …
thymikee Sep 6, 2026
e319193
bench(ios): read the deep-link confirmation from a snapshot and by no…
thymikee Sep 6, 2026
7616ba2
refactor(plan): keep the step-use selectors inside the registry
thymikee Sep 6, 2026
9189275
feat(apple): release a speculative runner when the plan is proven obs…
thymikee Sep 6, 2026
b36d2ce
Merge origin/main into codex/2198-runner-demand
thymikee Sep 6, 2026
41bdc69
Merge origin/main into codex/2198-runner-demand
thymikee Sep 6, 2026
adec882
bench(ios): press an unambiguous target on the catalog and Settings s…
thymikee Sep 6, 2026
e729321
fix(ios): keep observation on the bridge while app discovery is pendi…
thymikee Sep 6, 2026
325343d
fix(apple): queue a speculative-runner release behind a start that is…
thymikee Sep 7, 2026
a23f495
Merge remote-tracking branch 'origin/main' into codex/2198-runner-demand
thymikee Sep 7, 2026
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
10 changes: 10 additions & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@
"file": "packages/platform-apple/src/app-resolution-facade.ts",
"exports": ["buildAppNotInstalledError"]
},
{
"comment": "Runner operations reach the daemon only through the memoized loader in src/platform-runtime-apple-application-tools.ts (one dynamic import per specifier, #2314). Fallow cannot connect a member read off that loader's promise to these re-exports; keep the list to the facade members no static import consumes.",
"file": "packages/platform-apple/src/runner-operations-facade.ts",
"exports": [
"detachIosSimulatorRunnerSessionsForShutdown",
"hasLiveIosRunnerSession",
"releaseSpeculativeIosRunnerSessionFor",
"stopAllIosRunnerSessions"
]
},
{
"comment": "Apple install mechanics are reached through the named install-artifact façade. Fallow cannot connect workspace package exports to these source exports; keep this list limited to the actual facade re-exports.",
"file": "packages/platform-apple/src/core/install-artifact.ts",
Expand Down
15 changes: 8 additions & 7 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,24 +110,25 @@ A device-scoped pause on agent mutations during human operation.
### Commands and routing

**Command surface**:
The catalog of public command identity, interface exposure, adapter policy, and metadata across
entrypoints.
The catalog of public command identity, exposure, adapter policy, and metadata across entrypoints.

**Runtime use**:
A command's platform-neutral declaration of required operations and optional preferred fast paths.
A command's platform-neutral declaration of required operations and preferred fast paths.

**Inventory use**:
An inventory command's platform-neutral declaration for composing device sources without binding one.
An inventory command's platform-neutral declaration for composing device sources unbound.

**Daemon command registry**:
The daemon-side truth for route ownership and request-policy traits.

**Runner command traits**:
Per-command classifications controlling Apple runner lifecycle and recovery behavior
independently of the public command surface.
Per-command classes steering Apple runner lifecycle and recovery, independent of the public surface.

**Runner demand**:
What a Simulator open prepares, or releases unused, of the XCTest runner for remaining steps.

**Daemon RPC protocol version**:
The integer used to detect breaking compatibility across the remote daemon boundary.
The integer that detects breaking compatibility across the remote daemon boundary.

**Version-skew invariant**:
Local client and daemon versions must match; only remote daemons, separately versioned helpers,
Expand Down
4 changes: 4 additions & 0 deletions packages/command-registry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"types": "./src/registry.ts",
"default": "./src/registry.ts"
},
"./planned-operations": {
"types": "./src/planned-operations.ts",
"default": "./src/planned-operations.ts"
},
"./catalog": {
"types": "./src/catalog.ts",
"default": "./src/catalog.ts"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { commandDescriptors, selectFindStepUses } from '../registry.ts';
import { expect, test } from 'vitest';
import { findRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations';
import { commandDescriptors } from '../registry.ts';

test('find descriptor declares its complete runtime uses with no legacy projection', () => {
const find = commandDescriptors.find(({ name }) => name === 'find');

expect(find).not.toHaveProperty('capability');
// Plan-time consumers select the alternative from the step input the way the handler does.
expect(find?.platformExecution).toEqual({
kind: 'device-runtime',
uses: findRuntimePlanUses,
selectUses: selectFindStepUses,
});
// The complete direct-execution surface, action-selected so the handler binds exactly once
// (ADR 0019 §9): the read-only element-text plans shared with `get`, the plain capture pair
Expand Down
97 changes: 97 additions & 0 deletions packages/command-registry/src/__tests__/planned-operations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { commandDescriptors } from '../registry.ts';
import { resolvePlannedRuntimeOperations } from '../planned-operations.ts';

const steps = (...commands: string[]) =>
commands.map((command) => ({ command, positionals: [], flags: {} }));

test('observation commands require only capture and selector-observation operations', () => {
const operations = resolvePlannedRuntimeOperations(steps('snapshot', 'wait', 'is', 'screenshot'));
assert.ok(operations);
assert.ok(operations.includes('captureSnapshot'));
assert.ok(!operations.includes('captureSnapshotWithCustomActions'));
for (const operation of operations) {
assert.doesNotMatch(operation, /^(tap|fill|type|scroll|perform|hover|focus|longPress)/);
}
});

test('a preferred native read is a fast path, never a plan requirement', () => {
// `get` prefers the runner's point read but succeeds from the capture; the plan must not pull
// a runner in for it (ADR 0019: preferred is an optimization, required is the requirement).
const operations = resolvePlannedRuntimeOperations(steps('get'));
assert.ok(operations);
assert.ok(!operations.includes('readTextAtPoint'));
assert.ok(operations.includes('captureSnapshot'));
});

test('a snapshot step requires the custom-actions capture only when the daemon flag asks', () => {
const plain = resolvePlannedRuntimeOperations([
{ command: 'snapshot', positionals: [], flags: { depth: 2 } },
]);
const custom = resolvePlannedRuntimeOperations([
{ command: 'snapshot', positionals: [], flags: { snapshotCustomActions: true } },
]);
assert.ok(plain && custom);
assert.ok(!plain.includes('captureSnapshotWithCustomActions'));
assert.ok(custom.includes('captureSnapshotWithCustomActions'));
});

test('a find step is planned from its positionals the way the handler parses them', () => {
const find = (...positionals: string[]) =>
resolvePlannedRuntimeOperations([{ command: 'find', positionals, flags: {} }]);
const readOnly = find('text="Settings"', 'exists');
const text = find('text="Settings"', 'get', 'text');
const typed = find('text="Email"', 'type', 'hello');
const defaultClick = find('text="Settings"');
const unparsed = resolvePlannedRuntimeOperations([{ command: 'find', flags: {} }]);
assert.ok(readOnly && text && typed && defaultClick && unparsed);
assert.deepEqual(readOnly, ['captureSnapshot', 'captureSnapshotWithoutActiveApp']);
assert.deepEqual(text, ['captureSnapshot', 'captureSnapshotWithoutActiveApp']);
assert.ok(typed.includes('typeText') && typed.includes('focusPoint'));
// A missing action is a click, and a step that cannot be parsed keeps every alternative.
assert.ok(defaultClick.includes('focusPoint') && defaultClick.includes('typeText'));
assert.deepEqual(unparsed, defaultClick);
});

test('an interaction command contributes its required touch operations', () => {
const operations = resolvePlannedRuntimeOperations(steps('snapshot', 'click'));
assert.ok(operations);
assert.ok(operations.some((operation) => /^tap/.test(operation)));
});

test('commands without device runtime execution contribute nothing', () => {
assert.deepEqual(resolvePlannedRuntimeOperations(steps('devices', 'capabilities')), []);
});

test('an unregistered command makes the plan unproven', () => {
assert.equal(resolvePlannedRuntimeOperations(steps('snapshot', 'not-a-command')), undefined);
});

test('every step selector returns a non-empty subset of the alternatives its command declares', () => {
const probes = [
{ positionals: [], flags: {} },
{ positionals: [], flags: { snapshotCustomActions: true } },
{ positionals: ['text="x"', 'get', 'text'], flags: {} },
{ positionals: ['text="x"', 'type', 'y'], flags: {} },
{ positionals: ['text="x"'], flags: {} },
{ flags: {} },
];
let selectors = 0;
for (const descriptor of commandDescriptors) {
const execution = descriptor.platformExecution;
if (execution.kind !== 'device-runtime' || !('uses' in execution) || !execution.selectUses) {
continue;
}
selectors += 1;
for (const probe of probes) {
const selected = execution.selectUses(probe);
assert.ok(selected.length > 0, `${descriptor.name} selected no use`);
for (const use of selected) {
assert.ok(execution.uses.includes(use), `${descriptor.name} selected an undeclared use`);
}
}
}
// The commands whose declared alternatives differ in what they execute on a device.
assert.equal(selectors, 3);
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { commandDescriptors, selectSnapshotStepUses } from '../registry.ts';
import { snapshotRuntimePlanUses } from '@agent-device/contracts/platform-runtime-operations';
import { expect, test } from 'vitest';
import { commandDescriptors } from '../registry.ts';

test('snapshot descriptor declares its complete planned capture uses with no legacy projection', () => {
const snapshot = commandDescriptors.find(({ name }) => name === 'snapshot');

expect(snapshot).not.toHaveProperty('capability');
expect(snapshot).not.toHaveProperty('dispatch');
// Plan-time consumers select the alternative from the step input the way the handler does.
expect(snapshot?.platformExecution).toEqual({
kind: 'device-runtime',
uses: snapshotRuntimePlanUses,
selectUses: selectSnapshotStepUses,
});
expect(snapshotRuntimePlanUses.map(({ required }) => required)).toEqual([
['captureSnapshot'],
Expand All @@ -27,5 +29,6 @@ test('diff descriptor reuses the complete snapshot plan uses with no legacy proj
expect(diff?.platformExecution).toEqual({
kind: 'device-runtime',
uses: snapshotRuntimePlanUses,
selectUses: selectSnapshotStepUses,
});
});
50 changes: 50 additions & 0 deletions packages/command-registry/src/planned-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { RuntimeUseStep } from '@agent-device/contracts/command-platform-execution';
import {
isRuntimeOperationName,
type RuntimeOperationName,
} from '@agent-device/contracts/runtime-operation-names';
import { commandDescriptors } from './registry.ts';
import type { CommandDescriptor } from './types.ts';

const descriptorsByName = new Map<string, CommandDescriptor>(
commandDescriptors.map((descriptor) => [descriptor.name, descriptor]),
);

/** One step of a plan as the batch runner holds it: the command plus what its handler will read. */
export type PlannedStep = RuntimeUseStep & Readonly<{ command: string }>;

export type PlannedRuntimeOperation = RuntimeOperationName;

/**
* The runtime operations a sequence of steps must execute, read from each command's declared
* platform execution (ADR 0019 §6). Only `required` operations count: a preferred or conditional
* operation is a measured fast path the command succeeds without, so it must not pull a runner
* into a plan that is otherwise observation-only. A command whose alternatives differ selects
* them from the step the way its handler does (`selectUses`); every other command contributes
* all of its alternatives. Returns `undefined` when any command is unknown to the registry: an
* unregistered step makes the plan unproven rather than silently empty.
*/
export function resolvePlannedRuntimeOperations(
steps: readonly PlannedStep[],
): readonly PlannedRuntimeOperation[] | undefined {
const operations = new Set<PlannedRuntimeOperation>();
for (const step of steps) {
const required = requiredOperationsOf(step);
if (required === undefined) return undefined;
for (const operation of required) operations.add(operation);
}
return Object.freeze([...operations].sort());
}

/** One step's required operations, or `undefined` when the step cannot be planned honestly. */
function requiredOperationsOf(step: PlannedStep): readonly PlannedRuntimeOperation[] | undefined {
const execution = descriptorsByName.get(step.command)?.platformExecution;
if (execution === undefined) return undefined;
if (execution.kind !== 'device-runtime') return [];
const uses =
'uses' in execution ? (execution.selectUses?.(step) ?? execution.uses) : [execution.use];
const required = uses.flatMap((use) => use.required);
// `defineUse` admits only operation keys, so an unknown name means the vocabulary list and the
// operations union drifted; treat the plan as unproven rather than guess.
return required.every(isRuntimeOperationName) ? required : undefined;
}
Loading
Loading