refactor(daemon): extract native .ad replay to packages/ad-replay (#1478 P5)
refactor(daemon): extract native .ad replay to packages/ad-replay (#1478 P5)
Implements P5 per the approved design of record (proposal, binding amendment), built as staged, individually-gated commits on one branch, cutting over atomically in this PR. Step 2 of the approved sequence (behavior pinning) merged separately as #1552 and is this PR's regression net — none of those tests' assertions changed anywhere in this branch. All four findings from the first review are addressed in commits 281f33599..947b90ca3; the response comment maps them one-by-one.
The façade — final shape (structural-quality round)
packages/ad-replay exports exactly inspectAdReplay and runAdReplay as values, plus the enumerated neutral runtime vocabulary (20 types: runtime bag, outcomes/failures, manifest, evidence, and the ReplaySelectorPort family) — the maestro-shaped typed façade, pinned by the exact-symbol gate in scripts/layering/package-boundaries.test.ts (oxc static-export table; bare export * and default exports throw; plant-verified). Package exports map is exactly ".".
The façade design went through a deliberate reversal during review: an earlier round required hiding all types behind entrypoint signatures, which produced a root-side derivation shim, four daemon twin types, and a readonly→mutable copy translator; the structural-quality review reversed that rule, and this branch now exports the typed vocabulary directly — shim, twins, and translator deleted, daemon consumers use the engine's own types.
Engine internals follow the maestro precedent: runtime-port-types.ts (374 LOC, boundary vocabulary), verify-dispatch.ts (245, verify-then-dispatch policy), step-loop.ts (237, the loop + terminal-close). The daemon engine-adapter is 314 LOC (target was <300; the overage is the lastObservation per-step fix's load-bearing comment, kept deliberately). The engine's parse gate honors the port's own contract (readSelectorExpression('ordinary', [token]), with a contract-suite cell pinning adapter parity on malformed bare tokens).
- Parsing/planning/digest/resume behind the entrypoints:
inspectAdReplay's manifest carriesplanDigest/resolveEntryIndex; resume validation stays eager by design (a rejected--frommust not mutate coordinator state — pinned by test). - Target verification runs inside the engine step loop via narrow daemon capabilities; mismatch evidence crosses as typed values (
AdReplayGuardMismatchEvidence/AdReplayLandmarkMismatchEvidence), never a wiredetailsbag. - Neutral outcomes: no generic, no
DaemonResponsein either direction; the daemon side-map preserves the exact wire object. - Variables: the engine builds the scope and resolves each action exactly once (
${VAR}interpolation is a linear scanner in ad-script after a CodeQL polynomial-redos find — 200k-trial differential fuzz, zero mismatches); scrub values are computed once per step under one name.
Shared vocabulary went to its owner
Per the review's "proper shared owner" alternative, measured per symbol: the identity vocabulary (annotationLocalIdentity, matchesLocalIdentity, matchesAncestryPrefix, LocalIdentity, identityFieldMismatches, firstAncestryMismatch) and classifyTargetBindingMatch (+ vars.ts) moved to packages/ad-script — they interpret .ad/TargetAnnotationV1 semantics and are consumed by recording-side root code (and, for vars, the Maestro path). session-replay-report-action.ts / session-replay-suggestion-ranking.ts measured as root-only consumers and moved back to the daemon (undoing an over-move). ad-script's façade additions are covered by its existing boundary row.
The selector port
Three operations (readSelectorExpression, resolveRecordedTarget — same-alternative winner+domain invariant implemented in the production adapter, lifted verbatim; buildSelectorCandidates), trafficking only in strings, kernel snapshot values, and tagged unions. Production adapter: src/daemon/replay-selector-port.ts. In-memory adapter: src/__tests__/test-utils/in-memory-replay-selector-port.ts — now honors ReplaySelectorCandidateOptions.nodes with production's exact shared-ID drop semantics. Contract suite: 9 cells × 2 adapters (18 tests), including the shared-ID demotion cell (counterfactual: ignoring nodes fails the in-memory leg while production stays green). Two AST-needing helpers (resolveReplaySuggestionCandidate, readReplaySelectorDisplayValue) are daemon-side plain exports beside the adapter — provably inexpressible through the port without leaking the AST.
Invalid-backend rejection restored
prepareReplayPlan rejects any non-maestro replayBackend with the byte-identical INVALID_ARGS message from main, before any inspection or session work. Handler-level regression test proves zero step dispatch (counterfactual: without the guard, the script executed); a companion test pins that maestro still routes.
What moved / stayed (final)
- Package: step loop + verify-and-dispatch, inspect/manifest, digest/resume internals, verification policy, selector-port type family.
- ad-script: annotation identity vocabulary, binding classification,
${VAR}vars module (shared with the Maestro path). - Daemon (root): request admission, invalid-backend gate, P4b coordinator (sole transaction owner, ownership test untouched), capture/dispatch/publication/artifacts, wire builders consuming neutral evidence values, Maestro dispatch and
format.tsrouting (above both engines), report-action + suggestion-ranking,sanitizeIdentity/describeCandidate(pinned by snapshot-lines),target-identity-node.ts/target-evidence-tree.ts(shared with dispatch/recording).
Rebase: #1554's keep-session absorbed into the engine (head e6cbe6b76)
After #1554 merged, this branch rebased onto main and folded its terminal-lifecycle policy into the P5 architecture rather than keeping a parallel decider: resolveSuppressedTerminalCloseIndex/countExecutedReplayActions unified with the engine's repair terminal-close predicate inside the step loop (one OR'd suppression condition, keepSession || runtime.isRepairArmed(), checked dynamically after armStep so a first-time --save-script arm is visible); AdReplayRunRequest gained one field (keepSession); the daemon's session-replay-terminal-lifecycle.ts module is deleted (no duplicate isExecutableReplayAction anywhere); the SessionStore postcondition stays daemon-side. #1554's six unit tests pass unchanged end-to-end, plus five new package-internal runAdReplay tests cover the unified policy directly. The replayed count is now a per-dispatched-step counter (fixing the old approximation that over-counted nested-replay markers).
Decomposition: the daemon adapter is now four cohesive modules
session-replay-runtime.ts went from ~1100 lines (post-rebase) to 242 — thin orchestration only. Extracted along its natural seams: session-replay-runtime-engine-adapter.ts (473: the runtime-bag capabilities, build*Failure implementations, side-map mechanics), session-replay-runtime-plan.ts (261: backend validation, manifest inspection, resume-index resolution, Maestro routing), session-replay-runtime-session.ts (219: session preparation, repair preflight, save-script arming). Coordinator construction stays solely in the orchestrator — the ownership test passes untouched, no allowlist changes.
Two load-bearing ordering invariants discovered during the extraction are now pinned by counterfactual-verified tests: post-dispatch mismatch divergences report the pre-step artifact snapshot (engine test; counterfactual red showed the failed dispatch's own artifacts leaking in), and a rejected --from/--plan-digest never reaches prepareReplaySession's coordinator-mutating writes (plan test; counterfactual red showed pendingRecordAndHeal being cleared before the failure).
Gates (re-run at every stage; latest full chain at head e6cbe6b76)
typecheck / lint / format:check / check:layering (53 tests incl. the new exact-symbol gate) / check:replay-compat (10 mined scripts, 6 tags, 12 digest-pinned entries) / fallow — green. Full vitest: 5351/5352 at the last full run with the only failures being the documented contention-timeout class (isolate-rerun green; the two pid-liveness assertion races are fixed separately in #1556). Fallow baseline: one surgical 8-line addition for the relocated in-memory adapter; full regen deliberately rejected (would silently drop 12 unrelated stale entries).
Live evidence (exact head 947b90ca3, round 2)
Standard suites:
| Leg | Scenario | Result | Time |
|---|---|---|---|
| Android (Pixel_7_CI, Release APK, android-helper 0.20.3 probe-verified) | checkout-form-android.ad | PASS | 23.7s |
| Android | gesture-lab-android.ad | PASS | 12.3s |
| iOS (iPhone 17 Pro sim) | gesture-lab.ad | PASS | 31.0s |
| iOS | checkout-form.ad | BLOCKED — #1542 (known; one clean attempt; Android twin passes, engine exonerated) | 15.1s |
Extended evidence (Android, constructed via the CLI's own record/replay loop; verbatim log in artifacts):
- target-v1 + save-script: recorded flow with
open --save-script; saved script carries# agent-device:target-v1 {"id":"refresh-metrics","role":"button",...,"verification":"verified"}. (Finding: annotations require arming atopen—close --save-scriptalone yields selector chains without target-v1 evidence, persession-open-surface.tsarming semantics.) - Verification green path: replay of the annotated script — 6 steps, clean pass.
- Divergence red path: replay against the wrong screen →
REPLAY_DIVERGENCE, classificationselector-miss (matchCount 0) — recorded target evidence did not verify, with a record-and-heal repair suggestion including--from 3 --plan-digest 8ff7b932…. - Resume:
replay --from 3 --plan-digest <hash>after correcting the screen —Replayed 2 steps in 0.8s, completed cleanly. - Save-script repair: full record-and-heal loop — armed diverging replay, live-corrected via a blessed
@ref, resumed, explicitclose --save-scriptcommitted the repair; repaired script ends with# agent-device:heal-complete, carries fresh"verification":"verified"annotations for every step, and replays green (6 steps, 4.0s). Behavioral observation (informational, consistent with the repair-transaction commit boundary): an armed session's--fromresume does not auto-run a trailing scripted bareclose; the explicit close commits.
Artifacts: /private/tmp/ad-p5-live-artifacts-r2/ (28 files incl. the verbatim command log, original/divergence/repaired scripts, per-attempt replay trees). Round-1 artifacts at the pre-review head remain in /private/tmp/ad-p5-live-artifacts/.
Residual risks
- #1542 blocks the iOS checkout leg — the remaining ready-for-human blocker per the review.
- Selector packaging expressly NOT revisited here — remeasure at P6/W3 as a separate decision.
Later rounds (each gated green)
- Reconcile onto main absorbing #1554 (keep-session folded into the engine, terminal-lifecycle module deleted) and #1556/#1558/#1559.
- Review round: invalid-backend rejection restored; shared vocabulary → ad-script; neutral outcomes; verification inversion; complexity decompositions.
- Structural-quality round (9 commits @
ac5488628): typed façade + gate widening; parse-gate contract fix; step-loop three-way split; adapter 580→314; package-localresume/target-verificationtests with counterfactuals; keep-session test renamed to match topology; scrub single-naming;lastObservationper-step fix; decayed comments removed;declaredScriptPlatformsingle-sourced in ad-script (a real engine↔daemon duplicate was confirmed and removed).
Evidence status (final): rounds 1–4 of live evidence across the branch's history proved both platform suites, the extended target-v1/divergence/resume/keep-session/save-script-rejection/${VAR} legs, and the #1558/#1563 compose checks (verbatim details in the PR comments). At the final head, a controlled 2×2 attribution matrix (main vs. this branch, same simulator) confirmed the one remaining iOS checkout flake is a main-side contention-triggered gap in the #1563/#1566 interaction — filed as #1569 with full mechanism evidence (stale_accept saturates at the cap on every run, main included; the wall-clock retry budget starves under load) — explicitly not a finding against this branch (P5 touches no iOS capture code; main passed and failed identically). Merged on maintainer decision with #1569 as the documented residual, parallel fix in progress; a final quiet-host evidence round was in flight at merge time and its results follow up on this PR.
Generated by Claude Code