Files
callstack__agent-device/src/core/dispatch-series.ts
Michał Pierzchała f474f0784e feat: unify gesture planning and multi-touch execution (#1212)
* feat: unify gesture planning and multi-touch execution

* fix: correct unified gesture helper behavior

* refactor: tighten unified gesture architecture

* fix: preserve gesture routing contracts

* test: account for fresh gesture viewport

* refactor: remove retired gesture series

* fix: preserve example app navigation targets

* test: reconcile unified gestures with helper ownership

* docs: update Android helper gesture protocol

* fix: refresh Maestro percentage swipe frames

* refactor: remove stale Maestro frame cache

* fix: harden unified gesture execution

* fix: model gesture viewport in providers

* refactor: remove legacy gesture paths

* fix: remove unused swipe preset parser

* refactor: tighten unified gesture boundaries

* fix: close gesture review gaps

* fix: preserve gesture compatibility contracts

* fix: preserve multi-touch recording semantics

* fix: refresh Apple runner state after app relaunch

* test: lock Apple fling fallback route

* fix: close Apple runner review gaps

* refactor: tighten unified gesture seams

* refactor: consolidate gesture planning policy

* fix: preserve swipe response compatibility

* fix: keep gesture lab aligned with replay coordinates
2026-07-13 13:16:38 +02:00

93 lines
3.6 KiB
TypeScript

import { isApplePlatform, type DeviceInfo } from '../kernel/device.ts';
import { sleep } from '../utils/timeouts.ts';
export { requireIntInRange } from '../utils/validation.ts';
const DETERMINISTIC_JITTER_PATTERN: ReadonlyArray<readonly [number, number]> = [
[0, 0],
[1, 0],
[0, 1],
[-1, 0],
[0, -1],
[1, 1],
[-1, 1],
[1, -1],
[-1, -1],
];
/**
* Whether a press series should fuse into one or more `sequence` runner requests. Every Apple
* multi-press variant fuses — plain, double-tap, hold, and jitter series — so the budget chunker
* bounds per-request wall-clock (the retired `tapSeries` runner command executed all pauses in a
* single main-thread block, risking the 30s watchdog for large count x interval).
*/
export function shouldUseIosPressSequence(device: DeviceInfo, count: number): boolean {
return isApplePlatform(device.platform) && count > 1;
}
/**
* Wall-clock budget (ms) for one fused `sequence` runner request. The runner executes a whole
* chunk inside a single DispatchQueue.main block guarded by a 30s main-thread watchdog
* (mainThreadExecutionTimeout); if a chunk's holds + pauses exceed that, the runner reports a
* timeout while the remaining steps keep mutating the UI. We sub-chunk well under 30s so the
* estimated holds + pauses + per-step overhead of any single chunk stays safely inside it.
*/
const RUNNER_SEQUENCE_CHUNK_BUDGET_MS = 20_000;
/**
* Rough fixed per-step cost (ms) the runner spends on each gesture beyond its hold/pause
* (synthesis, frame resolution, XCTest dispatch). Kept conservative so the budget errs toward
* smaller chunks rather than risking the watchdog.
*/
const RUNNER_SEQUENCE_STEP_OVERHEAD_MS = 250;
/**
* Chunks sequence steps by BOTH a hard step-count cap and an estimated wall-clock budget, so a
* single fused request never risks the runner's 30s main-thread watchdog. Each step's estimated
* cost is its hold (durationMs) + inter-step pause (pauseMs) + fixed overhead. A step whose own
* estimated cost already exceeds the budget still gets its own single-step chunk (the daemon-side
* caps keep one step under 30s: max 10s hold + 10s pause + overhead).
*/
export function chunkRunnerSequenceStepsByBudget<
T extends { durationMs?: number; pauseMs?: number },
>(steps: T[], maxSteps: number, budgetMs: number = RUNNER_SEQUENCE_CHUNK_BUDGET_MS): T[][] {
if (steps.length === 0) return [];
const stepCap = maxSteps > 0 ? maxSteps : steps.length;
const chunks: T[][] = [];
let current: T[] = [];
let currentCostMs = 0;
for (const step of steps) {
const stepCostMs =
(step.durationMs ?? 0) + (step.pauseMs ?? 0) + RUNNER_SEQUENCE_STEP_OVERHEAD_MS;
const wouldExceedBudget = current.length > 0 && currentCostMs + stepCostMs > budgetMs;
const wouldExceedCount = current.length >= stepCap;
if (wouldExceedBudget || wouldExceedCount) {
chunks.push(current);
current = [];
currentCostMs = 0;
}
current.push(step);
currentCostMs += stepCostMs;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
export function computeDeterministicJitter(index: number, jitterPx: number): [number, number] {
if (jitterPx <= 0) return [0, 0];
const [dx, dy] = DETERMINISTIC_JITTER_PATTERN[index % DETERMINISTIC_JITTER_PATTERN.length]!;
return [dx * jitterPx, dy * jitterPx];
}
export async function runRepeatedSeries(
count: number,
pauseMs: number,
operation: (index: number) => Promise<void>,
): Promise<void> {
for (let index = 0; index < count; index += 1) {
await operation(index);
if (index < count - 1 && pauseMs > 0) {
await sleep(pauseMs);
}
}
}