diff --git a/docs/daemon-modularity-proposal.md b/docs/daemon-modularity-proposal.md new file mode 100644 index 000000000..9ea504073 --- /dev/null +++ b/docs/daemon-modularity-proposal.md @@ -0,0 +1,989 @@ +# 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 a replay observation and atomically update the session observation plus + * the exact partial ref frame exposed by the outcome. + */ + 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` deliberately combines capture, stored observation update, and exact partial-frame +activation. Splitting those operations would expose an illegal intermediate state under ADR 0014. + +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` atomically couples capture and partial-frame + activation. +- 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/dependency-graph-findings.md b/docs/dependency-graph-findings.md index 07896b743..c2409baae 100644 --- a/docs/dependency-graph-findings.md +++ b/docs/dependency-graph-findings.md @@ -136,6 +136,15 @@ numbers they explain. ## 0b. The biggest structural finding is not an inversion +> **Current status at `e545544dfa85`:** R9 remains 102, but membership is now commands 33, +> daemon-server 30, platforms 19, core 12, root composition 5, contracts 2, and client 1. None of +> the native replay, replay-test, or Maestro engine files is in this component. A graph +> counterfactual removing the concrete Apple `perf-xctrace.ts` type edge from `daemon/types.ts` +> lowers it to 91 (and daemon membership from 30 to 21). Removing only the Android `perf.ts` edge +> leaves both counts unchanged at 102/30; removing both produces the same 91/21 as the Apple cut +> alone. The Apple edge is load-bearing, while removing the Android edge is ownership hygiene. +> Engine extraction alone leaves R9 at 102. + Cycle size by edge kind, measured over the whole production graph: | edges considered | largest strongly-connected component | @@ -146,12 +155,11 @@ Cycle size by edge kind, measured over the whole production graph: | all kinds | 213 files | At runtime the module graph is a clean DAG. The 102-file cluster is purely type-level: you cannot -read the types of any one of those files without transitively reaching all 102. (`main` carries 107; -the boundary moves in this branch bring it to 102.) That is not a correctness -problem — types are erased — but it is a comprehension one, and it is the single largest obstacle to -reading a subsystem in isolation. It spans `commands` (33), `daemon` (21), `platforms` (13), `core` -(12), hubs at `runtime-contract.ts` (25 in-cluster dependents), `commands/runtime-types.ts` (21), -`backend.ts` (15), `commands/runtime-common.ts` (12). +read the types of any one of those files without transitively reaching all 102. (`main` carried 107; +the current boundary moves bring it to 102.) That is not a correctness problem — types are erased — +but it is a comprehension one, and it is the single largest obstacle to reading a subsystem in +isolation. At the current measured commit it spans `commands` (33), `daemon-server` (30), +`platforms` (19), `core` (12), root composition (5), `contracts` (2), and `client` (1). Now ratcheted for growth by **R9** (`TYPE_CYCLE_BASELINE` in `check.ts`), so it cannot get worse while nobody is looking — a type-only import that closes a new loop fails the gate, verified by @@ -237,6 +245,14 @@ becomes generic over the request type, or the request shape itself moves down. ## 2. `daemon/types.ts` is a second contracts module at rank 4 +> **Status after #1435:** the inventory below is historical. `SessionAction`, replay-suite results, +> `DaemonLockPolicy`, and the public daemon response/artifact/runtime-hint shapes now live below +> daemon. Four production files outside daemon still import `daemon/types.ts`; two are +> daemon-specific Maestro adapters scheduled to move back under daemon. `DaemonRequest` itself +> intentionally remains server-private because it carries admitted leases, callbacks, replay +> guards, and narrowed flags. Remove the remaining external imports through neutral caller-specific +> contracts; do not move `DaemonRequest` wholesale. + 554 lines, **174 dependents, 173 of them type-only**, and 10 of its 15 exported types are imported from outside `daemon/`: `DaemonRequest` (8), `SessionAction` (7), `ReplaySuiteResult` (6), `ReplaySuiteTestResult` (4), `DaemonResponse` (2), plus `SessionRuntimeHints`, `DaemonInvokeFn`, @@ -389,8 +405,9 @@ Still duplicated across zones, each needing the same treatment: `fill requires t ## Suggested order from here -1. **Move the 10 outward-facing `daemon/types.ts` types into `contracts/`** (§2). Mechanical, and - it clears most of §1's second cluster. +1. ~~**Move the 10 outward-facing `daemon/types.ts` types into `contracts/`** (§2).~~ Mostly + completed by #1435. Eliminate the four remaining external production importers with + caller-specific public contracts or daemon-owned adapters; keep `DaemonRequest` private. 2. ~~**Split `client/client-types.ts`** (§1).~~ Done — 42 → 18 total inversions. The follow-up is the upstream moves that unblock the last 5 (§1): `ScrollInputDirection` and the navigation-projection types out of `commands/`, Metro result payloads out of `metro/`. diff --git a/docs/module-interface-principles.md b/docs/module-interface-principles.md new file mode 100644 index 000000000..64d625d11 --- /dev/null +++ b/docs/module-interface-principles.md @@ -0,0 +1,165 @@ +# 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 0da425b7d..137417389 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,8 @@ "maestro:conformance": "node --experimental-strip-types --test scripts/maestro-conformance/verify.test.ts scripts/maestro-conformance/differential/run.test.ts scripts/maestro-conformance/differential/invariants.test.ts", "maestro:conformance:regenerate": "node scripts/maestro-conformance/regenerate.mjs", "maestro:conformance:differential": "node --experimental-strip-types scripts/maestro-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", "size:markdown": "node scripts/size-report.mjs --json .tmp/size-report.json --markdown .tmp/size-report.md", "perf": "node --experimental-strip-types scripts/perf/run.ts", diff --git a/scripts/prototypes/daemon-boundaries/README.md b/scripts/prototypes/daemon-boundaries/README.md new file mode 100644 index 000000000..8a22f267a --- /dev/null +++ b/scripts/prototypes/daemon-boundaries/README.md @@ -0,0 +1,47 @@ +# 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 new file mode 100644 index 000000000..e0a8cc0bd --- /dev/null +++ b/scripts/prototypes/daemon-boundaries/program-boundary.ts @@ -0,0 +1,234 @@ +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 new file mode 100644 index 000000000..ee5cf91b4 --- /dev/null +++ b/scripts/prototypes/daemon-boundaries/session-boundary.ts @@ -0,0 +1,156 @@ +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 new file mode 100644 index 000000000..92ef9bf95 --- /dev/null +++ b/scripts/prototypes/daemon-boundaries/session-publication-transaction.ts @@ -0,0 +1,183 @@ +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 new file mode 100644 index 000000000..74a7c2f4e --- /dev/null +++ b/scripts/prototypes/daemon-boundaries/session-script-aggregate.ts @@ -0,0 +1,265 @@ +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`; +}