diff --git a/CONTEXT.md b/CONTEXT.md index e774923367..0e695c5968 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -319,17 +319,19 @@ The perfect-shape refactor is complete and merged. Its end-state: improvement into a baseline edit. A shrunk tree is reported in the success line instead of failing. Hubs by in-component dependents: `runtime-contract.ts` (25), `commands/runtime-types.ts` (21), `backend.ts` (15), `commands/runtime-common.ts` (12). -- Daemon modularity migration (R10). The same tooling-only declaration records R7 at 30 - writer-owned fields / 42 owner-file claims, R9's 76 members by zone (`commands` 33, - `daemon-server` 20, `core` 10, `platforms` 7, root 5, `client` 1), and the four - production importers of `daemon/types.ts` from outside daemon. R7 counts and external importers may +- Daemon modularity ratchets (R10). The same tooling-only declaration pins R7's writer-owned + field/owner-claim counts, R9's 76 members by zone (`commands` 33, `daemon-server` 20, `core` 10, + `platforms` 7, root 5, `client` 1), and the external production importers of `daemon/types.ts` + (down to 2: the client normalizers and remote artifacts). R7 counts and external importers may only shrink; no zone may grow inside R9, and replay/Maestro/replay-test engine files remain outside - it. This per-zone migration ratchet is intentionally stricter than R9's ordinary total-growth - rule: during the extraction, even moving cycle membership into a zone at its ceiling must be - justified by lowering another ceiling or changing the migration baseline explicitly. Zero-count - policies begin enforcing when `src/ad-replay/` or `src/maestro/` first exists and - already protect `src/replay/test/`: engines cannot import daemon/platform/provider implementations, - and no logical module may deep-import another module's `internal/` tree. These are migration + it. This per-zone ratchet is intentionally stricter than R9's ordinary total-growth rule: even + moving cycle membership into a zone at its ceiling must be justified by lowering another ceiling + or changing the baseline explicitly. The #1478 extraction arc (P0–P5) completed against these + ratchets: engines live behind the `packages/{maestro,replay-test,ad-replay}` façades, engines + cannot import daemon/platform/provider implementations, and no logical module may deep-import + another module's `internal/` tree. The P6 platform-modularity phases were measured and deferred + at the #1478 checkpoint (2026-08-04): the Apple perf edge no longer participates in any cycle, + so the remaining R9 work sits at the command-registration hubs, not the platform seam. These are ratchets, not permission to scaffold façades before a real seam has two adapters. - Zero-dep CI jobs (R8). Some jobs run scripts straight from a checkout with `install-deps: false`, so they have no `node_modules`. Nothing local can feel that constraint — every dev machine has @@ -375,6 +377,21 @@ when landing it, satisfy the gate where one exists. (#1409) was closed unmerged. A new abstraction layer needs a demonstrated second consumer or axis of change. The one gated slice of this norm is tests: CI forbids test-only DI seams — a missing seam gets added as a real one or not at all. +- **Module seams** (the #1478 extraction's durable rules). State crosses seams as immutable + values, authority crosses as capabilities, and both only narrow; a seam exists when its port has + two real adapters (normally the daemon adapter and a deterministic in-memory adapter running the + same contract suite), otherwise it is indirection. Calls go down through module façades and back + through ports; no inter-module event bus — ADR 0018's journal is an observation channel, never a + coordination mechanism. No engine receives `DaemonRequest`, `DaemonError`, `SessionStore`, + mutable `SessionState`, provider handles, or concrete platform implementations; the daemon + adapter closes each capability over one already-admitted request, so an engine cannot express a + session name, acquire a lock, or select a provider scope. Session state keeps three consistency + disciplines distinct and never forces them through one generic transaction: immediate pessimistic + transitions for ref/observation lineage (ADR 0014's mid-request expiry is why end-of-request + commit/rollback is rejected), staged arm/complete/close-succeeded/commit-or-abort protocols with + operation-keyed receipts for repair/publication (ADR 0012/0016), and append-only facts for + recorded actions and diagnostics. Gates: R10 zero-count module policies, R11 package boundaries, + the façade symbol pins, and R7 ownership. - **Tests couple to stable interfaces.** Norm (see [Testing Principles](#testing-principles)) backed by the test-only-DI-seam gate above; broader test-strength enforcement is planned, not present — tracked under [#1412](https://github.com/callstack/agent-device/issues/1412). @@ -433,7 +450,7 @@ the observable freshness and failure semantics below before any runtime refactor Evidence: [ADR 0002](docs/adr/0002-persistent-platform-helper-sessions.md), [ADR 0004](docs/adr/0004-ios-snapshot-backend-strategy.md), [ADR 0005](docs/adr/0005-ios-runner-interaction-lifecycle.md), -[Maestro compatibility debt map](docs/maestro-compat-debt-map.md), +[ADR 0015](docs/adr/0015-direct-maestro-engine.md), [`find.test.ts`](src/daemon/handlers/__tests__/find.test.ts), [`snapshot-handler.test.ts`](src/daemon/handlers/__tests__/snapshot-handler.test.ts), [`snapshot-scoped-refs.test.ts`](src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts), diff --git a/docs/daemon-modularity-proposal.md b/docs/daemon-modularity-proposal.md deleted file mode 100644 index 5b22269a07..0000000000 --- a/docs/daemon-modularity-proposal.md +++ /dev/null @@ -1,997 +0,0 @@ -# Proposal: make daemon a serialized host, not the home of every runtime - -Status: revised design context; implementation handoff: -[`#1478`](https://github.com/callstack/agent-device/issues/1478) - -Measured at: `e545544dfa85` - -## Recommendation - -Keep the daemon as the serialized application host: - -- transport and authentication; -- request admission, cancellation, locks, and leases; -- request-scoped provider binding; -- session ownership and teardown; -- diagnostics, artifacts, response projection, and finalization. - -Move program semantics behind independent module APIs: - -- native `.ad` replay; -- Maestro; -- replay-test scheduling and reporting. - -Keep script-publication lifecycle in a session-owned capability. Native replay owns planning and -execution semantics, a pure internal codec owns canonical `.ad` syntax, and the session capability -owns publication validation. Active publication, ordinary and repair close-time publication, -repair commit, idle reap, and shutdown all consume live session state and must remain one -transactional authority. - -Connect those modules to daemon-owned capabilities through adapters created inside the already -admitted and locked request scope. Do not give an engine `DaemonRequest`, `DaemonInvokeFn`, -`SessionStore`, mutable `SessionState`, a provider handle, or a platform implementation. - -The organizing rules are: - -> State crosses boundaries as values, authority crosses as capabilities, and both only narrow. -> -> A boundary exists when its port has two implementations; otherwise it is indirection. - -The durable communication, state-consistency, R7, and locality rules live in -[`module-interface-principles.md`](module-interface-principles.md). This proposal applies them to -the daemon: immutable data travels down through façades, calls travel back through ports, and the -daemon adapter closes each capability over one already admitted request. Control flow stays -call-based; ADR 0018's journal is observation only, never an inter-module event bus. - -Make logical boundaries and one-way imports work under `src/` first. Consider private pnpm workspace -packages only after a module no longer reaches back into root source and a measured package trigger -exists. A package that imports `../../src/daemon` is a directory with a `package.json`, not -encapsulation. - -## Why change - -The production graph is acyclic and passes the repository's layering gates. The problem is not -spaghetti dependencies; it is weak information hiding and poor locality. - -| Production zone | Files | LOC | -| --- | ---: | ---: | -| daemon-server | 221 | 46,504 | -| platforms | 173 | 33,608 | -| commands | 110 | 16,468 | -| compat | 47 | 8,068 | -| utils | 72 | 7,278 | -| contracts | 83 | 6,756 | -| core | 32 | 6,526 | -| cli | 33 | 6,087 | -| replay | 22 | 4,339 | - -Relevant slices: - -| Slice | Files | LOC | -| --- | ---: | ---: | -| daemon `session-replay*` | 23 | 5,361 | -| native replay part of that slice | 19 | 4,297 | -| daemon replay-test runner | 8 | 1,967 | -| daemon Maestro integration | 4 | 1,064 | -| Maestro daemon adapter misplaced under `compat` | 5 | 1,549 | -| all Maestro production files under `compat` | 46 | 8,041 | -| Maestro engine/policy after removing daemon adapter files | 41 | about 6,492 | - -The test locality cost is larger than the production table suggests: - -- 27 daemon replay test files: 12,855 physical lines; -- 8 daemon replay-test files: 1,899 physical lines; -- 34 Maestro test files: 6,831 physical lines; -- `session-replay-vars.test.ts` alone is 2,299 lines. - -The dependency graph has zero value cycles and zero ranked back-edges. At the same time: - -- daemon-server has 117 collapsed dependencies on platforms, including 80 value dependencies; -- the dependency audit classified 88 daemon-server platform-condition branches, more than the - platform tree itself, so dependency direction alone understates the coupling; -- daemon-server has 22 dependencies on compat; -- compat has 5 dependencies back into daemon-server; -- `daemon/types.ts` is 508 lines with 155 production importers; -- `session-store.ts` has 62 production importers; -- `SessionStore.get()` returns a live mutable record whose aliasing is the persistence mechanism. - -The largest value-plus-type strongly connected component is 102 files. Its cycles are type-only, -so this is not a runtime correctness problem, but it is a locality problem. A fresh membership -probe at the measured commit finds 30 daemon files in that component, but none of the -`session-replay*`, `session-test*`, or `compat/maestro` engine files. Engine extraction therefore -must not claim this reduction. Session-state and platform-contract work in Phases 3 and 5 is where -the R9 baseline can shrink. In particular, `SessionState` has two direct type imports from concrete -platform perf implementations, but only the Apple `perf-xctrace.ts` edge is load-bearing: removing -it reduces the component from 102 files / 30 daemon files to 91 / 21. Removing only the Android -`perf.ts` edge leaves 102 / 30 unchanged, and removing both produces the same 91 / 21 as Apple -alone. The Android move is still ownership hygiene; it must not be credited with the SCC reduction. -The replacement Apple resource handle must not recreate the upward edge. - -Moving files without changing those interfaces would reduce a folder count while preserving the -reasoning burden. - -## Architectural constraints from the ADRs - -The migration must preserve these decisions: - -1. Providers remain below platform translation so provider integration tests exercise real platform - behavior (ADR 0001). -2. Platform helper reuse remains an optimization owned by the daemon/platform host; do not invent - one universal cross-platform runner (ADR 0002). -3. Daemon route and request policy remains private and distinct from public command metadata - (ADR 0003). -4. This is an implementation-only refactor for every supported surface: preserve the daemon RPC - request/response wire and do not bump `rpcProtocolVersion` (ADR 0006). Phase 3 deliberately closes - one accidental raw-RPC behavior—`record stop` with unsupported `flags.saveScript`—by rejecting or - ignoring that flag at the daemon seam. That behavior is wire-observable but absent from the CLI, - Node, and MCP contracts; pin the intended refusal before deleting its writer path rather than - treating adversarial input as a supported command contract. -5. Device/session locking and lease admission retain their current ordering (ADR 0007). -6. Command identity remains derived from `CommandDescriptor`; in-process `execute` and - cross-process `invoke` remain distinct (ADR 0008). -7. Keep the consolidated Apple-family model and its AppleOS discriminant/leak guard (ADR 0009). -8. Module failures preserve the ADR 0010 wire contract: `code`, `message`, `hint`, `details`, - `diagnosticId`, `logPath`, and optional typed `retriable`/`supportedOn` signals. -9. The guarantee matrix remains honest about every interaction path and fast path (ADR 0011). -10. Native replay retains target verification, structured divergence, plan-digest-bound resume, - agent-supervised repair, and no-clobber publication (ADR 0012). -11. Follow the ADR 0013 precedent at platform seams: normalize once into a canonical contract and - let adapters consume it rather than reinterpreting intent. -12. Ref admission and synchronous expiry remain at the actual daemon side-effect seam, including a - mutation that may have failed after changing the device (ADR 0014). -13. Maestro remains a source-preserving typed engine over its own runtime port. It must not lower - into native replay or share a replay VM with `.ad` (ADR 0015). -14. Active publication computes `sessionActive` from the daemon store, keeps ordinary publication - disjoint from repair state, and preserves the ARMED/ABORTED/PUBLISHED lifecycle (ADR 0016). -15. Sensitive live values are parameterized before durable recording or publication (ADR 0017). -16. If ADR 0018 proceeds, progress remains a separate transport output port rather than a generic - journal event. - -## Designs considered - -### A. Folder reshuffle only - -Move `session-test*` to `src/replay/test`, move Maestro files to `src/maestro`, and split the large -handler directory. - -This is useful as a migration step, but insufficient as the design. If the moved modules still -accept `DaemonRequest`, `SessionStore`, and `DaemonInvokeFn`, contributors can cross every boundary -exactly as before. - -### B. One universal ProgramRunner or shared effect VM - -Put `.ad` and Maestro in one package and make both yield a common `ProgramEffect` union. - -This makes the diagram small but the module shallow. `.ad` is a linear `SessionAction` format with -target-v1 verification and repair publication. Maestro has structured control flow, scoped -variables, script outputs, compatibility selector semantics, observation generations, and its own -resume restrictions. Their common execution vocabulary would either: - -- encode the semantic cross-product; -- become a generic daemon command trampoline; or -- quietly erase behavior ADR 0015 made explicit. - -Rejected. Share test-level outcomes and host capabilities, not interpreter semantics. - -### C. Big-bang workspace and universal DeviceGateway - -Immediately create packages for daemon, replay, Maestro, every platform, providers, contracts, and -UI trees. Replace the current platform/provider paths with one broad gateway. - -This gives hard physical boundaries, but the current engines still import root kernel, selector, -snapshot, request, and utility modules. Doing this first forces a root/package cycle or a broad -`foundation` package. It also puts tsdown bundling, declaration generation, dynamic platform loading, -and npm packaging on the critical path of a behavior-preserving refactor. - -Rejected as the first move. A request-bound device binding is promising later, but should compose -the existing `Interactor` and focused resource facets instead of replacing them with one tagged -`observe/interact/manage/resource` union. - -### D. One Redux-like or request-transactional session store - -Route every session mutation through actions/reducers and commit or roll back at request completion. - -Rejected. ADR 0014 requires ref-frame expiry to commit synchronously immediately before an -asynchronous device operation and to survive that operation's failure. End-of-request rollback -would recreate the success-only-rollback bug. Repair/publication are staged protocols with -failure-preserving tombstones, while actions/events are append-only streams; forcing all three -consistency disciplines through reducers adds a shallow translation layer. The request lock, -single-threaded runtime, capability-owned transitions, R7, and event audit trail already provide -the useful guarantees. - -### E. Domain-first strangler; workspaces only when earned - -Create small public façades around demonstrated domains, keep daemon-owned adapters at the outside, -and tighten the import graph after each move. Once a module has no root back-edge, keep the logical -module unless an independent build/consumer or an enforcement failure earns a package. - -Recommended. - -## Target architecture - -```mermaid -flowchart LR - Surface["CLI / MCP / daemon client"] --> Host["Daemon host"] - Host --> Scope["Locked request scope"] - Scope --> TestAdapter["Replay-test daemon adapter"] - Scope --> AdAdapter[".ad daemon adapter"] - Scope --> MaestroAdapter["Maestro daemon adapter"] - - TestAdapter --> Test["Replay-test scheduler"] - Test --> TestProgram["TestProgramExecutor"] - TestProgram --> AdAdapter - TestProgram --> MaestroAdapter - - AdAdapter --> Ad["Independent .ad replay engine"] - MaestroAdapter --> Maestro["Independent Maestro module"] - - Ad --> AdRuntime["AdReplayRuntime"] - Maestro --> MaestroPort["MaestroRuntimePort"] - AdRuntime --> Scope - MaestroPort --> Scope - - Scope --> Session["Locked session capabilities"] - Session --> Publication["Session script publication"] - Publication --> AdCodec["Internal canonical .ad codec"] - Scope --> Device["Request-bound device binding"] - Device --> Interactor["Interactor + focused resource facets"] - Interactor --> Platforms["Platform plugins and providers"] -``` - -The daemon adapter closes over one admitted request. Engine APIs do not accept an arbitrary session -name for mutation and cannot reacquire locks, switch provider scope, or address another session. - -Use three explicit visibility levels: - -| Surface | Visibility | Allowed consumers | -| --- | --- | --- | -| `.ad` engine façade and runtime types | module-public through `src/ad-replay/index.ts` | daemon `.ad` adapter | -| Maestro façade and runtime port | module-public through `src/maestro/index.ts` | daemon Maestro adapter | -| replay-test façade and host port | module-public through `src/replay/test/index.ts` | daemon replay-test adapter | -| canonical `.ad` codec | repository-private leaf contract | `.ad` engine and session script publication only | -| `SessionScriptPublication`, replay transaction, resource ledger | daemon-private capabilities | locked request and lifecycle coordinators | -| `LockedSessionContext`, `DaemonRequest`, provider handles | daemon-private implementation | daemon adapters and composition only | -| engine IR, parsers, mutable plan state | implementation-private | their owning engine | - -“Module-public” means the reviewed internal API other modules may import; it does not add an npm -export or change the daemon wire. Imports into an `internal/` directory fail the boundary gate. - -## Proposed module APIs - -### Native `.ad` replay - -Prepare once, then execute an opaque plan: - -```ts -export interface AdReplayEngine { - prepare(input: AdReplayPrepareInput): Promise; - execute( - prepared: PreparedAdReplay, - input: AdReplayExecutionInput, - runtime: AdReplayRuntime, - ): Promise; -} -``` - -`PreparedAdReplay` is opaque except for an immutable summary: source identity, plan digest, step -count, effective metadata, and static target hint. `AdReplayExecutionInput` carries runtime values -and an optional client-supplied resume claim: - -```ts -export type AdResumeClaim = { - from: number; - planDigest: string; -}; -``` - -`prepare` computes the digest from the freshly expanded plan; `execute` validates the claim against -that exact prepared plan. There is no daemon checkpoint keyed by digest and no second parse between -inspection and execution. - -The engine receives a small earned runtime port: - -```ts -export interface AdReplayRuntime { - readonly signal: AbortSignal; - - executeStep(input: AdStepExecution): Promise; - - /** - * Capture replay evidence and update operational observation state only. - * The returned value may carry opaque, one-shot lineage evidence; it cannot - * publish or replace client ref authority. - */ - observeReplay(input: AdObservationRequest): Promise; - - emitProgress(event: AdProgressEvent): void; -} - -export type AdStepExecution = { - action: SessionAction; - source: { - scriptPath: string; - sourcePath: string; - line: number; - ordinal: number; - }; - provenance: 'authored-ad-step'; - recordAs?: string; - targetGuard?: ReplayTargetGuardDenotation; - waitLandmark?: TargetAnnotationV1; -}; -``` - -The daemon runtime adapter owns translation to `DaemonRequest`, parent flag merging, -`internal.replayPlanStep`, target/landmark guards, session-scope inheritance through -`internal.resolvedSessionScope`, request-level provider-scope reuse, and response/error projection. -`observeReplay` updates the stored operational observation and returns opaque, one-shot -capture-lineage evidence. Daemon response composition projects the exact inline response or -successfully written overflow artifact, consumes and validates that evidence synchronously, and only -then activates the matching partial frame. Every finalization attempt consumes the token, including -empty, cancelled, invalid, and stale outcomes. The engine cannot publish refs or access session/store -authority; no asynchronous work may occur between activation and returning the projected response. -The outer replay keeps its stable session lock plus the device lock when known through this -finalization. Same-session nested actions reuse the admitted scope; their meaningful changes -invalidate lineage through observation, ref-frame, runtime-revision, or session-lifetime checks. - -The engine receives no session interface. A daemon-owned coordinator wraps `execute` inside the -already locked scope: - -1. Before execution, it validates the session-specific corrective-action watermark and configures - repair recording. -2. The runtime adapter arms recording when execution creates the session. -3. After a divergence outcome, it stores the returned resume watermark. -4. After success, it marks the repair transaction complete. -5. It maps the typed outcome into the daemon response and lifecycle decision. - -Arm, watermark, complete, abort, and tombstone operations remain private session implementation -details; they are not engine-callable methods. The production and deterministic in-memory runtime -adapters make `AdReplayRuntime` an earned seam rather than speculative indirection. - -### Session script publication - -Publication is a session capability, not a native replay runtime entry point: - -```ts -type ScriptPublicationTarget = Readonly<{ - path: string; - source: 'generated' | 'explicit' | 'healed-sibling'; - force: boolean; -}>; - -type RepairPlatformCloseReceipt = Readonly<{ - operationKey: string; -}>; - -interface SessionScriptPublication { - publishActive(input: ActivePublicationRequest): Promise; - finalizeExplicitClose(input: { - retarget?: { path: string; force?: true }; - platformClose: { - operationKey: string; - perform(): Promise; - }; - }): Promise; - finalizeLifecycleTeardown( - reason: 'idle-reap' | 'daemon-shutdown', - ): Promise; -} -``` - -This is a daemon-private, request-bound capability. Passing `platformCloseSucceeded: boolean` would -lose the operation identity and make sequencing the caller's responsibility. Instead, the -capability receives one narrow close operation and controls when it runs and when its success -receipt commits. It owns: - -- ADR 0016 eligibility, ARMED/ABORTED/PUBLISHED transitions, and repair-state disjointness; -- mutually exclusive tagged ordinary-recording and repair states rather than co-resident mutable - flags that callers must interpret; -- publication-side repair commit/tombstone transitions from ADR 0012; -- action-slice selection, durable target/per-target force authorization, close receipts, and retry - semantics; -- portability, unresolved-ref, target-evidence, and destination-guard validation; -- atomic create-exclusive or explicitly forced replacement; -- the store-derived active-session view used by response projection; and -- coordination across active publication, ordinary close, repair close, idle reap, and daemon - shutdown. - -`SessionScriptPublication` and a daemon-private `ReplaySessionTransaction` are two capability -projections over one private tagged aggregate, not two copies of session state. The replay -projection owns arm, corrective-action watermark, divergence, completion, and abort transitions; -the publication projection owns all artifact eligibility and commit transitions. Only the -daemon-owned locked replay coordinator sees the replay projection, and neither projection is passed -to an engine. - -> **Do not implement the following sketch verbatim.** It illustrates the one-aggregate/two-projection -> invariant and the minimum retry state. Issue -> [`#1478`](https://github.com/callstack/agent-device/issues/1478) owns the exact worker contract and -> supersedes this TypeScript wherever it is more specific. - -The current cluster of co-resident flags collapses into one state: - -```ts -type OrdinaryPublicationStatus = - | { kind: 'armed' } - | { kind: 'published'; path: string } - | { kind: 'aborted'; reason: 'second-open' }; - -type RepairPublicationStatus = - | { kind: 'armed' } - | { kind: 'complete' } - | { - kind: 'close-succeeded'; - completion: 'armed' | 'complete'; - receipt: RepairPlatformCloseReceipt; - } - | { kind: 'committed'; path: string; receipt?: RepairPlatformCloseReceipt } - | { - kind: 'aborted'; - reason: 'explicit-close-incomplete' | 'lifecycle-incomplete' | 'teardown-commit-failed'; - receipt?: RepairPlatformCloseReceipt; - }; - -type SessionScriptState = - | { kind: 'inactive' } - | { - kind: 'ordinary'; - target: ScriptPublicationTarget; - status: OrdinaryPublicationStatus; - } - | { - kind: 'repair'; - boundary: number; - sourcePath: string; - target: ScriptPublicationTarget; - watermark?: ResumeWatermark; - status: RepairPublicationStatus; - }; -``` - -ADR 0016 ordinary publication and ADR 0012 repair ownership are then type-level alternatives, -rather than nullable fields whose legal combinations every caller must remember. The repair close -transaction has one required ordering: - -1. Derive a candidate target from the request without mutating state. Force remains authorization - for one target: changing the path without a live `force` clears the old authorization. -2. If no receipt matches the effective platform-close operation key, perform that close. A failure - leaves the aggregate byte-for-byte unchanged. -3. After close succeeds, persist the target and `close-succeeded` receipt before attempting atomic - publication. -4. If publication fails, retain that state. A retry with the same close operation skips platform - dispatch and can safely retarget publication; a different operation key dispatches again. -5. Publication transitions to `committed`, retaining its receipt until the lifecycle coordinator - deletes the session. An incomplete repair transitions to explicit `aborted` state and never - publishes. Teardown persists the existing bounded tombstone before deleting failed or incomplete - repair state. - -The tagged aggregate and both capability projections must land together; do not temporarily keep a -second repair/publication state beside it. Centralizing the callers behind the locked replay -coordinator can follow as a separate migration step because it changes orchestration, not state -ownership. - -It uses an internal pure `.ad` codec for canonical serialization. The codec is shared syntax, not -the replay engine and not an injected port; the local filesystem remains directly testable through -temporary directories. Publication never asks replay to interpret `SessionState`, and replay never -owns session deletion or lifecycle ordering. - -This is the highest-risk extraction. Today `session-script-writer.ts` owns artifact preparation and -atomic commit, while active eligibility/transitions, close sequencing, and teardown tombstones live -in separate callers. There are five production `writeSessionLog` call sites: active publication, -ordinary close, repair close, lifecycle teardown, and a **surface-dormant** call after record-only -`record stop`. Supported CLI, Node, and MCP surfaces cannot attach `saveScript` to `record`, but a -raw JSON-RPC request can: `recordSessionAction` mirrors unvalidated request flags, -`recordActionEntry` sets `recordSession` when `saveScript` is truthy, and -`releaseRecordOnlySession(..., { writeLog: true })` then reaches the writer. This is accidental -daemon-seam behavior—there is no daemon-side per-command flag allowlist—not a fifth supported -publication trigger. Closing it is ADR 0003-compatible hardening, not a public contract removal. -First pin that every `record` action rejects or ignores `saveScript` without arming publication; -then delete the writer call. Preserve the four supported semantics behind one capability before -moving formatting code. - -### Maestro - -Keep the existing deep runtime port: - -```ts -export type MaestroRuntimePort = { - execute(request: MaestroRuntimeRequest): Promise; - observe(request: MaestroObservationRequest): Promise; - readMetrics?(): MaestroRuntimeMetrics; -}; -``` - -Add one façade: - -```ts -inspectMaestroFlow(source): MaestroFlowManifest; -prepareMaestroFlow(source, options): Promise; -executeMaestroFlow( - prepared, - runtime: MaestroRuntimePort, - options, -): Promise; -``` - -`PreparedMaestroFlow` is opaque except for stable metadata such as digest and step total. Typed -failure identity should be returned in `MaestroRunOutcome`, not reconstructed from a best-effort -observer side channel. - -Move these daemon-specific files out of the Maestro module: - -- `daemon-runtime-port.ts`; -- `daemon-runtime-port-support.ts`; -- `daemon-runtime-port-observation.ts`; -- `daemon-runtime-port-snapshot-source.ts`; -- `daemon-runtime-public-operation.ts`. - -Their destination is `src/daemon/adapters/maestro/`. Neutral snapshot presentation and tree helpers -with both daemon and Maestro consumers move to `src/snapshot/`. - -Of these five files, `daemon-runtime-port-support.ts` and -`daemon-runtime-public-operation.ts` directly import `daemon/types.ts`; the other three are -daemon-specific by role rather than by that direct dependency. - -No code outside Maestro imports its IR, plan steps, parser, selector ranking, expression state, or -compatibility internals. - -### Replay-test - -The test runner is the highest-confidence extraction. It already has one production caller and a -real callback seam: - -- `runReplay`; -- `cleanupSession`; -- `finalizeAttempt`. - -Its public API should become: - -```ts -export function runReplayTestSuite( - request: ReplayTestSuiteRequest, - host: ReplayTestHost, -): Promise; - -export interface ReplayTestHost { - inspect(sourcePath: string): Promise; - execute(input: ReplayTestAttempt): Promise; - finalize(input: ReplayTestFinalization): Promise; - cleanup(input: ReplayTestCleanup): Promise; - progress: ReplayTestProgressPort; -} -``` - -The scheduler owns discovery, filtering, retries, fail-fast, sharding, timeouts, result aggregation, -and reporters. It does not import either engine, daemon types, platform types, global request -progress, or `SessionStore`. - -The daemon adapter selects the `.ad` or Maestro implementation and owns: - -- session naming; -- nested in-process invocation; -- video lifecycle; -- artifact registration; -- cancellation binding; -- mapping neutral failures to `DaemonError`. - -The common interface stops at `inspect/execute` for test orchestration. Direct `.ad` repair/resume -and Maestro control-flow APIs remain engine-specific. - -### Session runtime - -Do not replace the roughly 266-line `SessionState` declaration with one giant class containing 100 -methods. Build an internal composition object around demonstrated state machines: - -```ts -interface LockedSessionContext { - readonly id: string; - readonly device: DeviceInfo; - view(): SessionView; - - readonly refs: RefFramePort; - readonly replay: ReplaySessionTransaction; - readonly scripts: SessionScriptPublication; - readonly recording: RecordingSessionPort; - readonly resources: SessionResourceLedger; - readonly lease: SessionLeasePort; -} -``` - -Only request execution can create a `LockedSessionContext`. It is an internal capability bag, not a -stable engine interface or package export. The replay adapter receives only `AdReplayRuntime`; the -daemon-owned replay coordinator closes over the locked context. The store's live mutable access -becomes capability-only, while ordinary readers receive immutable `SessionView` snapshots. - -The aggregate does not pretend all session data has one transaction model. It composes three -disciplines: - -1. **Immediate pessimistic transitions** for ref frames and observation lineage: commit - synchronously before an asynchronous side effect and never roll back on its failure. -2. **Staged protocols** for repair and publication: arm, establish a watermark, complete, then - settle any platform close, commit or abort, and preserve close receipts or failure tombstones - across retries. -3. **Append-only streams** for recorded actions and journal events: append facts without using - them to coordinate mutable state. - -Migration can then proceed field-cluster by field-cluster: - -1. ref frame (already close to this shape); -2. replay repair/publication; -3. recording; -4. runtime resources; -5. lease and advisory claim; -6. observation/snapshot state. - -R7 is the migration vehicle, not a temporary allowlist. At the measured commit it covers 30 -writer-owned fields through 42 owner-file claims, plus 11 store-established fields. For each -cluster: pin its invariant, route its writes through transition methods, collapse its nullable -fields into one tagged state, expose the corresponding immutable `view()`, and replace its R7 rows -with the new capability owner. The shrinking field/owner table reports progress while continuing -to reject undeclared writers. - -A resource ledger records cleanup when a runner, recording, app log, audio probe, perf capture, IME, -helper, or materialized path is acquired. Close, expiry, idle reap, and shutdown call one lifecycle -coordinator that preserves cleanup ordering and failure isolation. - -### Platform boundary - -Keep `Interactor` and the provider-first seam. Replace global platform registration with an injected -immutable registry, and eventually bind one request-scoped device runtime: - -```ts -export interface DeviceRuntimeGateway { - discover(request: DeviceInventoryRequest): Promise; - bind(context: DeviceBindingContext): Promise; -} - -export interface BoundDeviceRuntime { - readonly interactor: Interactor; - readonly appLog?: AppLogPort; - readonly recording?: RecordingPort; - readonly performance?: PerformancePort; - readonly lifecycle?: DeviceLifecyclePort; - release(): Promise; -} -``` - -This can reduce the eight resolver-shaped fields of the 20-field `RequestRouterDeps` to one binding -dependency; unrelated router dependencies remain. It does not invent another generic command -dispatcher. Concrete runner, ADB, helper, browser, log, recording, and perf types stay inside -platform/provider adapters. Session state stores neutral resource references, not Apple capture -objects or Android process unions. - -Only the composition root may import concrete platform packages. - -## Source layout before workspaces - -```text -src/ - ad-replay/ - index.ts - internal/ - maestro/ - index.ts - runtime-port.ts - internal/ - replay/ - ad-codec/ - index.ts - test/ - index.ts - internal/ - daemon/ - adapters/ - ad-replay/ - maestro/ - replay-test/ - platform/ - session/ - refs/ - replay-coordinator/ - scripts/ - recording/ - resources/ - lifecycle/ -``` - -Barrels exist only at these module boundaries. Internal code uses direct sibling imports. - -This layout defines locality operationally: each module directory owns one façade, its -`internal/` implementation, seam contracts/adapters, and tests that mirror the source topology. A -contributor starts at the façade and should remain inside that directory except for named ports and -leaf contracts. Zero imports into another module's `internal/` tree enforce that reading model. -R9 remains the anti-locality metric for type hubs that file moves do not fix. - -## Conditional workspace layout - -If package triggers are met after imports become one-way, use this candidate order: - -```text -packages/ - ad-replay/ - maestro/ - replay-test/ -``` - -Each package: - -- is `"private": true` initially; -- uses `workspace:*` dependencies; -- exports only `"."` unless a second real consumer earns a subpath; -- has TypeScript project references; -- cannot import root `src`, daemon, platform implementations, or another engine; -- is bundled into the existing single published `agent-device` artifact; -- preserves dynamic platform loading. - -Do not create `foundation`, `common`, or `program-api` by default. Extract a low-level contracts -package only from types with multiple real package consumers. The first candidate should be a small -snapshot/selector contract, not a dump of root utilities. - -Session runtime and platform packages should remain logical modules until their APIs and build -ownership are equally clear. - -## Enforcement - -Use the declarative zone-policy work from PR #1449 as the repository-wide policy source. Its -`eslint-plugin-boundaries` spike correctly identified tradeoffs: no ratchet, an inline type import -false positive, alpha plugin loading, and a large transitive dependency cost. Do not add that plugin -now. - -Enforce the new shape with: - -1. the existing layering and dependency-graph gates; -2. explicit new module zones and zero-count import rules; -3. zero imports into another module's `internal/` tree; -4. R7 field ownership as the session migration ratchet; -5. package `exports` when workspaces land; -6. TypeScript project references; -7. no wildcard exports or internal barrels; -8. contract suites run against the daemon adapter and its deterministic in-memory twin; -9. provider integration scenarios that still replace providers below platform translation. - -A proposed port does not enter the target policy until both implementations pass the same contract -suite. Local-substitutable dependencies such as filesystem writes continue to use temporary -directories directly rather than gaining mock-only ports. No event-bus dependency is permitted: -module control flow stays explicit and call-based. - -Target policy: - -| Module | Forbidden imports | -| --- | --- | -| ad-replay | daemon, platforms, providers, compat, Maestro | -| Maestro | daemon, platforms, providers, ad-replay | -| replay-test | daemon, platforms, providers, both engine internals | -| session capabilities | handlers, transport, concrete platforms | -| daemon handlers | engine internals, concrete platform internals | -| daemon adapters | may bridge only the explicitly named sides | - -## Tradeoffs and mitigations - -| Tradeoff | Mitigation | -| --- | --- | -| More adapter code | Quarantine request-shape complexity in adapters, keep each below 300 LOC, and require a contract-tested in-memory twin. | -| Internal public APIs need discipline | Review them as APIs but change them atomically with the repository: one façade file, no deprecation theater, no deep imports. | -| Softer enforcement under `src/` | Use the existing R4/R5 gates, zero-count module rules, and an `internal/` import ban; promote to workspaces only when a measured trigger appears. | -| Session encapsulation is difficult | Migrate tagged field clusters independently, preserve their distinct consistency disciplines, and use R7 plus focused invariant suites as the ratchet. | -| Facets may reveal missing abstractions or add LOC | Treat that as discovery. Ratchet the 88 daemon platform-branch sites; LOC alone is not evidence that the seam improved. | -| Workspaces add build and packaging complexity | Keep Phase 6 conditional and preserve the single published artifact and lazy platform loading. | - -## Migration sequence - -### Phase 0: pin and ratchet - -- Land or rebase the declarative policy table from PR #1449. -- Record current dependency counts and add zero-count targets for new modules. -- Use the frozen replay-compat corpus under `test/replay-compat/` to pin released `.ad` parse - behavior and provenance. -- Use `test/integration/nightly/concurrency-torture.test.ts` to pin session/lease/lock - serialization, and provider scenarios to pin provider-first behavior through real platform - translation. -- Keep focused contract tests for nested same-session execution, provider-scope reuse, ref expiry, - replay divergence/resume, repair and active publication, and Maestro runtime-port behavior. -- Define neutral module input, outcome, and ADR 0010 failure contracts before moving - implementations. `SessionAction`, `ReplaySuiteResult`, and `ReplaySuiteTestResult` already live - in `contracts/` at the measured commit; do not move daemon-private `DaemonRequest` down merely to - make an import disappear. -- Eliminate the four remaining production importers of `daemon/types.ts` from outside daemon: - client and remote code use their existing public contracts, while the two Maestro files move - into the daemon adapter. Keep `DaemonInvokeFn` adapter-local. -- Record both the 102-file R9 size and its zone membership. Engine extraction must not increase it - or pull an engine file into it; session and platform phases lower `TYPE_CYCLE_BASELINE` in the - same change whenever the component shrinks. -- Record R7's current 30 writer-owned fields and 42 owner-file claims. Each session capability move - must shrink or consolidate that table and move ordinary readers to immutable `view()` - projections. -- Do not add duplicate production paths or fallback engines. - -### Phase 1: extract replay-test - -- Move the eight `session-test*` implementation files beside existing replay-test reporters. -- Replace `DaemonRequest`/`DaemonResponse` with neutral request/result types. -- Replace global cancellation/progress reads with `AbortSignal` and an explicit progress port. -- Leave a thin daemon adapter for nested execution, video, cleanup, artifacts, and response mapping. -- Split the moved tests to mirror the new source topology. -- Keep every replay-test file outside the largest type SCC. - -This relocates about 1,967 production LOC; the net daemon-server reduction is that amount minus the -thin attempt adapter that remains. - -### Phase 2: correct Maestro ownership - -- Add a Maestro façade and route all non-test consumers through it. -- Move the five daemon runtime files to `src/daemon/adapters/maestro`. -- Move genuinely neutral snapshot helpers to `src/snapshot`. -- Move source-kind selection above native replay so `.ad` execution has no Maestro imports. -- Return typed failure details from the engine outcome. -- Gate Maestro-to-daemon imports at zero. -- Remove the two `compat/maestro` production imports of `daemon/types.ts` by moving those adapter - roles, not by promoting `DaemonRequest` into contracts. - -This is mostly ownership correction around an already-proven port. - -### Phase 3: encapsulate replay/session transactions - -- Consolidate the spread of repair fields into one private replay state capsule. -- Add one daemon-owned locked replay coordinator that wraps engine execution and privately applies - corrective-resume admission, arm, watermark, complete, abort, and tombstone transitions. Do not - expose those transitions to the engine. -- Put active, ordinary-close, repair-close, and lifecycle-teardown publication behind - `SessionScriptPublication`, preserving ADR 0016 disjointness and store-derived session activity. - Collapse output target, per-target force authorization, repair completion, committed/aborted - status, and the operation-keyed platform-close receipt into the tagged aggregate. Pin that a - failed platform close leaves state unchanged; a failed publication retains the receipt; the same - close retry skips dispatch; and a different close identity dispatches again. -- At the daemon request seam, pin that every `record` action rejects or ignores unsupported - `saveScript` without arming publication. Only then delete the surface-dormant but raw-wire - reachable `record stop` writer invocation. -- Move each field cluster behind transition methods, collapse it to tagged state, update R7 in the - same change, and stop new code from receiving mutable `SessionState`. -- Reserve the live store record for capability implementations; move ordinary readers to immutable - `SessionView` projections. -- Introduce the resource ledger and one lifecycle coordinator before changing teardown ownership. -- Replace concrete platform perf objects in `SessionState` with opaque resource handles owned by - the ledger/platform adapter. The Apple `perf-xctrace.ts` type edge is the load-bearing R9 edge; - do not recreate it under a new name. Removing the Android perf edge is ownership hygiene but does - not shrink the component by itself. - -### Phase 4: extract native replay - -- Add the `prepare/execute` engine façade under `src/ad-replay`. -- Move parsing, includes, planning, variables, digest construction, verification, resume rules, - divergence construction, and typed outcomes behind it. -- Implement `AdReplayRuntime` in `src/daemon/adapters/ad-replay`. -- Keep canonical `.ad` syntax in a pure internal codec shared with session publication. Publication - keeps portability/destination validation, atomic commit, and lifecycle authority. -- Preserve inherited session scope, request-level provider scope, the existing lock, and the - side-effect/ref-frame seam. `observeReplay` updates operational observation and returns opaque - lineage evidence; daemon response composition alone activates the exact projected partial frame. -- Delete superseded helpers and handler-mock tests as their interface replacements land. - -### Phase 5: narrow platform binding - -- Inject a platform registry rather than mutate a global registry. -- Wrap current local/provider selection in a request-bound `DeviceRuntimeGateway`. -- Move snapshot acquisition, concrete logs, recording, perf, and lifecycle mechanics behind focused - facets. -- Keep selector/ref policy, settle/verify, response construction, and guarantee classification in - daemon/core. -- Ratchet concrete daemon-to-platform imports toward composition/adapters only. -- Recompute the platform-conditional branch inventory and ratchet it down as plugin facets absorb - mechanics; a gateway that leaves the branches in daemon has not improved the seam. -- Recompute the R9 component after removing concrete platform state from session contracts and - lower `TYPE_CYCLE_BASELINE` immediately. The measured Apple-perf edge cut makes 91 the first - evidence-backed upper bound, not an aspirational guess; the Android-only cut leaves 102 - unchanged. - -### Optional Phase 6: create private workspace packages only when earned - -Logical modules are the target architecture; workspaces are not a scheduled milestone. Promote a -module only when it has zero root back-imports, a stable façade exercised by daemon and in-memory -adapters, and at least one measured reason that zone gates plus module exports are insufficient: - -- a second real build or consumer; -- independent ownership, test caching, or declaration checking that pays for package plumbing; or -- repeated boundary regressions that physical package resolution would prevent more reliably. - -When a trigger exists, add `packages/*` to `pnpm-workspace.yaml`, move ad-replay, Maestro, then -replay-test independently, configure tsdown to retain the single published artifact and lazy -platform imports, and add package build/declaration checks. If no trigger appears, stop with the -logical modules. - -### Phase 7: consider ADR 0018 - -The event journal can simplify diagnostics/session-event/replay-trace finalization, but it is not a -prerequisite for the module split. Progress remains separate. - -## Success criteria - -Measure information hiding, not just moved lines: - -| Metric | Current | Target | -| --- | ---: | ---: | -| daemon-server production LOC | 46,504 | below 42,000 after engine extraction and thin adapters | -| compat → daemon dependencies | 5 | 0 | -| replay-test imports from daemon | current direct coupling | 0 | -| engine-to-engine imports | 0 | 0 | -| R7 writer-owned fields / owner-file claims | 30 / 42 | shrinking to one owner row per capability-owned tagged cluster | -| direct mutable `SessionState` writers outside session capabilities | R7-policed | 0 | -| live `SessionStore` record access | 62 production store importers, readers and writers mixed | capability-only; ordinary readers use immutable `SessionView` | -| daemon → platform value dependencies | 80 | composition/adapters only | -| daemon platform-conditional branch sites | 88 in the dependency audit | decreasing per extracted facet; composition-only residuals justified | -| concrete platform state types in `SessionState` | several | 0 | -| external production importers of `daemon/types.ts` | 4 | 0 by Phase 2 | -| direct `daemon/types.ts` → platform implementation imports | 2 | 0 | -| engine files in largest type SCC | 0 | remain 0 | -| largest value-plus-type SCC (R9) | 102 files | no growth in engine phases; at most 91 after platform-resource extraction | -| daemon-server files in largest SCC | 30 | at most 21 after platform-resource extraction | -| `TYPE_CYCLE_BASELINE` | 102 | equal to each newly achieved lower value | -| `RequestRouterDeps` provider resolver fields | 8 of 20 total fields | 1 request-bound binding; unrelated fields unchanged | -| script publication assembly paths | 4 supported paths plus 1 surface-dormant/raw-wire writer path | 1 session capability; unsupported flag refused and writer call deleted | -| replay/test route adapter size | mixed into handlers | each below 300 LOC | -| public runtime exports per engine | many deep imports | one façade | -| imports into another module's `internal/` tree | not yet zoned | 0 | -| runtime cycles/back-edges | 0 / 0 | 0 / 0 | - -The identified replay/test/Maestro integration slices contain roughly 7,300 LOC of domain behavior. -Some of that must remain as daemon adapters, and correcting Maestro ownership first moves about -1,550 adapter LOC from `compat` toward the daemon side. A realistic net daemon-server reduction is -roughly 4,500–6,000 LOC, depending on how much response/artifact/session adaptation remains. Total -repository LOC will initially remain close to flat. The immediate benefit is that a contributor can -answer one question by reading one module and can only cross a boundary through a reviewed type. -For platform extraction, the branch-site count—not LOC—is the honest measure of whether mechanics -actually moved behind facets. - -## Design probes and production evidence - -Two throwaway prototypes live under `scripts/prototypes/daemon-boundaries/`. - -The program-boundary prototype exercises `.ad` and Maestro through one test-level `inspect/execute` -interface while recording separate native and Maestro runtime-port calls. It demonstrates that the -test scheduler can be engine-neutral without a shared VM. - -The session-boundary prototype exercises: - -- ref activation and expiry before both successful and failed mutations; -- parameterized recording; -- one private tagged aggregate exposed through separate replay and publication capability - projections; -- repair arm, corrective-resume watermark, completion, close receipt, retry, and commit on the same - aggregate instance; -- platform-close failure without state mutation; -- durable target/force and close-receipt state across a failed publication; -- same-close retry without redispatch, changed-close redispatch, and explicit committed/aborted - terminal states; -- client resume-digest validation outside session state; -- no-clobber active publication and repair/publication disjointness. - -It demonstrates that these invariants can be expressed through two narrow capability projections -over one aggregate without exposing a mutable session record or maintaining parallel repair and -publication state. - -These are interface-thinking aids, not feasibility evidence. The stronger production evidence is -the existing replay-test seam: `session-test-types.ts` already describes `runReplay`, -`cleanupSession`, and `finalizeAttempt`, and `session-replay.ts` is its single production adapter. -The migration should deepen and relocate that seam instead of creating a parallel scheduler. - -Run them with: - -```sh -pnpm prototype:program-boundary -pnpm prototype:session-boundary -``` - -Keep this proposal, the dependency-findings update, the scripts-local README, and both executable -probes in one change. The root scripts make these deliberate executable entry points for humans and -Fallow; they remain throwaway logic models, not production scaffolding or regression suites, and -should be removed with the probes after the migration questions are settled. - -## Implementation handoff - -Issue [`#1478`](https://github.com/callstack/agent-device/issues/1478) is the versioned execution -plan. It records the post-review interface decisions, compatibility constraints, dependency-ordered -worker briefs, STOP conditions, success metrics, and final documentation cleanup. The sketches in -this proposal illustrate those constraints; they are not a standalone worker specification. Where -the issue is more specific, implement the issue rather than extrapolating from a sketch here. - -This proposal remains the source for rationale, ADR constraints, measurements, tradeoffs, and probe -evidence. The optimization target is the minimum context and authority required for a safe -contribution, not minimum LOC or the maximum number of packages. diff --git a/docs/maestro-compat-debt-map.md b/docs/maestro-compat-debt-map.md deleted file mode 100644 index 85724b630f..0000000000 --- a/docs/maestro-compat-debt-map.md +++ /dev/null @@ -1,38 +0,0 @@ -# Maestro Compatibility Architecture - -This map describes the direct compatibility engine after ADR 0015. Maestro YAML has one production -route: typed parse, immutable plan compilation, typed execution, and a runtime port backed by public -agent-device commands. There is no `SessionAction` lowering, private command namespace, runtime -fallback, or second compatibility engine. - -| Area | Owners | Shared boundary | Remaining risk | -| --- | --- | --- | --- | -| Program parsing | `program-ir*.ts`, `program-loader.ts` | Source-preserving typed IR | The supported Maestro subset must reject unknown syntax explicitly. Keep parser modules focused as the grammar grows. | -| Plan and resume | `replay-plan*.ts`, `replay-plan-digest.ts` | Immutable expanded plan and digest | Runtime control steps are opaque resume boundaries. Includes, static conditions, and environment inputs must remain digest-bound. | -| Execution | `engine*.ts`, `runtime-port*.ts` | Typed commands, observation generations, runtime port | Mutation must invalidate observation evidence. Scoped and output variables must not leak across flow boundaries. | -| Daemon binding | `daemon-runtime-port*.ts` | Typed calls to public daemon commands | Reuse same-generation observation snapshots only as semantic evidence for atomic iOS selector dispatch, never as coordinate geometry. Otherwise preserve one fresh target snapshot, structured target retries, and platform-independent command semantics. Never restore private compatibility dispatch or re-enter Maestro parsing. | -| Target policy | `runtime-target*.ts` | Shared snapshot model in provider order | Exact-or-regex selector behavior and visibility are compatibility policy. Default selection follows Maestro's first-match semantics; explicit `index` selects a later match. Provider normalization belongs below this layer. | -| Gestures | `runtime-port-geometry*.ts` | ADR 0013 normalized single-pointer input | Preserve authored absolute/percentage endpoints exactly. Maestro does not own multi-touch planning or injection. | -| Failure and resume reporting | `session-replay-maestro-*.ts` | ADR 0012 `REPLAY_DIVERGENCE` wire contract | Source paths, step ordinals, artifacts, scrubbed variables, screen digest, and typed resume preflight must survive nested control failures. | -| Trusted scripts | `run-script-*.ts` | Typed environment/output maps | `node:vm` is not a security sandbox. Keep execution flow-local and do not expand trust without a separate product decision. | -| Suite integration | `session-test-*.ts`, replay backend selection | Existing test lifecycle and artifacts | YAML discovery must remain backend-scoped; `.ad` continues through generic replay even inside a Maestro suite. | - -## Deliberately Removed - -- Generic `SessionAction` conversion and `replayControl` wrappers. -- Private `__maestro*` command routing and positional JSON decoding. -- WeakMap compatibility state and action-after-assertion replay. -- `wait`/`find` fallback chains after coordinate input. -- Selector-name heuristics for text-entry coalescing. -- Fuzzy substring matching that broadened plain Maestro text selectors. -- Duplicate-stack ranking and visible-context promotion over provider order. -- Fabricated tab-strip slots and ancestor geometry used in place of provider rectangles. -- Cached gesture frames; percentage gestures use fresh shared viewport evidence. - -## Convergence Rules - -1. Compatibility aliases normalize into shared public commands; they do not clone platform input. -2. Single-pointer target and viewport plans stay distinct from ADR 0013 multi-touch plans. -3. Retry policy is typed and budgeted. Infrastructure failures propagate unless a structured error is explicitly retriable. -4. Performance comparisons count provider queries, captures, retries, hierarchy bytes, and warm latency, not only daemon round trips. -5. New parity behavior needs a typed-IR fixture, an engine/runtime-port test, and live Android+iOS evidence when device behavior is involved. diff --git a/docs/module-interface-principles.md b/docs/module-interface-principles.md deleted file mode 100644 index 64d625d11f..0000000000 --- a/docs/module-interface-principles.md +++ /dev/null @@ -1,165 +0,0 @@ -# Internal module interface principles - -Status: proposed architecture guidance - -These rules govern logical modules inside `agent-device`. They optimize for the minimum context and -authority needed to make a safe contribution, not minimum LOC or the maximum number of packages. - -## Capabilities narrow authority - -> State crosses boundaries as values, authority crosses as capabilities, and both only narrow. -> -> A boundary exists when its port has two implementations; otherwise it is indirection. - -Authority travels as an unforgeable argument, never as ambient state: - -```text -request token - -> admitted request (lock, lease, provider scope) - -> capability port (one session, no re-locking or session addressing) - -> engine runtime (only the operations its port exposes) - -> platform facet (one request-bound device) -``` - -An engine cannot mutate another session because its interface cannot express a session name, obtain -a store, acquire another lock, or select another provider scope. The daemon adapter closes over the -already admitted request and supplies only the capability required for that execution. - -A port is earned by two real adapters: normally the daemon adapter and a deterministic in-memory -adapter used by the same contract suite. Local-substitutable dependencies such as filesystem writes -use temporary directories directly; they do not acquire a public port solely for mocking. - -## Communication rules - -Treat these as PR tests: - -1. **Down through façades, up through ports, data as values.** Cross-module data is immutable: - prepared plans, step requests, observations, progress events, and typed outcomes. A mutable - object crossing the seam means the seam is fictional. -2. **Every port has two real adapters.** The production and in-memory adapters run the same - contract. If the in-memory twin cannot implement the port without recreating daemon internals, - the port is too broad or sits at the wrong seam. -3. **No event bus between modules.** Control flow remains explicit, call-based, and serialized. - ADR 0018's journal is an observation channel for diagnostics, reporting, and analysis; no module - coordinates state by subscribing to another module's events. - -## Session-state consistency - -Session state contains three different consistency disciplines. Do not force them through one -generic transaction mechanism. - -### Immediate pessimistic transitions - -Authorization and observation-lineage transitions commit synchronously before an asynchronous -device operation. Ref-frame expiry is the model: expire immediately before the possible side -effect, and retain that expiry even when dispatch times out or fails. - -### Staged protocols - -Repair and publication are saga-shaped protocols: arm, establish a watermark, complete, then commit -or abort while preserving operation receipts and failure tombstones. A successful non-idempotent -step commits its operation-keyed receipt before the next fallible step. Retry reuses a matching -receipt; a different operation identity executes again. Their failure handling is domain behavior -owned by the capability, not generic store rollback. - -### Append-only streams - -Recorded actions and journal events append facts. They are not reconstructed mutable state and do -not coordinate modules. - -Collapse each mutable field cluster into one discriminated union so invalid combinations are -unrepresentable. For the script cluster: - -```ts -type ScriptPublicationTarget = Readonly<{ - path: string; - source: 'generated' | 'explicit' | 'healed-sibling'; - force: boolean; -}>; - -type RepairPublicationStatus = - | { kind: 'armed' } - | { kind: 'complete' } - | { - kind: 'close-succeeded'; - completion: 'armed' | 'complete'; - receipt: { operationKey: string }; - } - | { kind: 'committed'; path: string; receipt?: { operationKey: string } } - | { - kind: 'aborted'; - reason: 'explicit-close-incomplete' | 'lifecycle-incomplete' | 'teardown-commit-failed'; - receipt?: { operationKey: string }; - }; - -type ScriptPublicationState = - | { kind: 'inactive' } - | { - kind: 'ordinary'; - target: ScriptPublicationTarget; - status: - | { kind: 'armed' } - | { kind: 'published'; path: string } - | { kind: 'aborted'; reason: 'second-open' }; - } - | { - kind: 'repair'; - boundary: number; - sourcePath: string; - target: ScriptPublicationTarget; - watermark?: ResumeWatermark; - status: RepairPublicationStatus; - }; -``` - -This makes ordinary publication and repair ownership mutually exclusive by construction. It also -keeps the publication target and its force authorization together, and makes a successful platform -close durable before atomic publication. A publication failure remains `close-succeeded`; retrying -the same operation does not repeat the close. - -A Redux-like or request-transactional session store is deliberately rejected. ADR 0014 requires -ref-frame expiry to commit in the middle of a request, before awaiting the device operation, and to -survive that operation's failure. End-of-request commit or rollback would recreate the -success-only-rollback bug. Existing mechanisms already provide the useful properties: the request -lock and single-threaded runtime provide isolation, capability modules own mutations, and the event -log provides an audit trail. Actions and reducers would add a shallow indirection layer without -adding safety. - -## R7 is the migration ratchet - -`SESSION_STATE_FIELD_OWNERS` already assigns every `SessionState` field to its legal writers. Move -one field cluster at a time: - -1. take its declared owner and pin the current invariant through the owning interface; -2. move every write behind capability transition methods; -3. collapse the nullable fields into one tagged field; -4. expose immutable `view()` projections to readers; -5. replace the cluster's R7 rows with the single new owner entry. - -The shrinking field/owner table is the progress metric. `SessionStore.get()` returning a live record -becomes capability-only; ordinary readers consume snapshots from `view()`. - -## Locality is operational - -A logical module is one directory containing: - -- one façade that presents its complete interface; -- implementation under `internal/`; -- contracts and adapters owned at its seams; and -- tests whose topology mirrors the implementation. - -A contributor answering one module question should start at its façade and remain inside that -directory except for named ports and leaf contracts. The gate enforces zero imports into another -module's `internal/` tree. Tests move with their subject rather than remaining in daemon-wide family -files. - -Locality is measured by: - -- forbidden-import counts at zero; -- public interface size and number of permitted consumers; -- R7 field/owner rows for session state; -- the largest value-plus-type SCC (R9); and -- whether source and tests for one question fit within one bounded directory read. - -Moving an engine does not improve R9 when none of its files belong to the SCC. That work continues -at the actual type hubs and concrete platform-state edges after extraction. diff --git a/package.json b/package.json index 51ca31b158..ce01a2f6ec 100644 --- a/package.json +++ b/package.json @@ -109,8 +109,6 @@ "maestro:conformance": "node --experimental-strip-types --test packages/maestro/test/conformance/verify.test.ts packages/maestro/test/conformance/differential/run.test.ts packages/maestro/test/conformance/differential/invariants.test.ts", "maestro:conformance:regenerate": "node --experimental-strip-types scripts/maestro-conformance/regenerate.mjs", "maestro:conformance:differential": "node --experimental-strip-types packages/maestro/test/conformance/differential/run.ts", - "prototype:program-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/program-boundary.ts", - "prototype:session-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/session-boundary.ts", "size": "node scripts/size-report.mjs", "perf": "node --experimental-strip-types scripts/perf/run.ts", "mutation:run": "node --experimental-strip-types scripts/mutation/run.ts", diff --git a/scripts/layering/facade-symbols.ts b/scripts/layering/facade-symbols.ts index c4d402f1a4..ec8c9328ad 100644 --- a/scripts/layering/facade-symbols.ts +++ b/scripts/layering/facade-symbols.ts @@ -348,7 +348,9 @@ export const FACADE_SYMBOLS: readonly (readonly [string, readonly string[]])[] = 'TvRemoteButton', 'TvRemoteCommandResult', 'VegaTvRemoteKey', + 'WAIT_REASONS', 'WaitCommandResult', + 'WaitReason', 'assertExclusiveScrollDistanceInputs', 'assertNoRemovedSwipeInput', 'assertScrollGestureInput', diff --git a/scripts/prototypes/daemon-boundaries/README.md b/scripts/prototypes/daemon-boundaries/README.md deleted file mode 100644 index 8a22f267a0..0000000000 --- a/scripts/prototypes/daemon-boundaries/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Daemon boundary prototypes - -These are throwaway executable design probes, not production implementations. Their assertions make -two architecture questions concrete before changing module ownership; they are not evidence that -the production migration is complete or behaviorally equivalent. - -## Program boundary - -Run: - -```sh -pnpm prototype:program-boundary -``` - -Question: can replay-test orchestration run both `.ad` and Maestro files through one small -program-level API without creating a shared replay VM? - -The prototype keeps the native replay and Maestro interpreters independent. Each adapter privately -binds its engine to a different runtime port. The test scheduler sees only `inspect` and `execute`; -this is the scheduler seam, not the native engine façade, which the proposal shapes as `prepare` -plus `execute` over one opaque prepared plan. It executes passing and failing examples for both -formats, asserts the independent runtime traces, and prints compact JSON evidence. - -## Session aggregate - -Run: - -```sh -pnpm prototype:session-boundary -``` - -Question: can a daemon-owned coordinator keep replay from receiving any mutable `SessionState` -interface, while plan-digest validation remains engine-owned and publication remains session-owned? - -The prototype uses one internal tagged script aggregate, not an engine port or parallel replay and -publication state. Separate `ReplaySessionTransaction` and `SessionScriptPublication` capability -projections mutate that same aggregate. The repair scenario carries one instance through arm, -corrective-resume watermark, recorded correction, completion, successful platform-close receipt, -failed publication, retry, and commit. - -Its assertions pin the transaction edges called out by the production close path: platform-close -failure leaves the whole aggregate unchanged; publication failure retains target/force and an -operation-keyed close receipt; retrying the same close does not dispatch it twice; changing the -close identity does; retargeting without live force drops the old target's authorization; and -terminal committed or aborted state is explicit. The aggregate probe also covers ref expiry before -successful and failed mutations, repair/recording disjointness, digest validation without session -mutation, and single active publication, then prints compact JSON evidence. diff --git a/scripts/prototypes/daemon-boundaries/program-boundary.ts b/scripts/prototypes/daemon-boundaries/program-boundary.ts deleted file mode 100644 index e0a8cc0bd5..0000000000 --- a/scripts/prototypes/daemon-boundaries/program-boundary.ts +++ /dev/null @@ -1,234 +0,0 @@ -import assert from 'node:assert/strict'; - -type ProgramFormat = 'ad' | 'maestro'; -type RunMode = 'pass' | 'fail'; - -type ProgramManifest = { - format: ProgramFormat; - sourcePath: string; - caseNames: readonly string[]; -}; - -type ProgramResult = - | { outcome: 'passed'; artifacts: readonly string[] } - | { outcome: 'failed'; code: string; artifacts: readonly string[] } - | { outcome: 'diverged'; step: number; artifacts: readonly string[] }; - -interface TestProgramExecutor { - inspect(sourcePath: string): Promise; - execute(sourcePath: string, mode: RunMode, signal: AbortSignal): Promise; -} - -type AdAction = - | { kind: 'open'; app: string } - | { kind: 'click'; selector: string } - | { kind: 'verify'; selector: string }; - -type PreparedAdReplay = { - readonly digest: string; - readonly actions: readonly AdAction[]; -}; - -interface AdReplayRuntime { - executeStep( - action: AdAction, - signal: AbortSignal, - ): Promise<{ outcome: 'completed' } | { outcome: 'diverged'; artifacts: readonly string[] }>; -} - -class AdReplayEngine { - async prepare(sourcePath: string): Promise { - return { - digest: `ad:${sourcePath}`, - actions: [ - { kind: 'open', app: 'demo' }, - { kind: 'click', selector: 'text="Continue"' }, - { kind: 'verify', selector: 'text="Welcome"' }, - ], - }; - } - - async execute( - prepared: PreparedAdReplay, - runtime: AdReplayRuntime, - signal: AbortSignal, - ): Promise { - for (const [index, action] of prepared.actions.entries()) { - const result = await runtime.executeStep(action, signal); - if (result.outcome === 'diverged') { - return { outcome: 'diverged', step: index + 1, artifacts: result.artifacts }; - } - } - return { outcome: 'passed', artifacts: ['ad-trace.ndjson'] }; - } -} - -type MaestroCommand = - | { kind: 'launchApp'; appId: string } - | { kind: 'tapOn'; text: string } - | { kind: 'assertVisible'; text: string }; - -type PreparedMaestroFlow = { - readonly digest: string; - readonly commands: readonly MaestroCommand[]; -}; - -interface MaestroRuntimePort { - execute(command: MaestroCommand, signal: AbortSignal): Promise; - observe(text: string, signal: AbortSignal): Promise; -} - -class MaestroEngine { - async prepare(sourcePath: string): Promise { - return { - digest: `maestro:${sourcePath}`, - commands: [ - { kind: 'launchApp', appId: 'demo' }, - { kind: 'tapOn', text: 'Continue' }, - { kind: 'assertVisible', text: 'Welcome' }, - ], - }; - } - - async execute( - prepared: PreparedMaestroFlow, - runtime: MaestroRuntimePort, - signal: AbortSignal, - ): Promise { - for (const command of prepared.commands) { - const failure = await executeMaestroCommand(command, runtime, signal); - if (failure) return failure; - } - return { outcome: 'passed', artifacts: ['maestro-trace.ndjson'] }; - } -} - -async function executeMaestroCommand( - command: MaestroCommand, - runtime: MaestroRuntimePort, - signal: AbortSignal, -): Promise { - if (command.kind === 'assertVisible') { - const visible = await runtime.observe(command.text, signal); - if (!visible) { - return { - outcome: 'failed', - code: 'MAESTRO_ASSERTION_FAILED', - artifacts: ['maestro-failure.png'], - }; - } - } - await runtime.execute(command, signal); -} - -function createExecutors(trace: { - ad: string[]; - maestro: string[]; -}): ReadonlyMap { - const adEngine = new AdReplayEngine(); - const maestroEngine = new MaestroEngine(); - return new Map([ - [ - 'ad', - { - async inspect(sourcePath) { - return { format: 'ad', sourcePath, caseNames: ['native checkout'] }; - }, - async execute(sourcePath, mode, signal) { - const prepared = await adEngine.prepare(sourcePath); - return await adEngine.execute( - prepared, - { - async executeStep(action) { - trace.ad.push(`execute:${action.kind}`); - return mode === 'fail' && action.kind === 'click' - ? { outcome: 'diverged', artifacts: ['ad-divergence.json'] } - : { outcome: 'completed' }; - }, - }, - signal, - ); - }, - }, - ], - [ - 'maestro', - { - async inspect(sourcePath) { - return { format: 'maestro', sourcePath, caseNames: ['maestro checkout'] }; - }, - async execute(sourcePath, mode, signal) { - const prepared = await maestroEngine.prepare(sourcePath); - return await maestroEngine.execute( - prepared, - { - async execute(command) { - trace.maestro.push(`execute:${command.kind}`); - }, - async observe(text) { - trace.maestro.push(`observe:${text}`); - return mode === 'pass'; - }, - }, - signal, - ); - }, - }, - ], - ] satisfies ReadonlyArray); -} - -async function runCase( - executors: ReadonlyMap, - format: ProgramFormat, - mode: RunMode, -): Promise<{ manifest: ProgramManifest; result: ProgramResult }> { - const executor = executors.get(format); - assert.ok(executor); - const sourcePath = format === 'ad' ? 'checkout.ad' : 'checkout.yaml'; - const manifest = await executor.inspect(sourcePath); - const result = await executor.execute(sourcePath, mode, new AbortController().signal); - return { manifest, result }; -} - -const trace = { ad: [] as string[], maestro: [] as string[] }; -const executors = createExecutors(trace); -const adPassed = await runCase(executors, 'ad', 'pass'); -const adDiverged = await runCase(executors, 'ad', 'fail'); -const maestroPassed = await runCase(executors, 'maestro', 'pass'); -const maestroFailed = await runCase(executors, 'maestro', 'fail'); - -assert.equal(adPassed.result.outcome, 'passed'); -assert.equal(adDiverged.result.outcome, 'diverged'); -assert.equal(maestroPassed.result.outcome, 'passed'); -assert.equal(maestroFailed.result.outcome, 'failed'); -assert.deepEqual(trace.ad, [ - 'execute:open', - 'execute:click', - 'execute:verify', - 'execute:open', - 'execute:click', -]); -assert.deepEqual(trace.maestro, [ - 'execute:launchApp', - 'execute:tapOn', - 'observe:Welcome', - 'execute:assertVisible', - 'execute:launchApp', - 'execute:tapOn', - 'observe:Welcome', -]); - -process.stdout.write( - `${JSON.stringify( - { - question: 'Can replay-test use one small interface without a shared replay VM?', - result: 'yes', - schedulerSurface: ['inspect', 'execute'], - nativeEngineSurface: ['prepare', 'execute'], - independentRuntimeEvidence: trace, - }, - null, - 2, - )}\n`, -); diff --git a/scripts/prototypes/daemon-boundaries/session-boundary.ts b/scripts/prototypes/daemon-boundaries/session-boundary.ts deleted file mode 100644 index ee5cf91b40..0000000000 --- a/scripts/prototypes/daemon-boundaries/session-boundary.ts +++ /dev/null @@ -1,156 +0,0 @@ -import assert from 'node:assert/strict'; -import { runRepairPublicationTransactionProbe } from './session-publication-transaction.ts'; -import { SessionScriptAggregate, type SessionScriptState } from './session-script-aggregate.ts'; - -type RefFrame = { generation: number; status: 'active' | 'expired' }; - -type SessionSnapshot = { - revision: number; - refFrame?: RefFrame; - script: SessionScriptState; - events: readonly string[]; -}; - -class LockedSessionState { - private revision = 0; - private nextGeneration = 1; - private refFrame: RefFrame | undefined; - private readonly scripts = new SessionScriptAggregate(); - private readonly events: string[] = []; - - snapshot(): SessionSnapshot { - return structuredClone({ - revision: this.revision, - refFrame: this.refFrame, - script: this.scripts.view(), - events: this.events, - }); - } - - activateRefFrame(): void { - this.refFrame = { generation: this.nextGeneration++, status: 'active' }; - this.commit(`ref-frame:activated:${this.refFrame.generation}`); - } - - attemptMutation(operation: string, succeeds: boolean): void { - if (this.refFrame?.status === 'active') { - this.refFrame.status = 'expired'; - this.events.push(`ref-frame:expired-before:${operation}`); - } - this.events.push(`mutation:${operation}:${succeeds ? 'succeeded' : 'failed'}`); - this.commit('mutation-attempt'); - } - - armOrdinaryRecording(): void { - this.scripts.recording.armOrdinary({ - path: 'session.ad', - source: 'explicit', - force: false, - }); - this.commit('ordinary-recording:armed'); - } - - recordAction(action: string): void { - this.scripts.recording.record(action); - this.commit(`action:${action}`); - } - - beginRepair(sourcePath: string): void { - this.scripts.replay.arm({ - sourcePath, - boundary: 0, - target: { - path: 'checkout.healed.ad', - source: 'healed-sibling', - force: false, - }, - }); - this.commit('repair:armed'); - } - - publishActive(): void { - this.scripts.publication.publishActive(); - this.commit('ordinary-recording:published'); - } - - private commit(event: string): void { - this.revision += 1; - this.events.push(event); - } -} - -function validateResumeClaim( - claim: { from: number; planDigest: string }, - prepared: { planDigest: string; stepCount: number }, -): void { - if (claim.planDigest !== prepared.planDigest) { - throw new Error('stale client-supplied plan digest'); - } - if (!isValidStepOrdinal(claim.from, prepared.stepCount)) { - throw new Error('resume ordinal is invalid'); - } -} - -function isValidStepOrdinal(from: number, stepCount: number): boolean { - return Number.isInteger(from) && from >= 1 && from <= stepCount; -} - -const mutationSession = new LockedSessionState(); -mutationSession.activateRefFrame(); -mutationSession.attemptMutation('failed-click', false); -assert.equal(mutationSession.snapshot().refFrame?.status, 'expired'); - -const successfulMutationSession = new LockedSessionState(); -successfulMutationSession.activateRefFrame(); -successfulMutationSession.attemptMutation('successful-click', true); -assert.equal(successfulMutationSession.snapshot().refFrame?.status, 'expired'); - -const repairPublicationEvidence = await runRepairPublicationTransactionProbe(); - -const publicationSession = new LockedSessionState(); -publicationSession.armOrdinaryRecording(); -publicationSession.recordAction('fill text=${PASSWORD}'); -publicationSession.publishActive(); -assert.deepEqual(publicationSession.snapshot().script, { - kind: 'ordinary', - target: { path: 'session.ad', source: 'explicit', force: false }, - status: { kind: 'published', path: 'session.ad' }, - actions: ['fill text=${PASSWORD}'], -}); -assert.throws(() => publicationSession.publishActive(), /already published/); - -const disjointSession = new LockedSessionState(); -disjointSession.beginRepair('checkout.ad'); -assert.throws(() => disjointSession.publishActive(), /unavailable during repair/); -const stateBeforeStaleClaim = disjointSession.snapshot(); -assert.throws( - () => - validateResumeClaim( - { from: 2, planDigest: 'stale-plan' }, - { planDigest: 'fresh-plan', stepCount: 3 }, - ), - /stale client-supplied plan digest/, -); -assert.deepEqual(disjointSession.snapshot(), stateBeforeStaleClaim); - -process.stdout.write( - `${JSON.stringify( - { - question: 'Can replay avoid a mutable session interface entirely?', - result: 'yes', - evidence: { - failedMutationExpiredRefs: mutationSession.snapshot().refFrame?.status === 'expired', - successfulMutationExpiredRefs: - successfulMutationSession.snapshot().refFrame?.status === 'expired', - ...repairPublicationEvidence, - clientDigestValidationLeftSessionUntouched: true, - activePublicationIsRepairDisjoint: true, - parameterizedArtifactPublished: - publicationSession.snapshot().script.kind === 'ordinary' && - publicationSession.snapshot().script.actions[0] === 'fill text=${PASSWORD}', - }, - }, - null, - 2, - )}\n`, -); diff --git a/scripts/prototypes/daemon-boundaries/session-publication-transaction.ts b/scripts/prototypes/daemon-boundaries/session-publication-transaction.ts deleted file mode 100644 index 92ef9bf95b..0000000000 --- a/scripts/prototypes/daemon-boundaries/session-publication-transaction.ts +++ /dev/null @@ -1,183 +0,0 @@ -import assert from 'node:assert/strict'; -import { - SessionScriptAggregate, - type ScriptPublicationTarget, - type SessionScriptState, -} from './session-script-aggregate.ts'; - -const OPERATION_KEY = 'close:["com.example.app"]'; - -type CloseCounters = { - platform: number; - publication: number; -}; - -const healedTarget = (force: boolean): ScriptPublicationTarget => ({ - path: 'checkout.healed.ad', - source: 'healed-sibling', - force, -}); - -function armRepair(force = false): SessionScriptAggregate { - const scripts = new SessionScriptAggregate(); - scripts.replay.arm({ - sourcePath: 'checkout.ad', - boundary: 0, - target: healedTarget(force), - }); - return scripts; -} - -function completeCorrectedRepair(): SessionScriptAggregate { - const scripts = armRepair(true); - scripts.replay.stampCorrectiveResume(3); - assert.throws(() => scripts.replay.admitCorrectiveResume(3), /no corrective action/); - scripts.recording.record('click text="Continue anyway"'); - scripts.replay.admitCorrectiveResume(3); - scripts.replay.complete(); - return scripts; -} - -async function assertFailedCloseLeavesStateUnchanged( - scripts: SessionScriptAggregate, -): Promise { - const before = scripts.view(); - await assert.rejects( - () => - scripts.publication.finalizeExplicitClose({ - operationKey: OPERATION_KEY, - targetPath: 'must-not-stick.ad', - performPlatformClose: async () => { - throw new Error('device unavailable'); - }, - publish: () => assert.fail('publication must wait for platform close'), - }), - /device unavailable/, - ); - assert.deepEqual(scripts.view(), before); -} - -async function failPublicationAfterClose( - scripts: SessionScriptAggregate, - counters: CloseCounters, -): Promise { - await assert.rejects( - () => - scripts.publication.finalizeExplicitClose({ - operationKey: OPERATION_KEY, - performPlatformClose: async () => { - counters.platform += 1; - }, - publish: () => { - counters.publication += 1; - throw new Error('target already exists'); - }, - }), - /target already exists/, - ); - const failed = scripts.view(); - assert.equal(failed.kind, 'repair'); - assert.deepEqual(failed.status, { - kind: 'close-succeeded', - completion: 'complete', - receipt: { operationKey: OPERATION_KEY }, - }); - return failed; -} - -async function commitOnMatchingCloseRetry( - scripts: SessionScriptAggregate, - counters: CloseCounters, -): Promise { - await scripts.publication.finalizeExplicitClose({ - operationKey: OPERATION_KEY, - targetPath: 'checkout.promoted.ad', - performPlatformClose: async () => { - counters.platform += 1; - }, - publish: (target) => { - counters.publication += 1; - assert.equal(target.force, false); - }, - }); - const committed = scripts.view(); - assert.equal(committed.kind, 'repair'); - assert.equal(committed.status.kind, 'committed'); - assert.equal(counters.platform, 1); - assert.equal(counters.publication, 2); - - await scripts.publication.finalizeExplicitClose({ - operationKey: OPERATION_KEY, - performPlatformClose: async () => assert.fail('committed repair must not close again'), - publish: () => assert.fail('committed repair must not publish again'), - }); - return committed; -} - -async function proveMatchingCloseRetry(): Promise> { - const scripts = completeCorrectedRepair(); - await assertFailedCloseLeavesStateUnchanged(scripts); - const counters = { platform: 0, publication: 0 }; - const failed = await failPublicationAfterClose(scripts, counters); - assert.equal(failed.kind, 'repair'); - assert.equal(failed.sourcePath, 'checkout.ad'); - assert.equal(failed.watermark, undefined); - - const committed = await commitOnMatchingCloseRetry(scripts, counters); - assert.equal(committed.kind, 'repair'); - assert.equal(committed.target.force, false); - return { - oneAggregateSpansReplayAndPublication: true, - correctiveResumeRequiresRecordedAction: true, - platformCloseFailureLeftStateUnchanged: true, - failedPublishRetainedCloseReceipt: true, - sameCloseRetrySkippedPlatformDispatch: true, - retargetClearedPriorForce: true, - }; -} - -async function proveChangedCloseIdentityRedispatches(): Promise { - const scripts = armRepair(); - scripts.replay.complete(); - const counters = { platform: 0, publication: 0 }; - await failPublicationAfterClose(scripts, counters); - await scripts.publication.finalizeExplicitClose({ - operationKey: 'close:["com.example.other"]', - performPlatformClose: async () => { - counters.platform += 1; - }, - publish: () => {}, - }); - assert.equal(counters.platform, 2); - return true; -} - -async function proveCommittedAndAbortedAreExplicit(): Promise { - const incomplete = armRepair(); - assert.equal( - await incomplete.publication.finalizeExplicitClose({ - operationKey: 'close:[]', - performPlatformClose: async () => {}, - publish: () => assert.fail('incomplete repair must not publish'), - }), - 'aborted', - ); - const incompleteState = incomplete.view(); - assert.equal(incompleteState.kind, 'repair'); - assert.equal(incompleteState.status.kind, 'aborted'); - - const lifecycleAbort = armRepair(); - lifecycleAbort.publication.abortForLifecycle(); - const lifecycleState = lifecycleAbort.view(); - assert.equal(lifecycleState.kind, 'repair'); - assert.equal(lifecycleState.status.kind, 'aborted'); - return true; -} - -export async function runRepairPublicationTransactionProbe(): Promise> { - return { - ...(await proveMatchingCloseRetry()), - differentCloseIdentityRedispatched: await proveChangedCloseIdentityRedispatches(), - committedAndAbortedAreExplicit: await proveCommittedAndAbortedAreExplicit(), - }; -} diff --git a/scripts/prototypes/daemon-boundaries/session-script-aggregate.ts b/scripts/prototypes/daemon-boundaries/session-script-aggregate.ts deleted file mode 100644 index 74a7c2f4e7..0000000000 --- a/scripts/prototypes/daemon-boundaries/session-script-aggregate.ts +++ /dev/null @@ -1,265 +0,0 @@ -import assert from 'node:assert/strict'; - -export type ScriptPublicationTarget = Readonly<{ - path: string; - source: 'generated' | 'explicit' | 'healed-sibling'; - force: boolean; -}>; - -type CorrectiveResumeWatermark = { - expectedFrom: number; - actionsCountAtDivergence: number; -}; - -type RepairPlatformCloseReceipt = Readonly<{ - operationKey: string; -}>; - -type OrdinaryPublicationStatus = - | { kind: 'armed' } - | { kind: 'published'; path: string } - | { kind: 'aborted'; reason: 'second-open' }; - -type RepairPublicationStatus = - | { kind: 'armed' } - | { kind: 'complete' } - | { - kind: 'close-succeeded'; - completion: 'armed' | 'complete'; - receipt: RepairPlatformCloseReceipt; - } - | { - kind: 'committed'; - path: string; - receipt: RepairPlatformCloseReceipt; - } - | { - kind: 'aborted'; - reason: 'explicit-close-incomplete' | 'lifecycle-incomplete'; - receipt?: RepairPlatformCloseReceipt; - }; - -export type SessionScriptState = - | { kind: 'inactive' } - | { - kind: 'ordinary'; - target: ScriptPublicationTarget; - status: OrdinaryPublicationStatus; - actions: string[]; - } - | { - kind: 'repair'; - sourcePath: string; - boundary: number; - target: ScriptPublicationTarget; - watermark?: CorrectiveResumeWatermark; - status: RepairPublicationStatus; - actions: string[]; - }; - -type RepairArmRequest = { - sourcePath: string; - boundary: number; - target: ScriptPublicationTarget; -}; - -type ExplicitCloseRequest = { - operationKey: string; - targetPath?: string; - force?: boolean; - performPlatformClose(): Promise; - publish(target: ScriptPublicationTarget): void; -}; - -export type ReplaySessionTransaction = Readonly<{ - arm(request: RepairArmRequest): void; - stampCorrectiveResume(expectedFrom: number): void; - admitCorrectiveResume(from: number): void; - complete(): void; -}>; - -export type SessionScriptPublication = Readonly<{ - publishActive(): void; - finalizeExplicitClose(request: ExplicitCloseRequest): Promise<'committed' | 'aborted'>; - abortForLifecycle(): void; -}>; - -export type SessionScriptRecording = Readonly<{ - armOrdinary(target: ScriptPublicationTarget): void; - record(action: string): void; -}>; - -function terminalOutcome(status: RepairPublicationStatus): 'committed' | undefined { - if (status.kind === 'committed') return 'committed'; - assert.notEqual(status.kind, 'aborted'); - return undefined; -} - -function repairCompletion(status: RepairPublicationStatus): 'armed' | 'complete' { - if (status.kind === 'close-succeeded') return status.completion; - assert.ok(status.kind === 'armed' || status.kind === 'complete'); - return status.kind; -} - -function closeReceipt(status: RepairPublicationStatus): RepairPlatformCloseReceipt | undefined { - return status.kind === 'close-succeeded' ? status.receipt : undefined; -} - -async function settlePlatformClose( - priorReceipt: RepairPlatformCloseReceipt | undefined, - request: ExplicitCloseRequest, -): Promise { - if (priorReceipt?.operationKey === request.operationKey) return priorReceipt; - await request.performPlatformClose(); - return { operationKey: request.operationKey }; -} - -function retarget( - current: ScriptPublicationTarget, - requestedPath: string | undefined, - liveForce: boolean | undefined, -): ScriptPublicationTarget { - if (!requestedPath || requestedPath === current.path) { - return { ...current, force: current.force || liveForce === true }; - } - return { - path: requestedPath, - source: 'explicit', - force: liveForce === true, - }; -} - -export class SessionScriptAggregate { - private state: SessionScriptState = { kind: 'inactive' }; - - readonly replay: ReplaySessionTransaction = { - arm: (request) => this.armRepair(request), - stampCorrectiveResume: (expectedFrom) => this.stampCorrectiveResume(expectedFrom), - admitCorrectiveResume: (from) => this.admitCorrectiveResume(from), - complete: () => this.completeRepair(), - }; - - readonly publication: SessionScriptPublication = { - publishActive: () => this.publishActive(), - finalizeExplicitClose: (request) => this.finalizeExplicitClose(request), - abortForLifecycle: () => this.abortForLifecycle(), - }; - - readonly recording: SessionScriptRecording = { - armOrdinary: (target) => this.armOrdinary(target), - record: (action) => this.recordAction(action), - }; - - view(): SessionScriptState { - return structuredClone(this.state); - } - - private armOrdinary(target: ScriptPublicationTarget): void { - assert.notEqual(this.state.kind, 'repair', 'ordinary recording is unavailable during repair'); - assert.equal(this.state.kind, 'inactive', 'ordinary recording is already active'); - this.state = { kind: 'ordinary', target, status: { kind: 'armed' }, actions: [] }; - } - - private recordAction(action: string): void { - assert.notEqual( - this.state.kind, - 'inactive', - 'ordinary recording or repair must be armed first', - ); - assert.equal(this.state.status.kind, 'armed', 'script transaction no longer accepts actions'); - this.state.actions.push(action); - } - - private armRepair(request: RepairArmRequest): void { - assert.notEqual(this.state.kind, 'repair', 'repair transaction already active'); - assert.equal(this.state.kind, 'inactive', 'repair is disjoint from ordinary recording'); - this.state = { - kind: 'repair', - sourcePath: request.sourcePath, - boundary: request.boundary, - target: request.target, - status: { kind: 'armed' }, - actions: [], - }; - } - - private stampCorrectiveResume(expectedFrom: number): void { - const repair = this.requireRepair(); - assert.equal(repair.status.kind, 'armed', 'repair no longer accepts a resume watermark'); - repair.watermark = { - expectedFrom, - actionsCountAtDivergence: repair.actions.length, - }; - } - - private admitCorrectiveResume(from: number): void { - const repair = this.requireRepair(); - const watermark = repair.watermark; - assert.ok(watermark, 'no corrective resume is pending'); - assert.equal(watermark.expectedFrom, from, 'resume ordinal does not match the watermark'); - assert.ok( - repair.actions.length > watermark.actionsCountAtDivergence, - 'no corrective action was recorded', - ); - repair.watermark = undefined; - } - - private completeRepair(): void { - const repair = this.requireRepair(); - assert.equal(repair.watermark, undefined, 'corrective resume has not been admitted'); - assert.equal(repair.status.kind, 'armed', 'repair cannot complete from this phase'); - repair.status = { kind: 'complete' }; - } - - private publishActive(): void { - assert.notEqual(this.state.kind, 'repair', 'active publication is unavailable during repair'); - assert.equal(this.state.kind, 'ordinary', 'ordinary recording has no actions'); - assert.notEqual(this.state.status.kind, 'published', 'artifact was already published'); - assert.equal(this.state.status.kind, 'armed', 'ordinary recording cannot be published'); - assert.ok(this.state.actions.length > 0, 'ordinary recording has no actions'); - assert.ok( - !this.state.actions.some((action) => action.includes('@unresolved')), - 'artifact contains an unresolved session-local ref', - ); - serializeAdArtifact(this.state.actions); - this.state.status = { kind: 'published', path: this.state.target.path }; - } - - private async finalizeExplicitClose( - request: ExplicitCloseRequest, - ): Promise<'committed' | 'aborted'> { - const repair = this.requireRepair(); - const terminal = terminalOutcome(repair.status); - if (terminal) return terminal; - - const target = retarget(repair.target, request.targetPath, request.force); - const completion = repairCompletion(repair.status); - const receipt = await settlePlatformClose(closeReceipt(repair.status), request); - - repair.target = target; - repair.status = { kind: 'close-succeeded', completion, receipt }; - if (completion === 'armed') { - repair.status = { kind: 'aborted', reason: 'explicit-close-incomplete', receipt }; - return 'aborted'; - } - - request.publish(target); - repair.status = { kind: 'committed', path: target.path, receipt }; - return 'committed'; - } - - private abortForLifecycle(): void { - const repair = this.requireRepair(); - assert.equal(repair.status.kind, 'armed'); - repair.status = { kind: 'aborted', reason: 'lifecycle-incomplete' }; - } - - private requireRepair(): Extract { - assert.equal(this.state.kind, 'repair', 'repair transaction is not active'); - return this.state; - } -} - -function serializeAdArtifact(actions: readonly string[]): string { - return `${actions.join('\n')}\n`; -}