Files
callstack__agent-device/CHANGELOG.md
Michał Pierzchała 62001cf210 refactor(record): derive session recording from the publication lifecycle (#1719)
* refactor(record): derive session recording from the publication lifecycle

`SessionState.recordSession` stored an answer the script-publication
aggregate already contained. Every writer set both, but nothing made them
agree, and #1533 was the consequence: a `--save-script` ingress re-armed
the flag behind an ABORTED status, and a bare `close` published a
recording the caller had been told was aborted.

That fix routed every write through one rule, which made the two agree
without making disagreement unrepresentable. The field remained a second
source of truth, and its doc comments had to carry the invariant that a
type could enforce.

Remove the field and derive the answer. `isRecordingPublication` reads
recording off the lifecycle: ordinary authoring records only while ARMED;
a repair transaction records for its whole lifetime, terminal statuses
included. That last clause is deliberately exact rather than merely safe —
`armRepairStep` armed the old flag and neither `abortRepair` nor
`commitRepair` ever cleared it, so narrowing it would silently stop
evidence capture for a committed repair. Whether it should is a real
question, and a behavior change, so it is left alone here.

What this buys, beyond one less field:

- `buildNextOpenSession` and `finalizeOrdinaryCloseScript` make no
  recording decision at all now, so no surface can arm recording without
  moving the lifecycle that authorizes it.
- The writer's publication gate is answered entirely by the aggregate. Its
  separate ABORTED check is gone: a terminal authoring lifecycle is
  already not recording, so one question replaces two that could disagree.
- The R7 ownership ratchet drops from 23 writer-owned fields / 29 owner
  claims to 22 / 26, and the layering manifest loses the entry whose
  comment documented the smell ("deliberately set on its own by paths that
  record without arming a publication").

Behavior-preserving: the derivation reproduces what the flag held at every
transition. The test fixtures that armed `recordSession` with no
publication state described a shape production stopped producing at #1478;
they now carry the lifecycle that causes recording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

* test(close-script): flush queued event-log writes before removing the tmp root

CI failed the Coverage lane with ENOTEMPTY removing the test's tmp root,
in `afterEach` rather than in an assertion.

`SessionStore.recordAction` QUEUES its event-log append
(`queueEventLogWrite`) instead of writing it, and every close path in this
file records an action. Nothing awaited that write, so `fs.rmSync(root,
{recursive: true})` could race it: the pending append recreates
`<root>/sessions/<name>/` while rmSync is walking, and the final rmdir
fails ENOTEMPTY. It needs CI's parallel load to lose the race — the file
passes 12/12 in isolation locally.

Await `flushSessionEventLogWrites()` before removing. The hazard is latent
in any test that records actions and then removes its tmp root; this fixes
the file that failed rather than sweeping the pattern, which deserves its
own change.

Not added to the #1419 contention-retry list: that list requires a
concrete spawn/wait mechanism named per entry, and this file has none. The
race was a real teardown bug, not lane contention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

* docs: correct ADR 0016 on recording vs publication for repair

Review caught a real overstatement. The amendment claimed evidence capture
and publication authorization are "the same question asked of the same
state". That holds for ordinary authoring — ARMED both records and
publishes, ABORTED and PUBLISHED do neither — but not for repair:
`isRecordingPublication` is true for every repair status including
`committed` and `aborted`, while the writer additionally applies
`isRepairArmedWriteBlocked`, refusing a committed transaction and one that
is not yet committable.

State it as it is: both decisions derive from the same aggregate, but they
remain distinct predicates, and collapsing them would republish a committed
repair or commit an incomplete prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFW9gJqz1wEHoowkdd1nFW

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 10:58:03 +02:00

16 KiB

Changelog

Unreleased

  • Internal: session recording is now derived from the script-publication lifecycle instead of being stored beside it. SessionState.recordSession is removed; isRecordingPublication answers the question from the aggregate (ordinary authoring records only while ARMED, a repair transaction records for its whole lifetime). The stored flag was a second source of truth that handler surfaces set directly, which is how #1533's aborted-recording drift arose — no surface can now arm recording without moving the lifecycle that authorizes it, and the script writer's publication gate is answered entirely by the aggregate. Behavior-preserving: the derivation reproduces what the flag held at every transition.
  • Fixed: a script recording aborted by a second open is no longer published by a later bare close (#1533). open <app> --save-script followed by a second successful open terminates the recording and warns "Script publication was aborted…", and close --save-script correctly refuses it with "Retry with plain close; it will tear down the session without writing." But when that second open itself carried --save-script, the flag re-armed recording behind the terminal status, and a bare close then wrote the full session log to disk — publishing a recording the caller had been told was aborted, and breaking the promise the refusal makes. An aborted authoring lifecycle is now terminal by construction: --save-script arms nothing on any surface that handles it — the re-open builder, the close finalizer, and the recorded-action ingress — and the script writer refuses the lifecycle from every path that reaches it (bare close, teardown, idle-reap, active publication). This also stops an aborted session from paying recording-time costs it can never publish: a re-opened aborted recording no longer keeps the direct iOS selector fast paths for click and get disabled. Armed recordings, published recordings, and every repair transaction are unaffected.
  • agent-device mcp now serves the stateless MCP 2026-07-28 revision alongside the handshake-based revisions it already spoke, as the spec's "dual-era server". Modern clients probe server/discover, which advertises the supported revisions, the tools capability, and server identity; their requests declare a protocol version in _meta, and their results carry resultType: "complete" plus _meta["io.modelcontextprotocol/serverInfo"]. tools/list and server/discover now return the ttlMs/cacheScope cache hints, so a client can cache the 55-tool, ~223KB tool list for an hour instead of re-fetching it on every start; the list was already emitted in a deterministic (sorted) order, which is the other half of what makes it cacheable. Each revision is answered on its own wire contract: a request declaring 2025-11-25 or 2025-06-18 through modern framing still gets the legacy result shape, and initialize never agrees to 2026-07-28, which has no handshake to establish. A declared revision this server does not implement is rejected with UnsupportedProtocolVersionError (-32022) naming the ones it does, rather than being served under a version the client did not ask for, and modern framing that omits its required protocolVersion/clientCapabilities metadata — or supplies a clientInfo that is not a valid Implementation — is rejected as invalid params. initialize and ping were removed in 2026-07-28, so a modern-framed call to either is answered -32601 rather than served inside a resultType: "complete" envelope. Responses to legacy clients are unchanged byte-for-byte — initialize and ping are still served, and no cache, resultType, or _meta field is added to their results. Nothing here affects the CLI, Node, or daemon surfaces: the stdio transport, the tool set, and every tool's input/output schema are untouched.
  • Fixed: the MCP initialize handshake now answers with the protocol revision the client requested when it is one this server implements, instead of always answering 2025-11-25. A client pinned to 2025-06-18 was told to speak a revision it had not asked for, which the lifecycle contract answers by disconnecting.
  • agent-device help workflow is now a compact ~8KB card instead of a ~41KB dump; the same depth still exists, split into help scripting (save-script, secret-safe fills, batch JSON, replay divergence/repair, recording) and help gestures (multi-touch shapes and platform quirks), plus a few paragraphs folded into the topics that already owned the subject (help debugging, help physical-device, help validate). Every help <topic> first line is now agent-device <version> — <topic> so an agent can read the installed version from its mandatory first help read instead of a separate agent-device --version call.
  • Cloud iOS (BrowserStack, AWS Device Farm): snapshot and diff no longer fail with SESSION_NOT_FOUND on a live provider session (#1658). The app-session guard they ran belongs to the local XCUITest runner, which must attach to a target app; a cloud capture reads the provider's own driver session and needs no app identity, so the guard now applies to local Apple targets only. Relatedly, a cloud iOS open com.example.app now records that bundle id on the session — the provider path skips local app resolution (no simctl/devicectl reaches a hosted device), and used to drop an explicitly spelled bundle id along with it, leaving the session with no app identity at all. Opening a second bundle id replaces the first, matching the local path, where an explicitly spelled target always wins over the session's current app; deep links, display names, and bare open still keep the app already tracked.
  • Cloud fill (BrowserStack, AWS Device Farm) now witnesses that the field it tapped actually holds text-entry focus before sending its keys, instead of dispatching tap and keys in back-to-back requests (#1658). A WebView input — an OAuth/SSO page in a Safari view controller, for example — takes first responder asynchronously, so the keys used to land with nothing focused while fill still answered "Filled N chars"; tapping and filling as two separate commands worked only because the round trip between them gave the field time to focus. The witness is the focused element's own geometry: fill polls the active element and proceeds only once it contains the point it tapped, which is the one signal that identifies which field took focus. Keyboard visibility cannot — it reads the same before and after a second fill into an already-open form, so it could not tell a focused password field from the email field the previous fill left focused. The response discloses textEntryReadiness: focused-element, or keyboard-shown when the driver has no active-element route but the keyboard rose from hidden after the tap. Both describe a fill that witnessed focus before typing; there is deliberately no value for typing without evidence, because nothing renders this field and such a value would reach a caller as an ordinary success. Breaking: when focus cannot be witnessed, cloud fill now FAILS with COMMAND_FAILED / text_entry_focus_not_observed and sends no keys, instead of typing into whatever holds first responder and answering "Filled N chars" — a fill with no witness must not read as a filled field. That covers a tap that focused nothing, a keyboard already up on a driver that cannot name the focused field, and a driver that reports neither (text_entry_focus_unobservable, which points at press + type as the deliberate way to enter text unwitnessed). Only a positively classified unimplemented route counts as unsupported, so a dead session, an auth rejection, or a grid outage surfaces instead of degrading into a blind text entry.
  • Changed mutating selector ambiguity semantics (press/click/fill/longpress): duplicate accessibility wrappers collapse only when every match forms one ancestor-descendant chain resolving to the same actionable node. Matches in distinct subtrees now fail fast with AMBIGUOUS_MATCH and a bounded, immediately reusable candidate-ref frame; visible/depth/area geometry no longer silently picks a mutation target. The direct iOS XCTest path now counts raw exact matches before hittability and delegates ambiguity to the same runtime rule. AppControlBench provenance: element-14 ran on 0.20.5; this change is intended for 0.20.7+, and comparative benchmark reports should note that it can replace a wrong-success recovery loop with one candidate-pick turn while occasionally adding that turn for genuinely distinct duplicates.
  • scroll and back now accept --settle (with --settle-quiet and --timeout), collapsing scroll-then-observe and back-then-observe into one call (#1638). The response carries the same settled payload the touch commands return — verdict, changed-lines diff with fresh refs on added lines, the unchanged-interactive tail, and refsGeneration when the settled tree was stored — and is best-effort: it never fails the action. One difference is deliberate: scroll/back resolve no element, so the diff baseline is the session's stored pre-action tree ("the last tree you observed") rather than a freshly resolved pre-action capture. Both commands now also preserve the daemon on timeout, like the other settle-capable commands.
  • Security: repository ./agent-device.json now accepts only project-safe automation defaults. It rejects daemon endpoint/auth/transport/server settings, tenant/run/lease selectors, provider/cloud and Metro connection fields, headers, executable reporter modules, local write destinations, and other operator-controlled values before local module loading or any daemon health/RPC request. Put remote endpoint and token together in protected CI environment variables, user config, an explicit --config file, or the existing connect/--remote-config workflow. Daemon auth tokens no longer travel in serialized command flags.
  • viewport is now rejected during capability admission on Apple targets instead of reaching the device and failing inside dispatch. No Apple backend can resize a screen — simulator and device geometry is fixed by the selected device type — so viewport on iOS/iPadOS/tvOS/macOS now fails with UNSUPPORTED_OPERATION, viewport is not supported on this device, and a hint pointing at --platform web and at picking a different simulator. capabilities no longer advertises viewport on Apple targets. Web viewport resizing (agent-device viewport 1280 900 --platform web) is unchanged, and Android was already denied.
  • --save-script is now accepted only by the commands that declare it — open, close, and replay. A hand-built daemon request (or a batch step) that set saveScript on any other command, such as record or trace, used to arm script publication and could write a .ad artifact; it is now rejected with INVALID_ARGS before the request reaches admission, the device, or any handler. CLI, Node, and MCP usage of --save-script on its documented commands is unchanged.
  • diff screenshot no longer runs the retired best-effort OCR and non-text analyzers. Their optional ocr and nonTextDeltas fields remain in the result type for source compatibility but are no longer emitted; use the baseline/current images and diff artifact with vision for qualitative interpretation.
  • Breaking: removed the deprecated --session-locked and --session-lock-conflicts flags. Use --session-lock reject|strip instead; passing either old flag now fails with Unknown flag: ... Use --session-lock reject|strip instead.
  • Breaking: removed the replay export --format flag. replay export always writes Maestro YAML.
  • Breaking: removed the unused LeaseAllocatePayload, LeaseHeartbeatPayload, and LeaseReleasePayload type exports from agent-device/contracts. Lease request metadata is fully described by DaemonRequestMeta.
  • Maestro compat: assertVisible and assertNotVisible now accept childOf for ancestor scoping, matching tapOn (#1294).
  • Breaking: removed deprecated gesture duration and rotate velocity inputs (#1218).
    • swipe x1 y1 x2 y2 no longer accepts a trailing durationMs positional; use gesture pan x1 y1 (x2-x1) (y2-y1) durationMs for deliberate timed drags.
    • Maestro swipe operations with a duration continue to normalize to gesture pan with the endpoint-hold execution profile, preserving the Maestro-compatible fast-swipe-then-hold behavior on iOS.
    • gesture fling direction x y no longer accepts a trailing durationMs positional; use gesture pan for timed movement.
    • gesture swipe preset no longer accepts a trailing durationMs positional; use gesture pan for timed movement.
    • gesture rotate degrees [x] [y] no longer accepts a trailing velocity positional; rotation pacing is derived from degrees.
    • MCP/Node schemas no longer advertise velocity or durationMs on swipe/fling/gesture swipe; durationMs remains on gesture pan and gesture transform.
    • A .ad script that still carries a removed positional now fails when the script is parsed, before the replay executes any device action, naming the line and its rewrite (for example swipe accepts 4 arguments: x1 y1 x2 y2 (line 6). The trailing durationMs positional was removed: use "gesture pan 197 650 0 -350 300" ...). Previously the script ran up to that step and then failed as a replay divergence.
    • Published the gesture migration guide covering CLI, Node.js, MCP, and saved .ad recordings, plus the deprecation policy the next such removal follows (#1216).
    • replay export now writes an explicit duration: 100 for swipe — the canonical fling duration — instead of omitting it and letting Maestro apply its own 400ms default. Maestro flows replayed by agent-device are unaffected; a timed Maestro swipe still normalizes to gesture pan with the endpoint-hold profile.
  • Breaking: the deprecated rotate CLI command alias has been removed. Use orientation instead; invoking rotate now fails with rotate was renamed to orientation; for the two-finger gesture use: gesture rotate.
  • Breaking (ADR 0014, session ref-frame lifetime): a mutation through an @ref now expires the session's ref frame, so a later ref mutation without a fresh observation fails closed with a typed details.reason (ref_frame_expired, ref_generation_mismatch, plain_ref_requires_complete_frame, or ref_not_issued) instead of acting on a possibly-navigated screen. A ref-oriented sequence that performs several mutations must re-snapshot between them, consume an honestly issued --settle ref in pinned @eN~s<gen> form, or use selectors. Enforcement applies on every platform, not just iOS. Legacy hand-written .ad scripts that reuse several bare refs from one snapshot must capture between mutations or use selectors.
  • Ref reads resolve against the authorized ref frame's source tree, so an internal read-only capture (including Android freshness) can no longer retarget an admitted @ref by positional coincidence. Read-only ref consumers keep the structured staleness warning while the frame retains the ref's evidence.

0.15.0

  • Breaking: apps discovery and public app-list helpers now default to user-installed apps. Use --all or filter: 'all' to include system/OEM apps.
  • Breaking: removed the agent-device/android-apps public subpath. Use the Android app helpers from agent-device/android-adb.
  • Breaking: removed the agent-device/daemon public subpath. Use agent-device/contracts for daemon request/response types.
  • Breaking: removed public local ADB bypass/selection helpers such as spawnAndroidAdbBySerial and resolveAndroidAdbProvider; use createLocalAndroidAdbProvider(device) or pass providers directly to the helpers from agent-device/android-adb.
  • Added Android ADB provider helpers for exec, stream, clipboard, keyboard, app lifecycle, logcat, and port reverse workflows.