Files
callstack__agent-device/packages/platform-linux/src/runtime.ts
Michał Pierzchała 46eff36f85 refactor: migrate focus to the request-bound device runtime (#1925)
* refactor: migrate focus to the request-bound device runtime

Wave 5's first unit (#1739, ADR 0019). `focus x y` and `find <q> focus` now
reach the device through one admitted, request-bound `focusPoint` operation
instead of the `handleFocusCommand` interactor leaf and its dispatch-table arm.

- New `FocusRuntimeOperations` contract with local and provider interactor
  binders, mirroring the screenshot/element-text seam rather than inventing a
  second way for one operation class to reach its mechanics.
- Exact-owner facts replace the capability bucket: apple simulator/device,
  android emulator/device/unknown, harmonyos emulator/device, linux device,
  web device, vega none, providers wherever their interactor is reachable.
  That is the retired bucket's cell table, restated as facts.
- `focus` leaves BASE_COMMAND_CAPABILITY_MATRIX and both hand-maintained
  overlays (HARMONYOS_SUPPORTED_COMMANDS, WEB_INTERACTION_COMMANDS).
- R40 is the new parametrized cutover row; `focusPoint` has exactly one owner.
- The `x y` positional parse moves to utils and is shared with the still-legacy
  touch siblings, so a migrated command cannot drift from them.

`find` stays legacy: this unit owns its focus leg only, its `type` leg still
dispatches, and R35 waits on the Wave 5 `type` unit.

* test(focus): cover the owning interactor binders, lower the find ratchet

Review follow-ups on #1925.

P1: focus-runtime.test.ts bound a fake focusPoint, so deleting the interactor
call inside bindLocalFocusInteractor left focus a successful no-op with every
test green. Adds packages/contracts/src/focus-runtime.test.ts, which executes
both binders and asserts resolver context, positional (x, y) forwarding, the
structured missing-provider failure, and that an already-cancelled request
never resolves an interactor at all.

Two planted mutants confirm it bites: removing
`await interactor.focus(input.point.x, input.point.y)` and transposing its two
arguments each fail exactly the two forwarding tests, while the daemon-level
focus and find suites stay green — which is the gap the reviewer named.

Coverage: find.test.ts shrank to 1204 lines when its focus assertion moved off
the dispatch mock; the ratchet pin follows it down.

* test(focus): add live Linux focus coverage to the desktop replay

The Linux `focus` claim rested on the provider scenario at command-contract
level. The desktop replay runs on real Linux hardware in the Smoke lane, so it
now runs a coordinate focus and re-asserts the session survived it.

Coordinate, not selector: the step exists to prove the migrated `focusPoint`
path executes on real hardware, so it must not be able to fail on match
ambiguity or CI layout drift.

Reclassifies focus contract -> live in the Linux coverage manifest and updates
the two pinned counts. The manifest gate is two-sided — a live claim must name
a command the replay actually invokes — so the claim cannot drift from the file.
2026-08-21 11:34:22 +02:00

203 lines
7.8 KiB
TypeScript

import type {
DeviceBinding,
CaptureSnapshotInput,
PlatformRuntimeHost,
PlatformRuntimeOperations,
PlatformRuntimeOwner,
RuntimeFacts,
RuntimeOperationUnavailability,
} from '@agent-device/contracts/platform';
import {
applicationLifecycleOperationFacts,
availableApplicationLifecycleOperations,
bindLocalFocusInteractor,
bindLocalScreenshotInteractor,
bindElementTextRuntime,
captureSnapshotSignal,
createUnavailablePlatformRuntimeFacts,
elementTextRuntimeOperationFacts,
focusRuntimeOperationFacts,
localRuntimeOwner,
sameRuntimeOwner,
screenshotRuntimeOperationFacts,
snapshotRuntimeOperationFacts,
} from '@agent-device/contracts/platform';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import { bindLinuxApplicationLifecycle } from './lifecycle.ts';
const supported = Object.freeze({ available: true } as const);
const linuxOwner = localRuntimeOwner('linux');
const unsupportedPlatformLeaf = unavailableLinuxRuntimeFact('unsupported-platform-leaf');
const elementTextKindUnavailable = unavailableLinuxRuntimeFact('unsupported-device-kind');
const focusKindUnavailable = unavailableLinuxRuntimeFact(
'unsupported-device-kind',
'focus is supported only for the Linux desktop device.',
);
const runtimeHintsUnavailable = unavailableLinuxRuntimeFact(
'unsupported-platform-leaf',
'Runtime hints are supported only for local iOS-family simulators and Android devices.',
);
const appleRunnerUnavailable = unavailableLinuxRuntimeFact(
'unsupported-platform-leaf',
'Apple runner preparation is supported only for Apple targets.',
);
const providerPortReverseUnavailable = unavailableLinuxRuntimeFact(
'unsupported-provider-mode',
'Port reverse is supported only by an owning provider runtime.',
);
const openTargetKindUnavailable = unavailableLinuxRuntimeFact(
'unsupported-device-kind',
'open is supported only for the Linux desktop device.',
);
const closeTargetKindUnavailable = unavailableLinuxRuntimeFact(
'unsupported-device-kind',
'close is supported only for the Linux desktop device.',
);
const snapshotKindUnavailable = unavailableLinuxRuntimeFact(
'unsupported-device-kind',
'snapshot is supported only for the Linux desktop device.',
);
const screenshotKindUnavailable = unavailableLinuxRuntimeFact(
'unsupported-device-kind',
'screenshot is supported only for the Linux desktop device.',
);
const snapshotCustomActionsUnavailable = unavailableLinuxRuntimeFact(
'unsupported-platform-leaf',
'Re-run without --actions, or target an iOS simulator.',
);
export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner {
return Object.freeze({
owner: linuxOwner,
ownsDevice: (device) => device.platform === 'linux',
inspectFacts: async (device) => linuxFacts(device),
bind: async (request) => {
if (
request.intent.kind === 'exact-owner' &&
!sameRuntimeOwner(request.intent.owner, linuxOwner)
) {
throw new AppError('UNSUPPORTED_OPERATION', 'Linux runtime owner identity does not match');
}
if (request.device.platform !== 'linux') {
throw new AppError('UNSUPPORTED_PLATFORM', 'Linux runtime cannot bind this device');
}
const facts = linuxFacts(request.device);
const lifecycle = bindLinuxApplicationLifecycle({
host: host.localInteractors,
device: request.device,
signal: request.scope.signal,
});
return Object.freeze({
device: request.device,
owner: linuxOwner,
facts,
operations: Object.freeze({
...availableApplicationLifecycleOperations(lifecycle, facts.operations),
...(facts.operations.captureSnapshot.available
? linuxSnapshotOperations(host, request)
: {}),
...(facts.operations.captureScreenshot.available
? bindLocalScreenshotInteractor({
device: request.device,
signal: request.scope.signal,
resolveInteractor: host.localInteractors.resolve,
})
: {}),
...(facts.operations.focusPoint.available
? bindLocalFocusInteractor({
device: request.device,
signal: request.scope.signal,
resolveInteractor: host.localInteractors.resolve,
})
: {}),
...(facts.operations.readTextAtPoint.available
? bindElementTextRuntime({
device: request.device,
signal: request.scope.signal,
resolveInteractor: host.localInteractors.resolve,
})
: {}),
}),
[Symbol.asyncDispose]: async () => undefined,
}) satisfies DeviceBinding<PlatformRuntimeOperations>;
},
shutdown: async () => undefined,
});
}
function linuxFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations> {
const openTarget = device.kind === 'device' ? supported : openTargetKindUnavailable;
const closeTarget = device.kind === 'device' ? supported : closeTargetKindUnavailable;
const unavailable = createUnavailablePlatformRuntimeFacts(device, linuxOwner, {
appLog: unsupportedPlatformLeaf,
network: unsupportedPlatformLeaf,
screenshot: screenshotKindUnavailable,
snapshot: snapshotKindUnavailable,
viewport: unsupportedPlatformLeaf,
focus: focusKindUnavailable,
elementText: elementTextKindUnavailable,
readiness: unsupportedPlatformLeaf,
lifecycle: applicationLifecycleOperationFacts({
resolveOpenTarget: openTarget,
prepareApplicationOpen: openTarget,
openApplication: openTarget,
applyRuntimeHints: runtimeHintsUnavailable,
clearRuntimeHints: runtimeHintsUnavailable,
closeApplication: closeTarget,
finalizeApplicationClose: closeTarget,
prepareAppleRunner: appleRunnerUnavailable,
configureProviderPortReverse: providerPortReverseUnavailable,
}),
});
return Object.freeze({
device: unavailable.device,
operations: {
...unavailable.operations,
...snapshotRuntimeOperationFacts({
capture: device.kind === 'device' ? supported : snapshotKindUnavailable,
customActions: snapshotCustomActionsUnavailable,
withoutActiveApp: device.kind === 'device' ? supported : snapshotKindUnavailable,
}),
...screenshotRuntimeOperationFacts({
capture: device.kind === 'device' ? supported : screenshotKindUnavailable,
}),
// Parity with the retired `focus` capability bucket (`{ device: true }`): the desktop is
// the only Linux cell with a pointer to drive.
...focusRuntimeOperationFacts({
focus: device.kind === 'device' ? supported : focusKindUnavailable,
}),
// The Linux read is value-first (AXValue/title/description) where the captured tree is
// label-first, so the desktop row genuinely reads differently from its snapshot text.
...elementTextRuntimeOperationFacts({
readTextAtPoint: device.kind === 'device' ? supported : elementTextKindUnavailable,
}),
},
});
}
function linuxSnapshotOperations(
host: PlatformRuntimeHost,
request: Parameters<PlatformRuntimeOwner['bind']>[0],
) {
const captureSnapshot = async (input: CaptureSnapshotInput) =>
await host.snapshot.captureSurface(
request.device,
input.options,
captureSnapshotSignal(request.scope.signal, input),
);
return Object.freeze({
captureSnapshot,
captureSnapshotWithCustomActions: captureSnapshot,
captureSnapshotWithoutActiveApp: captureSnapshot,
});
}
function unavailableLinuxRuntimeFact(
reason: RuntimeOperationUnavailability['reason'],
hint?: string,
): RuntimeOperationUnavailability {
return Object.freeze(
hint === undefined ? { available: false, reason } : { available: false, reason, hint },
);
}