From 38a4802291c6a74e49d701616f852dfdbb16af0b Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:52:09 +0000 Subject: [PATCH 1/3] feat(agent): add remote transport and history foundations --- docs/agent-mode-remote-paging-spike.md | 326 + frontend/src-tauri/Cargo.lock | 1388 ++- frontend/src-tauri/Cargo.toml | 16 +- frontend/src-tauri/src/agent.rs | 778 +- frontend/src-tauri/src/agent/system_prompt.rs | 9 - frontend/src-tauri/src/agent_event_journal.rs | 8283 ++++++++++++++ .../src-tauri/src/agent_live_authority.rs | 287 + frontend/src-tauri/src/agent_live_binding.rs | 2212 ++++ .../src-tauri/src/agent_live_coordinator.rs | 6355 +++++++++++ frontend/src-tauri/src/agent_live_host.rs | 2605 +++++ .../src-tauri/src/agent_live_projection.rs | 650 ++ frontend/src-tauri/src/agent_live_tauri.rs | 4158 +++++++ .../src-tauri/src/agent_remote_portable.rs | 5552 +++++++++ .../src/agent_remote_portable_tauri.rs | 2061 ++++ frontend/src-tauri/src/agent_tauri.rs | 48 +- frontend/src-tauri/src/durable_host_epoch.rs | 656 ++ frontend/src-tauri/src/lib.rs | 47 +- frontend/src-tauri/src/remote_agent_rpc.rs | 6248 +++++++++++ frontend/src-tauri/src/remote_protocol.rs | 4253 +++++++ frontend/src-tauri/src/remote_transport.rs | 9965 +++++++++++++++++ frontend/src-tauri/src/secure_storage.rs | 890 ++ 21 files changed, 56636 insertions(+), 151 deletions(-) create mode 100644 docs/agent-mode-remote-paging-spike.md create mode 100644 frontend/src-tauri/src/agent_event_journal.rs create mode 100644 frontend/src-tauri/src/agent_live_authority.rs create mode 100644 frontend/src-tauri/src/agent_live_binding.rs create mode 100644 frontend/src-tauri/src/agent_live_coordinator.rs create mode 100644 frontend/src-tauri/src/agent_live_host.rs create mode 100644 frontend/src-tauri/src/agent_live_projection.rs create mode 100644 frontend/src-tauri/src/agent_live_tauri.rs create mode 100644 frontend/src-tauri/src/agent_remote_portable.rs create mode 100644 frontend/src-tauri/src/agent_remote_portable_tauri.rs create mode 100644 frontend/src-tauri/src/durable_host_epoch.rs create mode 100644 frontend/src-tauri/src/remote_agent_rpc.rs create mode 100644 frontend/src-tauri/src/remote_protocol.rs create mode 100644 frontend/src-tauri/src/remote_transport.rs create mode 100644 frontend/src-tauri/src/secure_storage.rs diff --git a/docs/agent-mode-remote-paging-spike.md b/docs/agent-mode-remote-paging-spike.md new file mode 100644 index 000000000..fb02dd89d --- /dev/null +++ b/docs/agent-mode-remote-paging-spike.md @@ -0,0 +1,326 @@ +# Agent Mode v1 paging and synchronization + +Status: selected v1 design. Implementation and validation are in progress. + +## Decision + +Agent Mode v1 uses count-bounded, cursor-based pages of Goose's native +persisted `Message` records. + +- One page record is one Goose message row: its storage key, role, optional + logical message ID, creation time, projection metadata, and complete + content-block array. +- A record can contain text, thinking, tool requests, tool responses, + permissions, notices, and errors. Those blocks are not split into synthetic + storage rows for paging. +- The default page size is 25 records and the v1 maximum is 50 records. +- Pages walk from the newest history toward older history. The frontend keeps + the accumulated records in chronological order. +- Session summaries are paged independently with the same count-first model. + +This deliberately copies Maple Chat's proven scrollback behavior without +copying its storage schema. Chat stores several kinds of flat conversation +items; Goose stores a richer role message whose content array can contain +several presentation blocks. Both are legitimate page units for their own +authoritative stores. + +There is no complete-turn page rule, no client-provided byte budget, and no +Maple-owned duplicate history journal. Transport frame limits and per-record +projection limits remain mandatory safety boundaries, but they are not the +product pagination model. + +## Authority and shared path + +Goose remains the sole durable authority for committed Agent history. The host +Maple installation remains the sole execution and storage authority. + +```text +Goose SQLite message pager + | + v +Maple Agent history service + | | + v v +local Tauri adapter authenticated Iroh adapter + | | + +---------+----------+ + v + shared Agent UI +``` + +The embedded and remote adapters must call the same host-side history service. +The remote implementation must not load the whole conversation and slice it, +and Maple must not query Goose's private SQLite schema directly. + +## Goose message page + +Goose should expose a public storage-native API equivalent to: + +```rust +pub struct ConversationMessagePageQuery { + pub before: Option, + pub page_size: usize, +} + +pub struct ConversationMessagePage { + /// Newest first, matching the storage query and Maple Chat's API. + pub records: Vec, + pub next_cursor: Option, + pub history_revision: u64, +} + +pub struct ConversationMessageRecord { + /// The private SQLite row tie-breaker used to derive an opaque Maple record + /// identity. This is distinct from Message::id, which is a logical ID. + pub row_id: i64, + pub message: Message, +} + +impl SessionManager { + pub async fn get_conversation_message_page( + &self, + session_id: &str, + query: ConversationMessagePageQuery, + ) -> Result; +} +``` + +The storage query is a reverse keyset read over the total order +`(created_timestamp, row_id)`: + +```sql +SELECT ... +FROM messages +WHERE session_id = ? + AND ( + created_timestamp < ? + OR (created_timestamp = ? AND id < ?) + ) +ORDER BY created_timestamp DESC, id DESC +LIMIT page_size + 1 +``` + +The implementation returns the selected rows newest first and deserializes no +more than `page_size + 1` rows. The client reverses each page before prepending +it to its chronological in-memory window, matching Maple Chat. + +The cursor is typed inside Goose and opaque outside the host. It binds at least: + +- session identity; +- history revision; +- `created_timestamp`; and +- the row-ID tie breaker. + +Appending a new message does not invalidate an older-history cursor. +Replacement, truncation, deletion, or in-place mutation advances the history +revision in the same SQLite transaction and makes an old cursor fail with a +typed cursor-invalidated result. `replace_conversation` must never silently +reuse a pre-rewrite cursor just because replacement rows happen to have the +same message IDs. + +The messages table needs a composite index on +`(session_id, created_timestamp DESC, id DESC)`. + +## Maple wire projection + +Maple must not expose arbitrary provider metadata, raw image bytes, credentials, +or unbounded tool results merely because they exist inside a Goose message. +The host projects each selected Goose row into one safe Maple record: + +```rust +pub struct AgentHistoryRecord { + /// Stable for this history revision and storage row; not a claim that the + /// optional Goose logical message ID is database-unique. + pub record_id: String, + pub role: AgentHistoryRole, + pub created_ms: u64, + /// Complete safe presentation projection for this one Goose record. + pub items: Vec, +} + +pub struct AgentHistoryPage { + pub records: Vec, + pub next_cursor: Option, + pub history_revision: u64, + /// Present only with an authoritative absolute live suffix and matching + /// journal cut established by the attach coordinator. + pub live_items: Option>, + pub through_event_cursor: Option, +} +``` + +One `AgentHistoryRecord` consumes one requested record even when it contains +several timeline items. The response validator therefore bounds `records.len()` +against the request limit rather than bounding the number of projected cards. + +The cursor is not an authorization capability. Goose binds it to the session +and history revision; Maple resolves the account-scoped store and execution +target from the authenticated handle and request envelope before passing it to +Goose. Cursor fields can only narrow the explicitly authorized session query +and can never override that account, target, operation, or session scope. + +The Iroh operation uses the bulk lane. Every request and response remains bound +to the authenticated endpoint, exact pairing incarnation, execution target, +and current connection stamp. The host revalidates that admission immediately +before storage access and before disclosure. + +The existing one-MiB frame ceiling remains. A single safe record that cannot +fit returns a typed `HistoryRecordTooLarge`; the host never fetches all history, +silently truncates a tool result, or loops with an unchanged cursor. + +## Page composition + +Arbitrary message boundaries are legal. + +- A tool request can be on an older page and its response on a newer page. + Their stable tool ID lets the accumulated projection enrich one card without + duplicating it. +- A page can begin in the middle of an assistant turn. Prepending older records + repairs turn grouping without changing the stable render identity of already + visible cards. +- A permission request and its resolving response can cross pages. Historical + correlation improves its displayed state, while the host's live pending- + permission registry remains the sole authority for whether an action is + currently answerable. +- Repeated reasoning stored across split provider messages must retain today's + safe content and stable identities. It may temporarily group differently at + an arbitrary page boundary and settle when older records are prepended. That + does not justify loading a complete turn, adding unbounded projection state + to the cursor, or splitting a native Goose message into new storage records. +- Agent-only content stays filtered at the host authority. + +Concatenating all pages must preserve every eligible safe content block in +storage order with stable record and tool identities. Cross-record card +enrichment should converge as older pages arrive. Byte-for-byte reproduction +of every whole-conversation coalescing heuristic is not a v1 requirement when +it would require a different storage schema or synthetic page units. + +## Live synchronization is a separate cursor + +History paging and live resume solve different problems and must not share one +cursor. + +Committed history uses the Goose history cursor. Live events use a host-owned, +account-and-execution-target-scoped journal epoch and one monotonically +increasing sequence across that stream. The sequence is deliberately not +per-session: events for tasks A and B can interleave without creating false +gaps. + +A plain history page does not claim a live checkpoint. A synchronized attach +uses the account-scoped event coordinator instead: + +1. The coordinator drains all earlier publishes, captures an absolute safe live + suffix for the selected session at journal cut C0, and registers a paused, + bounded subscriber at C0. +2. The host reads one bounded Goose head page outside the event actor. +3. It replays account events `(C0, C1]` to a second coordinator barrier C1. +4. The client installs `head -> absolute live suffix at C0 -> replay to C1`, + then resumes the already-registered subscriber. + +The page exposes `live_items` and `through_event_cursor` only as a pair for that +completed protocol. `Some([])` is an authoritative empty live suffix; both +fields absent means ordinary committed-history paging. A paused-buffer overflow, +journal gap, owner change, or failed revalidation returns `HeadReloadRequired` +without ever falling back to a whole-conversation snapshot. + +The host keeps a bounded durable event/outcome journal so a phone can resume +after ordinary backgrounding without loading history again: + +1. reconnect and present the last event cursor; +2. replay missed events in sequence; +3. continue the live subscription; and +4. leave previously loaded history pages untouched. + +If the cursor is from an old host epoch or older than retained replay, the host +returns `HeadReloadRequired`. The client reloads only the newest bounded message +page and resumes events from its new watermark. It never falls back to a whole- +conversation snapshot. + +Event sequences deduplicate replayed append deltas. Stable timeline item IDs +alone are insufficient because applying the same `merge: append` delta twice +would duplicate text. + +`HistoryReplaced` advances the Goose history revision, invalidates committed +pages for that session, and triggers one bounded head reload. It does not erase +or regress the current live suffix. + +## Frontend behavior copied from Maple Chat + +Agent Mode reuses the mature Chat scrollback mechanics: + +- a persistent top sentinel with a 100-pixel loading margin; +- explicit upward wheel, touch, scrollbar, or keyboard intent before loading; +- one page per gesture and at most one queued follow-up; +- no mount-time or momentum-driven page cascade; +- capture of the first visible semantic anchor before prepend; +- anchor-and-offset restoration with a scroll-height fallback; +- stable ID deduplication and cursor-progress checks; and +- projection/session leases so delayed A -> B -> A work cannot mutate the + wrong timeline. + +State is maintained per Agent session for loaded records, the next older cursor, +history revision, load generation, and live suffix. The journal cursor belongs +to the authenticated account-and-target subscription and is shared across its +session routes. It survives a same-owner connection-generation refresh and is +reset when the account or execution-target lineage changes. Inactive sessions +can therefore keep receiving bounded live updates without forcing the selected +session to reload. + +Session selection loads one newest page. Run completion and `HistoryReplaced` +reconcile one newest page. The product UI stops using unbounded `loadSession` +and `listSessions`; those may remain temporarily only as internal compatibility +methods while migration tests are being converted. + +## Required proof + +### Goose storage + +- 101 rows with the same timestamp page 25 at a time without gaps or duplicates. +- A multi-content message remains one indivisible record and consumes one slot. +- Insert-at-head between older-page requests does not invalidate or duplicate. +- Replace, truncate, delete, and in-place mutation atomically invalidate an old + cursor. +- Wrong-session and malformed cursors fail before returning data. +- Query-plan and instrumentation prove at most `limit + 1` rows are fetched and + the composite index is used. +- A 10,000-message session has bounded decoded rows and peak memory. + +### Maple projection and transport + +- Plain text, multiple blocks in one row, and hidden content. +- Tool request/response split exactly at a page boundary. +- Reasoning replay and usage boundaries split across pages without lost safe + content or synthetic storage records. +- Permission request/resolution and stopped notices split across pages. +- Concatenated paged projection preserves eligible safe content and canonical + storage order; cross-page tool and permission enrichment converges. +- Request limit, cursor, target, account, pairing incarnation, and connection + generation are all enforced. +- One oversized record fails explicitly without blocking control/cancel traffic. +- Embedded and Iroh adapters return the same serialized page for the same host + state. + +### Frontend and mobile resume + +- Four or more history pages prepend chronologically with no duplicate cards. +- A visible expanded tool card and keyboard focus survive prepend/regrouping. +- Live terminal or resolved state wins over an older paged snapshot. +- A replayed append event is applied once by event sequence. +- `HistoryReplaced` rejects a stale in-flight page and reloads only the head. +- Phone background/foreground resumes events without a history fetch while the + event cursor is retained. +- Replay overflow reloads one bounded head, never the whole conversation. +- Exact-app macOS testing proves local Agent scrollback is not degraded; iOS + simulator/device testing proves repeated background/resume behavior. + +## Deliberately deferred + +- Merging Chat and Agent presentation components. +- Complete-turn pagination. +- Client-selected byte budgets. +- A second durable copy of committed Agent history in Maple. +- Multi-controller leases or arbitrary observer fan-out beyond the explicitly + paired v1 controller. + +These are not prerequisites for native Goose-message paging and can be revisited +only if measured product behavior requires them. diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 035141663..6a70df398 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -325,6 +325,15 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -561,6 +570,17 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atk" version = "0.18.2" @@ -747,12 +767,29 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -806,9 +843,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -1074,7 +1111,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -1201,9 +1238,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1370,6 +1407,21 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1497,6 +1549,16 @@ dependencies = [ "url", ] +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1529,7 +1591,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1542,7 +1604,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1555,7 +1617,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -1617,6 +1679,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "croner" version = "3.0.1" @@ -1707,6 +1775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1757,6 +1826,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1766,8 +1844,26 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -1902,9 +1998,29 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 1.0.109", +] [[package]] name = "dbus" @@ -1961,7 +2077,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -2054,6 +2181,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.10.7" @@ -2095,7 +2228,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2104,7 +2237,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -2123,9 +2256,9 @@ dependencies = [ [[package]] name = "dlopen2" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ "dlopen2_derive", "libc", @@ -2240,12 +2373,39 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "serdect", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", ] [[package]] @@ -2263,15 +2423,15 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", "hkdf", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "subtle", @@ -2307,6 +2467,18 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2322,6 +2494,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "enum-assoc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872ece5a3d5aa63541e400219676c38e8f5474cf3366834c3f2d1fe23d29bc55" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2420,7 +2603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2582,6 +2765,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -2646,7 +2835,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -2806,6 +2995,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.1", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -3015,6 +3217,21 @@ dependencies = [ "x11", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -3169,7 +3386,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -3229,6 +3446,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -3243,7 +3472,7 @@ dependencies = [ [[package]] name = "goose" version = "1.45.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=064244e6bddf641876676f054a006b7da1da5182#064244e6bddf641876676f054a006b7da1da5182" +source = "git+https://github.com/OpenSecretCloud/goose.git?rev=7c989fd13213e1c31d77d2d4bf298660ec5fdca0#7c989fd13213e1c31d77d2d4bf298660ec5fdca0" dependencies = [ "agent-client-protocol", "agent-client-protocol-http", @@ -3264,6 +3493,7 @@ dependencies = [ "futures", "gethostname", "goose-acp-macros", + "goose-context-management", "goose-download-manager", "goose-providers", "goose-sdk-types", @@ -3284,7 +3514,6 @@ dependencies = [ "once_cell", "pastey 0.2.3", "process-wrap", - "pulldown-cmark", "rand 0.10.2", "rayon", "regex", @@ -3338,16 +3567,32 @@ dependencies = [ [[package]] name = "goose-acp-macros" version = "1.45.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=064244e6bddf641876676f054a006b7da1da5182#064244e6bddf641876676f054a006b7da1da5182" +source = "git+https://github.com/OpenSecretCloud/goose.git?rev=7c989fd13213e1c31d77d2d4bf298660ec5fdca0#7c989fd13213e1c31d77d2d4bf298660ec5fdca0" dependencies = [ "quote", "syn 2.0.108", ] +[[package]] +name = "goose-context-management" +version = "0.1.0-alpha.5" +source = "git+https://github.com/OpenSecretCloud/goose.git?rev=7c989fd13213e1c31d77d2d4bf298660ec5fdca0#7c989fd13213e1c31d77d2d4bf298660ec5fdca0" +dependencies = [ + "anyhow", + "async-trait", + "goose-providers", + "include_dir", + "minijinja", + "rmcp", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "goose-download-manager" version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose.git?rev=064244e6bddf641876676f054a006b7da1da5182#064244e6bddf641876676f054a006b7da1da5182" +source = "git+https://github.com/OpenSecretCloud/goose.git?rev=7c989fd13213e1c31d77d2d4bf298660ec5fdca0#7c989fd13213e1c31d77d2d4bf298660ec5fdca0" dependencies = [ "anyhow", "once_cell", @@ -3360,7 +3605,7 @@ dependencies = [ [[package]] name = "goose-provider-types" version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose.git?rev=064244e6bddf641876676f054a006b7da1da5182#064244e6bddf641876676f054a006b7da1da5182" +source = "git+https://github.com/OpenSecretCloud/goose.git?rev=7c989fd13213e1c31d77d2d4bf298660ec5fdca0#7c989fd13213e1c31d77d2d4bf298660ec5fdca0" dependencies = [ "anyhow", "async-stream", @@ -3386,7 +3631,7 @@ dependencies = [ [[package]] name = "goose-providers" version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose.git?rev=064244e6bddf641876676f054a006b7da1da5182#064244e6bddf641876676f054a006b7da1da5182" +source = "git+https://github.com/OpenSecretCloud/goose.git?rev=7c989fd13213e1c31d77d2d4bf298660ec5fdca0#7c989fd13213e1c31d77d2d4bf298660ec5fdca0" dependencies = [ "anyhow", "async-stream", @@ -3410,7 +3655,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" version = "0.1.0-alpha.5" -source = "git+https://github.com/aaif-goose/goose.git?rev=064244e6bddf641876676f054a006b7da1da5182#064244e6bddf641876676f054a006b7da1da5182" +source = "git+https://github.com/OpenSecretCloud/goose.git?rev=7c989fd13213e1c31d77d2d4bf298660ec5fdca0#7c989fd13213e1c31d77d2d4bf298660ec5fdca0" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", @@ -3555,6 +3800,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -3610,35 +3866,112 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hkdf" -version = "0.12.4" +name = "hickory-net" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ - "hmac", + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", ] [[package]] -name = "hmac" -version = "0.12.1" +name = "hickory-proto" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" dependencies = [ - "digest 0.10.7", + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", ] [[package]] -name = "home" -version = "0.5.12" +name = "hickory-resolver" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" dependencies = [ - "windows-sys 0.61.2", + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration 0.7.0", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", ] [[package]] -name = "html5ever" -version = "0.38.0" +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ @@ -3760,11 +4093,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.5.3", ] [[package]] @@ -3919,6 +4252,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -4120,11 +4459,27 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry 0.6.1", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -4136,6 +4491,171 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "rand 0.10.2", + "reqwest 0.13.2", + "rustc-hash", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum 0.28.0", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +dependencies = [ + "curve25519-dalek 5.0.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand 0.10.2", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.2", + "rustls", + "simple-dns", + "strum 0.28.0", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "iroh-relay" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.2", + "reqwest 0.13.2", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum 0.28.0", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots", + "ws_stream_wasm", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -4398,7 +4918,7 @@ dependencies = [ "pem", "serde", "serde_json", - "signature", + "signature 2.2.0", "simple_asn1", "zeroize", ] @@ -4409,7 +4929,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -4444,7 +4964,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -4534,7 +5054,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", "redox_syscall", ] @@ -4595,6 +5115,19 @@ dependencies = [ "value-bag", ] +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "loop9" version = "0.1.5" @@ -4609,6 +5142,9 @@ name = "lru" version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "lru-slab" @@ -4616,6 +5152,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "maple" version = "3.3.4" @@ -4626,12 +5168,17 @@ dependencies = [ "axum", "base64 0.22.1", "ciborium", + "core-foundation 0.10.1", + "dirs", + "fs2", "futures-util", + "getrandom 0.3.4", "goose", "goose-providers", "httpdate", "icu_properties", "image 0.25.10", + "iroh", "keyring", "libc", "log", @@ -4650,6 +5197,7 @@ dependencies = [ "reqwest 0.13.2", "rmcp", "rustls", + "security-framework", "serde", "serde_json", "sha2 0.10.9", @@ -4671,6 +5219,7 @@ dependencies = [ "tower-http 0.6.8", "webpki-roots", "windows 0.62.2", + "zeroize", ] [[package]] @@ -4858,6 +5407,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -4886,7 +5452,60 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", ] [[package]] @@ -4938,7 +5557,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "jni-sys 0.3.0", "log", "ndk-sys", @@ -4962,6 +5581,119 @@ dependencies = [ "jni-sys 0.3.0", ] +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation 0.3.2", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -4974,7 +5706,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -4987,7 +5719,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -5027,13 +5759,75 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "noq" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5231,7 +6025,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -5245,7 +6039,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -5266,8 +6060,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -5277,7 +6073,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -5310,12 +6106,26 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", ] +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", + "objc2-security-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -5337,7 +6147,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -5349,7 +6159,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -5362,7 +6172,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -5373,47 +6183,82 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] -name = "objc2-osa-kit" +name = "objc2-security" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "objc2 0.6.4", - "objc2-app-kit", - "objc2-foundation 0.3.2", + "objc2-core-foundation", ] [[package]] -name = "objc2-quartz-core" -version = "0.2.2" +name = "objc2-security-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] -name = "objc2-quartz-core" +name = "objc2-system-configuration" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", + "dispatch2", + "libc", "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-security", ] [[package]] @@ -5422,7 +6267,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit", @@ -5453,7 +6298,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-app-kit", @@ -5493,6 +6338,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -5561,7 +6410,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -5731,6 +6580,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "parking" version = "2.2.1" @@ -5791,7 +6650,7 @@ source = "git+https://github.com/OpenSecretCloud/pdf_oxide.git?rev=f24b43ba997dd dependencies = [ "aes 0.9.1", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "brotli", "byteorder", "bytes", @@ -5859,12 +6718,31 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.12.1" @@ -6028,9 +6906,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -6039,8 +6917,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -6081,7 +6969,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -6139,6 +7027,9 @@ name = "portable-atomic" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +dependencies = [ + "serde", +] [[package]] name = "portable-atomic-util" @@ -6149,6 +7040,30 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -6181,6 +7096,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "primal-check" version = "0.3.4" @@ -6336,7 +7262,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "memchr", "unicase", ] @@ -6570,6 +7496,15 @@ dependencies = [ "rand 0.10.2", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -6626,7 +7561,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -6683,7 +7618,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -6864,6 +7799,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.4.0" @@ -7012,10 +7953,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -7099,11 +8040,11 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7162,7 +8103,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7195,7 +8136,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "core_maths", "log", @@ -7305,6 +8246,12 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -7323,10 +8270,10 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -7337,7 +8284,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7354,13 +8301,23 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "selectors" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cssparser", "derive_more", "log", @@ -7383,6 +8340,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -7405,6 +8368,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -7547,6 +8520,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -7589,6 +8572,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -7660,6 +8649,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "simba" version = "0.10.0" @@ -7703,6 +8701,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -7762,7 +8769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7787,6 +8794,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + [[package]] name = "soup3" version = "0.5.0" @@ -7813,6 +8826,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "spin" version = "0.9.8" @@ -7822,6 +8846,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.7.3" @@ -7829,7 +8859,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", ] [[package]] @@ -7926,7 +8966,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "bytes", "chrono", @@ -7969,7 +9009,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.1", "byteorder", "chrono", "crc", @@ -8263,7 +9303,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8303,11 +9354,17 @@ dependencies = [ "slotmap", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tao" version = "0.35.2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -8521,7 +9578,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "url", - "windows-registry", + "windows-registry 0.5.3", "windows-result 0.3.4", ] @@ -8799,7 +9856,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8898,6 +9955,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "libc", "num-conv", "num_threads", @@ -9047,6 +10105,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -9071,10 +10130,34 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.8.2" @@ -9218,7 +10301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", @@ -9241,7 +10324,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes", "http", "percent-encoding", @@ -9392,7 +10475,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10198,6 +11281,12 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -10220,7 +11309,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -10400,6 +11489,17 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -10800,6 +11900,21 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.62.2", +] + [[package]] name = "write-fonts" version = "0.48.1" @@ -10863,6 +11978,25 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wyz" version = "0.5.1" @@ -10916,7 +12050,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", @@ -11088,18 +12222,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index b734de484..c80e9456a 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -47,6 +47,11 @@ base64 = "0.22" reqwest = { version = "0.13", features = ["stream"] } futures-util = "0.3" sha2 = "0.10" +iroh = { version = "=1.0.3", default-features = false, features = ["tls-ring"] } +zeroize = { version = "1", features = ["derive"] } +ciborium = "0.2" +getrandom = "0.3" +fs2 = "0.4" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux", target_os = "android"))'.dependencies] # PDF OCR uses Maple's explicitly packaged ONNX Runtime. The loader policy @@ -57,8 +62,10 @@ ort = { version = "=2.0.0-rc.11", default-features = false, features = ["std", " # Pin Goose to an exact official upstream commit. Keep this as a git dependency # instead of a submodule so ordinary Maple checkouts do not need the full Goose # history. -goose = { git = "https://github.com/aaif-goose/goose.git", rev = "064244e6bddf641876676f054a006b7da1da5182", package = "goose", default-features = false } -goose-providers = { git = "https://github.com/aaif-goose/goose.git", rev = "064244e6bddf641876676f054a006b7da1da5182", package = "goose-providers", default-features = false } +# Temporary immutable OpenSecretCloud fork pin for bounded Agent history paging. +# Replace with an upstream release once the public Goose paging API lands. +goose = { git = "https://github.com/OpenSecretCloud/goose.git", rev = "7c989fd13213e1c31d77d2d4bf298660ec5fdca0", package = "goose", default-features = false } +goose-providers = { git = "https://github.com/OpenSecretCloud/goose.git", rev = "7c989fd13213e1c31d77d2d4bf298660ec5fdca0", package = "goose-providers", default-features = false } opensecret = "3.6.0" rand = "0.8.6" async-trait = "0.1" @@ -80,6 +87,9 @@ libc = "0.2" # avoids physical-pixel scaling errors on Retina and mixed-DPI displays. objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSWindow"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString"] } +security-framework = { version = "=3.5.1", features = ["OSX_10_15"] } +core-foundation = "0.10" +dirs = "6" [target.'cfg(target_os = "ios")'.dependencies] # We build ONNX Runtime 1.23.2 from source for iOS (see scripts/build-ios-onnxruntime.sh) @@ -99,8 +109,6 @@ webpki-roots = "1" keyring = { version = "3", features = ["windows-native"] } windows = { version = "0.62.2", features = ["Win32_System_Threading"] } -[dev-dependencies] -ciborium = "0.2" [patch.crates-io] # Local patch for tao 0.35.2 Android intent crashes: # https://github.com/tauri-apps/tao/issues/1217 diff --git a/frontend/src-tauri/src/agent.rs b/frontend/src-tauri/src/agent.rs index f715a4ca0..4db511f60 100644 --- a/frontend/src-tauri/src/agent.rs +++ b/frontend/src-tauri/src/agent.rs @@ -28,8 +28,10 @@ use goose::conversation::{fix_conversation, Conversation}; use goose::execution::manager::{AgentManager, AgentManagerGetResult, RuntimeContext}; use goose::permission::permission_confirmation::PrincipalType; use goose::permission::{Permission, PermissionConfirmation}; -use goose::session::session_manager::{Session, SessionType}; -use goose::session::SessionManager; +use goose::session::{ + MessageHistoryCursor, MessageHistoryPageError, MessageHistoryPageQuery, Session, + SessionListCursor, SessionListPageError, SessionListPageQuery, SessionManager, SessionType, +}; use goose::skills::{SkillsClient, EXTENSION_NAME as SKILLS_EXTENSION_NAME}; use icu_properties::{props::DefaultIgnorableCodePoint, CodePointSetData}; use provider::{MapleProvider, MAPLE_PROVIDER_NAME}; @@ -104,6 +106,9 @@ const DEFAULT_AGENT_SESSION_TITLE: &str = "New task"; const DEFAULT_MCP_TIMEOUT_SECONDS: u64 = 300; const MAX_AGENT_SESSION_TITLE_CHARS: usize = 80; const MAX_AGENT_ERROR_CHARS: usize = 1_200; +pub(crate) const DEFAULT_AGENT_PAGE_SIZE: usize = 25; +pub(crate) const MAX_AGENT_PAGE_SIZE: usize = 50; +const MAX_AGENT_PAGE_CURSOR_BYTES: usize = 512; const MAX_MCP_CONNECTION_ERRORS: usize = 3; const MAX_MCP_SERVER_NAME_CHARS: usize = 64; const MAX_MCP_CONNECTION_ERROR_CHARS: usize = 200; @@ -549,7 +554,7 @@ pub(crate) enum AgentServiceEvent { }, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionSummary { pub id: String, @@ -557,6 +562,9 @@ pub struct AgentSessionSummary { pub project_root: String, pub created_ms: i64, pub updated_ms: i64, + /// Exact native keyset sort timestamp used by Goose's session pager. + /// Frontends merge event upserts using `(pageSortMs DESC, id DESC)`. + pub page_sort_ms: i64, pub message_count: usize, pub model: Option, pub mode: String, @@ -570,6 +578,111 @@ pub struct AgentSessionDetail { pub mcp_errors: Vec, } +/// One count-bounded page request over Goose's native persisted message rows. +/// +/// A row remains the pagination unit even when Goose stores several visible +/// content blocks in it. The opaque cursor is owned and validated by Goose; it +/// is never interpreted by the renderer or Maple's remote protocol. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentHistoryPageRequest { + pub session_id: String, + #[serde(default)] + pub cursor: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentHistoryRecord { + /// Stable source-row identity supplied by Goose. This deliberately differs + /// from nullable provider message IDs and from presentation item IDs. + pub record_id: String, + pub role: String, + pub created_ms: u64, + /// User-safe Maple projection of every visible block in this one row. + /// Hidden provider rows remain present with an empty item list so a cursor + /// advances over the exact native storage order without leaking content. + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentHistoryPage { + /// Newest-first, matching Goose's storage keyset pager. + pub records: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Opaque storage generation. Append preserves it; replace/truncate rotates + /// it so delayed A -> B -> A page loads cannot merge into a new history. + pub history_revision: String, + /// Authoritative absolute live suffix captured with the event watermark. + /// `Some([])` deliberately clears a cached overlay; `None` means this + /// response did not establish a synchronized live checkpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub live_items: Option>, + /// Reserved for the independent live-event replay journal. Historical + /// cursors must never double as event acknowledgement cursors. + #[serde(skip_serializing_if = "Option::is_none")] + pub through_event_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentLiveEventCursor { + pub journal_id: String, + pub sequence: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionPageRequest { + #[serde(default)] + pub project_root: Option, + #[serde(default)] + pub cursor: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionPage { + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AgentPagingError { + InvalidRequest(&'static str), + StaleHistory, + HistoryRecordTooLarge, + Unavailable, +} + +impl AgentPagingError { + pub(crate) fn user_message(&self) -> &'static str { + match self { + Self::InvalidRequest(message) => message, + Self::StaleHistory => "Agent task history changed; reload its newest page", + Self::HistoryRecordTooLarge => { + "One Agent history record is too large to display safely" + } + Self::Unavailable => "Agent task history is unavailable", + } + } +} + +impl std::fmt::Display for AgentPagingError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.user_message()) + } +} + +impl std::error::Error for AgentPagingError {} + #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentTimelineItem { @@ -2445,6 +2558,81 @@ impl AgentRuntimeHandle { Ok(sessions) } + /// Read one newest-first session-summary page directly through Goose's + /// storage keyset pager. This never loads all task rows and slices in Maple. + pub(crate) async fn list_sessions_page( + &self, + request: AgentSessionPageRequest, + ) -> Result { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let limit = request.limit.unwrap_or(DEFAULT_AGENT_PAGE_SIZE); + if !(1..=MAX_AGENT_PAGE_SIZE).contains(&limit) { + return Err(AgentPagingError::InvalidRequest( + "Agent task page limit must be between 1 and 50", + )); + } + validate_agent_page_cursor(request.cursor.as_deref())?; + let filter_root = request + .project_root + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|path| normalize_project_root(Path::new(path))) + .transpose() + .map_err(|_| AgentPagingError::InvalidRequest("Agent project path is invalid"))?; + let cursor = request + .cursor + .as_deref() + .map(SessionListCursor::from_str) + .transpose() + .map_err(|_| AgentPagingError::InvalidRequest("Agent task cursor is invalid"))?; + + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation() + .await + .map_err(|_| AgentPagingError::StaleHistory)?; + let session_manager = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, account_scope) + .map_err(|_| AgentPagingError::StaleHistory)?; + Arc::clone(¤t.session_manager) + } + None => account_session_manager(&state.host.paths, user_id) + .map_err(|_| AgentPagingError::Unavailable)?, + } + }; + let page = session_manager + .list_sessions_paged(SessionListPageQuery { + cursor, + page_size: Some(limit), + working_dir: filter_root, + session_types: Some(vec![SessionType::User]), + keyword: None, + only_sessions_with_messages: false, + include_last_message_snippet: false, + }) + .await + .map_err(map_goose_session_page_error)?; + let items = page + .sessions + .into_iter() + .map(|session| session_summary(&session)) + .collect::>(); + if items.len() > limit { + return Err(AgentPagingError::Unavailable); + } + self.verify_generation() + .await + .map_err(|_| AgentPagingError::StaleHistory)?; + Ok(AgentSessionPage { + items, + next_cursor: page.next_cursor.map(|cursor| cursor.to_string()), + }) + } + pub(crate) async fn load_session( &self, session_id: String, @@ -2512,6 +2700,91 @@ impl AgentRuntimeHandle { }) } + /// Read one newest-first page directly from Goose storage without loading + /// the complete conversation. Embedded Tauri and authenticated remote RPC + /// both call this exact host method. + pub(crate) async fn list_session_records_page( + &self, + request: AgentHistoryPageRequest, + ) -> Result { + let state = &self.service; + let user_id = self.user_id.as_ref(); + let account_scope = self.account_scope.as_ref(); + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err(AgentPagingError::InvalidRequest( + "Agent task ID cannot be empty", + )); + } + let limit = request.limit.unwrap_or(DEFAULT_AGENT_PAGE_SIZE); + if !(1..=MAX_AGENT_PAGE_SIZE).contains(&limit) { + return Err(AgentPagingError::InvalidRequest( + "Agent history page limit must be between 1 and 50", + )); + } + validate_agent_page_cursor(request.cursor.as_deref())?; + + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + self.verify_generation() + .await + .map_err(|_| AgentPagingError::StaleHistory)?; + let session_manager = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, account_scope) + .map_err(|_| AgentPagingError::StaleHistory)?; + Arc::clone(¤t.session_manager) + } + None => account_session_manager(&state.host.paths, user_id) + .map_err(|_| AgentPagingError::Unavailable)?, + } + }; + + // Parse the opaque token only through Goose's public contract. Maple + // never decodes storage anchors or accepts an unscoped tuple cursor. + let cursor = request + .cursor + .as_deref() + .map(MessageHistoryCursor::from_str) + .transpose() + .map_err(|_| AgentPagingError::InvalidRequest("Agent history cursor is invalid"))?; + let page = session_manager + .list_messages_paged( + session_id, + MessageHistoryPageQuery { + cursor, + page_size: Some(limit), + }, + ) + .await + .map_err(map_goose_history_page_error)?; + + let records = page + .messages + .into_iter() + .map(|record| project_history_record(record.record_id, record.message)) + .collect::>(); + + if records.len() > limit { + return Err(AgentPagingError::Unavailable); + } + for record in &records { + validate_local_history_record_bound(record)?; + } + self.verify_generation() + .await + .map_err(|_| AgentPagingError::StaleHistory)?; + + Ok(AgentHistoryPage { + records, + next_cursor: page.next_cursor.map(|cursor| cursor.to_string()), + history_revision: page.history_revision.to_string(), + live_items: None, + through_event_cursor: None, + }) + } + pub(crate) async fn rename_session( &self, maple_api_session: Arc, @@ -2835,6 +3108,182 @@ impl AgentRuntimeHandle { } } +fn normalized_agent_message_created_ms(created: i64) -> u64 { + const MILLISECOND_TIMESTAMP_THRESHOLD: i64 = 10_000_000_000; + let milliseconds = if created > MILLISECOND_TIMESTAMP_THRESHOLD { + created + } else { + created.saturating_mul(1_000) + }; + u64::try_from(milliseconds).unwrap_or_default() +} + +fn project_history_record(record_id: String, mut source_message: Message) -> AgentHistoryRecord { + let role = message_role(&source_message); + let created_ms = normalized_agent_message_created_ms(source_message.created); + if source_message.id.is_none() { + source_message.id = Some(record_id.clone()); + } + let visible = source_message.user_visible_content(); + let mut items = message_to_timeline_items(&visible, false); + for item in &mut items { + if item.item_type == "permission" && item.status.as_deref() == Some("pending") { + item.status = Some("cancelled".to_string()); + } + } + AgentHistoryRecord { + record_id, + role, + created_ms, + items, + } +} + +fn validate_local_history_record_bound( + record: &AgentHistoryRecord, +) -> Result<(), AgentPagingError> { + // The embedded adapter still carries Maple's richer local presentation. + // Enforce the universal single-record ceiling on that exact DTO before it + // can cross Tauri, even though the authenticated remote adapter projects a + // narrower secret-free representation below. + let mut local_encoded = SerializedByteCounter::default(); + ciborium::ser::into_writer(record, &mut local_encoded) + .map_err(|_| AgentPagingError::Unavailable)?; + if local_encoded.bytes > crate::remote_protocol::MAX_HISTORY_RECORD_PRESENTATION_BYTES { + return Err(AgentPagingError::HistoryRecordTooLarge); + } + // Match the remote transport's universal single-frame limit at the shared + // host boundary. Embedded Tauri must not succeed with a record that the + // authenticated remote adapter must reject. + let remote = crate::remote_protocol::RemoteAgentHistoryRecord { + record_id: record.record_id.clone(), + role: record.role.clone(), + created_ms: record.created_ms, + items: record + .items + .iter() + .map(project_safe_remote_history_item) + .collect::, AgentPagingError>>()?, + }; + remote.validate().map_err(|error| match error.code { + crate::remote_protocol::ErrorCode::HistoryRecordTooLarge + | crate::remote_protocol::ErrorCode::InvalidFrame => { + AgentPagingError::HistoryRecordTooLarge + } + _ => AgentPagingError::Unavailable, + })?; + let mut encoded = SerializedByteCounter::default(); + ciborium::ser::into_writer(&remote, &mut encoded).map_err(|_| AgentPagingError::Unavailable)?; + if encoded.bytes > crate::remote_protocol::MAX_HISTORY_RECORD_PRESENTATION_BYTES { + Err(AgentPagingError::HistoryRecordTooLarge) + } else { + Ok(()) + } +} + +pub(crate) fn project_safe_remote_history_item( + item: &AgentTimelineItem, +) -> Result { + let safe = crate::agent_live_projection::project_timeline_item(item) + .map_err(|_| AgentPagingError::Unavailable)?; + let item = crate::remote_protocol::RemoteAgentTimelineItem { + id: safe.id, + item_type: match safe.item_type { + crate::agent_live_coordinator::MapleLiveItemType::Message => "message", + crate::agent_live_coordinator::MapleLiveItemType::Thinking => "thinking", + crate::agent_live_coordinator::MapleLiveItemType::Tool => "tool", + crate::agent_live_coordinator::MapleLiveItemType::Permission => "permission", + crate::agent_live_coordinator::MapleLiveItemType::System => "system", + crate::agent_live_coordinator::MapleLiveItemType::Error => "error", + } + .to_string(), + role: safe.role.map(|role| { + match role { + crate::agent_live_coordinator::MapleLiveRole::User => "user", + crate::agent_live_coordinator::MapleLiveRole::Assistant => "assistant", + crate::agent_live_coordinator::MapleLiveRole::Thought => "thought", + crate::agent_live_coordinator::MapleLiveRole::System => "system", + } + .to_string() + }), + title: safe.title, + text: safe.text, + status: safe.status, + created_ms: safe.created_ms, + merge: match safe.merge { + crate::agent_live_coordinator::MapleLiveMerge::Append => "append", + crate::agent_live_coordinator::MapleLiveMerge::Replace => "replace", + } + .to_string(), + }; + item.validate().map_err(|error| match error.code { + crate::remote_protocol::ErrorCode::HistoryRecordTooLarge + | crate::remote_protocol::ErrorCode::InvalidFrame => { + AgentPagingError::HistoryRecordTooLarge + } + _ => AgentPagingError::Unavailable, + })?; + Ok(item) +} + +#[derive(Default)] +struct SerializedByteCounter { + bytes: usize, +} + +impl Write for SerializedByteCounter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.bytes = self + .bytes + .checked_add(buffer.len()) + .ok_or_else(|| std::io::Error::other("serialized history record length overflow"))?; + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn validate_agent_page_cursor(cursor: Option<&str>) -> Result<(), AgentPagingError> { + if cursor.is_some_and(|cursor| { + cursor.is_empty() + || cursor.len() > MAX_AGENT_PAGE_CURSOR_BYTES + || !cursor.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':') + }) + }) { + Err(AgentPagingError::InvalidRequest( + "Agent page cursor is invalid", + )) + } else { + Ok(()) + } +} + +fn map_goose_history_page_error(error: anyhow::Error) -> AgentPagingError { + match error.downcast_ref::() { + Some( + MessageHistoryPageError::InvalidCursor + | MessageHistoryPageError::CursorSessionMismatch + | MessageHistoryPageError::InvalidPageSize, + ) => AgentPagingError::InvalidRequest("Agent history cursor or limit is invalid"), + Some(MessageHistoryPageError::StaleRevision) => AgentPagingError::StaleHistory, + Some(MessageHistoryPageError::SessionNotFound) | None => AgentPagingError::Unavailable, + } +} + +fn map_goose_session_page_error(error: anyhow::Error) -> AgentPagingError { + match error.downcast_ref::() { + Some( + SessionListPageError::InvalidCursor + | SessionListPageError::CursorFilterMismatch + | SessionListPageError::InvalidPageSize, + ) => AgentPagingError::InvalidRequest("Agent task cursor or limit is invalid"), + None => AgentPagingError::Unavailable, + } +} + async fn delete_persisted_agent_session( session_manager: &SessionManager, pending_permissions: &PendingPermissions, @@ -5483,7 +5932,7 @@ fn message_to_timeline_items_with_thinking( .clone() .unwrap_or_else(|| format!("message-{}-{}", role, message.created)); let created_ms = if message.created > 0 { - (message.created as u128) * 1000 + u128::from(normalized_agent_message_created_ms(message.created)) } else { unix_ms() }; @@ -6055,6 +6504,10 @@ fn session_summary(session: &Session) -> AgentSessionSummary { project_root: path_string(&session.working_dir), created_ms: session.created_at.timestamp_millis(), updated_ms: session.updated_at.timestamp_millis(), + page_sort_ms: session + .last_message_at + .unwrap_or(session.updated_at) + .timestamp_millis(), message_count: session.message_count, model: session .model_config @@ -6065,7 +6518,11 @@ fn session_summary(session: &Session) -> AgentSessionSummary { } fn sort_sessions_newest_first(sessions: &mut [AgentSessionSummary]) { - sessions.sort_by(|a, b| b.updated_ms.cmp(&a.updated_ms)); + sessions.sort_by(|a, b| { + b.page_sort_ms + .cmp(&a.page_sort_ms) + .then_with(|| b.id.cmp(&a.id)) + }); } async fn record_and_emit_timeline_item( @@ -9039,6 +9496,7 @@ mod tests { project_root: test_project_path("session-sort"), created_ms: 0, updated_ms, + page_sort_ms: updated_ms, message_count: 0, model: None, mode: DEFAULT_GOOSE_MODE.to_string(), @@ -9064,6 +9522,36 @@ mod tests { ); } + #[test] + fn agent_session_page_sort_uses_native_timestamp_then_stable_id() { + let summary = |id: &str, updated_ms: i64, page_sort_ms: i64| AgentSessionSummary { + id: id.to_string(), + title: id.to_string(), + project_root: test_project_path("session-page-sort"), + created_ms: 0, + updated_ms, + page_sort_ms, + message_count: 0, + model: None, + mode: DEFAULT_GOOSE_MODE.to_string(), + }; + let mut sessions = vec![ + summary("a", 100, 300), + summary("b", 200, 300), + summary("z", 999, 250), + ]; + + sort_sessions_newest_first(&mut sessions); + + assert_eq!( + sessions + .into_iter() + .map(|session| session.id) + .collect::>(), + vec!["b".to_string(), "a".to_string(), "z".to_string()] + ); + } + #[test] fn mcp_selection_distinguishes_defaults_from_explicit_empty() { let configured = normalize_mcp_servers(vec![ @@ -10354,6 +10842,92 @@ mod tests { assert!(!output.to_string().contains("provider-private-tool-state")); } + #[test] + fn remote_history_projection_matches_safe_live_redaction_and_drops_tool_secrets() { + let rich = AgentTimelineItem { + id: "tool-secret".to_string(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: Some("Shell: cat /Users/private/.env".to_string()), + text: Some("parser failed with DATABASE_URL=postgres://secret".to_string()), + status: Some("failed".to_string()), + input: Some(serde_json::json!({"command": "cat /Users/private/.env"})), + output: Some(serde_json::json!({"token": "sk-secret"})), + created_ms: 1_700_000_000_000, + merge: "replace".to_string(), + }; + + let safe_history = project_safe_remote_history_item(&rich).expect("safe history item"); + let safe_live = + crate::agent_live_projection::project_timeline_item(&rich).expect("safe live item"); + assert_eq!(safe_history.title, safe_live.title); + assert_eq!(safe_history.text, safe_live.text); + assert_eq!(safe_history.status, safe_live.status); + let encoded = serde_json::to_string(&safe_history).expect("encode safe item"); + for secret in [ + "/Users/private", + "DATABASE_URL", + "postgres://", + "sk-secret", + "command", + ] { + assert!(!encoded.contains(secret), "leaked {secret}"); + } + assert!(!encoded.contains("input")); + assert!(!encoded.contains("output")); + } + + #[test] + fn paged_null_id_rows_with_the_same_role_and_timestamp_remain_distinct() { + let mut first_message = Message::assistant().with_text("first"); + first_message.created = 1_700_000_000; + let mut second_message = Message::assistant().with_text("second"); + second_message.created = 1_700_000_000; + let first = project_history_record("mhrw_epoch-row-1".to_string(), first_message); + let second = project_history_record("mhrw_epoch-row-2".to_string(), second_message); + + assert_eq!(first.items.len(), 1); + assert_eq!(second.items.len(), 1); + assert_ne!(first.items[0].id, second.items[0].id); + assert_eq!(first.items[0].id, "mhrw_epoch-row-1-text"); + assert_eq!(second.items[0].id, "mhrw_epoch-row-2-text"); + assert_eq!(first.created_ms, 1_700_000_000_000); + } + + #[test] + fn paged_message_timestamp_normalization_preserves_legacy_milliseconds() { + assert_eq!( + normalized_agent_message_created_ms(1_700_000_000), + 1_700_000_000_000 + ); + assert_eq!( + normalized_agent_message_created_ms(1_700_000_000_123), + 1_700_000_000_123 + ); + let mut message = Message::assistant() + .with_id("legacy-millis") + .with_text("stored in milliseconds"); + message.created = 1_700_000_000_123; + let record = project_history_record("mhrw_timestamp".to_string(), message); + assert_eq!(record.created_ms, 1_700_000_000_123); + assert_eq!(record.items[0].created_ms, 1_700_000_000_123_u128); + } + + #[test] + fn embedded_history_rejects_one_oversized_record_explicitly() { + let record = project_history_record( + "mhrw_oversized".to_string(), + Message::assistant().with_id("oversized-message").with_text( + "x".repeat(crate::remote_protocol::MAX_HISTORY_RECORD_PRESENTATION_BYTES), + ), + ); + + assert_eq!( + validate_local_history_record_bound(&record), + Err(AgentPagingError::HistoryRecordTooLarge) + ); + } + #[test] fn hidden_usage_boundary_resets_visible_inference_state() { let first = "First visible thought."; @@ -11456,6 +12030,200 @@ mod tests { assert!(!Arc::ptr_eq(&first, &other_account)); } + #[tokio::test] + async fn paged_history_adapter_preserves_native_rows_and_maps_cursor_errors() { + let sink = Arc::new(NoopAgentEventSink); + let (test_root, paths, state) = agent_service_test_context("native-history-pager", sink); + let user_id = "native-history-pager-user"; + let project_root = test_root.join("project"); + fs::create_dir_all(&project_root).unwrap(); + let session_manager = account_session_manager(&paths, user_id).unwrap(); + let session = session_manager + .create_session( + project_root.clone(), + "Paged task".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .unwrap(); + let other = session_manager + .create_session( + project_root, + "Other task".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .unwrap(); + session_manager + .add_message( + &session.id, + &Message::assistant() + .with_id("hidden-native-row") + .with_text("provider-private") + .with_visibility(false, true), + ) + .await + .unwrap(); + session_manager + .add_message( + &session.id, + &assistant_tool_message( + "multi-content-native-row", + "tool-call-one", + "inspect the file", + "", + ), + ) + .await + .unwrap(); + let handle = state.handle_for_user(user_id).await.unwrap(); + + let first = handle + .list_session_records_page(AgentHistoryPageRequest { + session_id: session.id.clone(), + cursor: None, + limit: Some(1), + }) + .await + .unwrap(); + assert_eq!(first.records.len(), 1); + assert_eq!(first.records[0].items.len(), 2); + let cursor = first.next_cursor.clone().expect("older native row exists"); + + let second = handle + .list_session_records_page(AgentHistoryPageRequest { + session_id: session.id.clone(), + cursor: Some(cursor.clone()), + limit: Some(1), + }) + .await + .unwrap(); + assert_eq!(second.records.len(), 1); + assert!(second.records[0].items.is_empty()); + assert_eq!(second.history_revision, first.history_revision); + + assert!(matches!( + handle + .list_session_records_page(AgentHistoryPageRequest { + session_id: other.id, + cursor: Some(cursor.clone()), + limit: Some(1), + }) + .await, + Err(AgentPagingError::InvalidRequest(_)) + )); + + session_manager + .replace_conversation( + &session.id, + &Conversation::new_unvalidated(vec![ + Message::user().with_text("replacement history") + ]), + ) + .await + .unwrap(); + assert_eq!( + handle + .list_session_records_page(AgentHistoryPageRequest { + session_id: session.id, + cursor: Some(cursor), + limit: Some(1), + }) + .await, + Err(AgentPagingError::StaleHistory) + ); + + drop(handle); + drop(state); + drop(session_manager); + let _ = fs::remove_dir_all(test_root); + } + + #[tokio::test] + async fn paged_session_adapter_filters_before_limit_and_binds_cursor_scope() { + let sink = Arc::new(NoopAgentEventSink); + let (test_root, paths, state) = agent_service_test_context("native-session-pager", sink); + let user_id = "native-session-pager-user"; + let mut project_a = test_root.join("project-a"); + let mut project_b = test_root.join("project-b"); + fs::create_dir_all(&project_a).unwrap(); + fs::create_dir_all(&project_b).unwrap(); + // Production task creation stores Maple's normalized root. macOS's + // temporary directory is exposed through `/var` but canonicalizes to + // `/private/var`, so make the fixture exercise that same contract. + project_a = normalize_project_root(&project_a).unwrap(); + project_b = normalize_project_root(&project_b).unwrap(); + let session_manager = account_session_manager(&paths, user_id).unwrap(); + let first_a = session_manager + .create_session( + project_a.clone(), + "A one".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .unwrap(); + let _only_b = session_manager + .create_session( + project_b.clone(), + "B only".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .unwrap(); + let second_a = session_manager + .create_session( + project_a.clone(), + "A two".to_string(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .unwrap(); + let handle = state.handle_for_user(user_id).await.unwrap(); + let first = handle + .list_sessions_page(AgentSessionPageRequest { + project_root: Some(path_string(&project_a)), + cursor: None, + limit: Some(1), + }) + .await + .unwrap(); + assert_eq!(first.items.len(), 1); + let cursor = first.next_cursor.clone().expect("second A task exists"); + let second = handle + .list_sessions_page(AgentSessionPageRequest { + project_root: Some(path_string(&project_a)), + cursor: Some(cursor.clone()), + limit: Some(1), + }) + .await + .unwrap(); + let returned = [first.items[0].id.clone(), second.items[0].id.clone()]; + assert!(returned.contains(&first_a.id)); + assert!(returned.contains(&second_a.id)); + assert!(second.next_cursor.is_none()); + + assert!(matches!( + handle + .list_sessions_page(AgentSessionPageRequest { + project_root: Some(path_string(&project_b)), + cursor: Some(cursor), + limit: Some(1), + }) + .await, + Err(AgentPagingError::InvalidRequest(_)) + )); + + drop(handle); + drop(state); + drop(session_manager); + let _ = fs::remove_dir_all(test_root); + } + #[tokio::test] async fn unchanged_new_task_rename_preserves_automatic_title_eligibility() { let sink = Arc::new(RecordingAgentEventSink::default()); diff --git a/frontend/src-tauri/src/agent/system_prompt.rs b/frontend/src-tauri/src/agent/system_prompt.rs index d2d7d5a03..efb18919d 100644 --- a/frontend/src-tauri/src/agent/system_prompt.rs +++ b/frontend/src-tauri/src/agent/system_prompt.rs @@ -54,15 +54,6 @@ No extensions are defined. You should let the user know that they should add ext {% endif %} {% endif %} -{% if include_extensions and extension_tool_limits is defined and not code_execution_mode %} -{% with (extension_count, tool_count) = extension_tool_limits %} -# Suggestion - -The user has {{extension_count}} extensions with {{tool_count}} tools enabled, exceeding recommended limits ({{max_extensions}} extensions or {{max_tools}} tools). -Consider asking if they'd like to disable some extensions to improve tool selection accuracy. -{% endwith %} -{% endif %} - # Response Guidelines Use Markdown formatting for all responses. diff --git a/frontend/src-tauri/src/agent_event_journal.rs b/frontend/src-tauri/src/agent_event_journal.rs new file mode 100644 index 000000000..41e4a18bd --- /dev/null +++ b/frontend/src-tauri/src/agent_event_journal.rs @@ -0,0 +1,8283 @@ +//! Bounded durable replay for already-sanitized Agent presentation events. +//! +//! This journal is deliberately independent from persisted-history pagination: +//! history cursors address Goose storage, while [`LiveEventCursor`] addresses a +//! short retained suffix of live presentation events. A missing retained suffix +//! is never treated as an empty replay; callers receive [`SnapshotRequired`] +//! and must rebuild from paged history before resuming live delivery. +//! +//! The journal does not project `AgentServiceEvent` itself. The global Agent +//! event sink does not carry account ownership, so persisting at that boundary +//! could publish an old account's event after an account transition. Callers +//! must first project an event into a reviewed, bounded payload type and append +//! it with the exact opaque account scope and current account generation. +//! +//! Exactly one journal instance must be composed into Maple's single host +//! process; clones share its mutex. An exclusive root lock also rejects a +//! second process or accidentally independent instance for the same root. +//! +//! Format v3 keeps a large immutable snapshot behind a fixed prefix and writes +//! bounded hash-chained event frames after it. Two alternating checksummed +//! anchor slots in that prefix commit the exact terminal sequence, byte offset, +//! and chain hash without rewriting the snapshot on every event. Only an +//! all-zero slot is absent: every nonzero slot must be fully checksummed and +//! valid, so a torn anchor write fails closed instead of rolling back. File EOF +//! must equal the selected anchor's exact committed end; even a well-formed +//! hash-chained frame beyond it is ambiguous and requires authoritative reseed +//! instead of automatic adoption or truncation. The checksum is a crash-tear +//! detector, not a keyed authenticity proof; malicious same-UID storage +//! mutation remains outside this journal's threat model. +//! +//! Durable construction is currently supported only on macOS and Linux. The +//! journal fails closed elsewhere until that platform has an owner-only ACL, +//! no-follow opens, and a durable atomic replacement implementation. + +#![allow( + dead_code, + reason = "the replay journal is wired by the remote Agent vertical slice" +)] + +use crate::agent_live_authority::VerifiedJournalReseedAuthority; +use fs2::FileExt; +use getrandom::fill as fill_random; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + fmt, + fs::{self, File, OpenOptions}, + io::{ErrorKind, Read, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex, MutexGuard}, +}; + +#[cfg(test)] +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; + +const JOURNAL_FORMAT_VERSION: u8 = 3; +const JOURNAL_ID_BYTES: usize = 16; +const JOURNAL_ID_HEX_BYTES: usize = JOURNAL_ID_BYTES * 2; +const ACCOUNT_KEY_HEX_BYTES: usize = 64; +const MAX_ACCOUNT_SCOPE_BYTES: usize = 256; +const MAX_EVENT_OWNER_ID_BYTES: usize = 128; +const MAX_HEADER_BYTES: usize = 32 * 1_024 * 1_024; +const MAX_RECORD_OVERHEAD_BYTES: usize = 768; +const MAX_ACCOUNT_JOURNAL_FILES: usize = 64; +const TEMP_FILE_PREFIX: &str = ".agent-live-events-"; +const RETIRING_FILE_PREFIX: &str = ".agent-live-retiring-v1-"; +const PROCESS_TOKEN_BYTES: usize = 16; +const PROCESS_TOKEN_HEX_BYTES: usize = PROCESS_TOKEN_BYTES * 2; +const MAX_CURSOR_SEQUENCE: u64 = 9_007_199_254_740_991; +const MAX_IDEMPOTENCY_EVENT_IDS: usize = 65_536; +const MAX_IDEMPOTENCY_METADATA_BYTES: usize = 20 * 1_024 * 1_024; +const MAX_CHECKPOINT_BYTES: usize = 8 * 1_024 * 1_024; +const CHECKPOINT_SCHEMA: &str = "maple_live_projection_v1"; +const DISK_SUPERBLOCK_MAGIC: &[u8; 8] = b"MPLAEJ3\0"; +const DISK_ANCHOR_MAGIC: &[u8; 8] = b"MPLANCH3"; +const DISK_FRAME_MAGIC: &[u8; 4] = b"MEV3"; +const DISK_SUPERBLOCK_VERSION: u32 = 3; +const DISK_ANCHOR_VERSION: u32 = 1; +const DISK_FRAME_VERSION: u8 = 1; +const DISK_SUPERBLOCK_BYTES: usize = 80; +const DISK_SUPERBLOCK_BYTES_U32: u32 = 80; +const DISK_ANCHOR_SLOT_BYTES: usize = 256; +const DISK_ANCHOR_SLOT_BYTES_U32: u32 = 256; +const DISK_ANCHOR_SLOT_COUNT: usize = 2; +const DISK_PREFIX_BYTES: usize = + DISK_SUPERBLOCK_BYTES + DISK_ANCHOR_SLOT_BYTES * DISK_ANCHOR_SLOT_COUNT; +const DISK_PREFIX_BYTES_U32: u32 = 592; +const DISK_FRAME_HEADER_BYTES: usize = 88; +const DISK_SUPERBLOCK_HASHED_BYTES: usize = 48; +const DISK_ANCHOR_HASHED_BYTES: usize = 224; +const DISK_SUPERBLOCK_CHECKSUM_DOMAIN: &[u8] = b"maple-agent-journal-v3-superblock"; +const DISK_ANCHOR_CHECKSUM_DOMAIN: &[u8] = b"maple-agent-journal-v3-anchor"; +const DISK_SNAPSHOT_HASH_DOMAIN: &[u8] = b"maple-agent-journal-v3-snapshot"; +const DISK_FRAME_HASH_DOMAIN: &[u8] = b"maple-agent-journal-v3-frame"; +const DISK_CHAIN_BASE_DOMAIN: &[u8] = b"maple-agent-journal-v3-chain-base"; +const OBSERVED_FILE_DIGEST_DOMAIN: &[u8] = b"maple-agent-journal-observed-file-v1"; +const PROJECTION_DIGEST_DOMAIN: &[u8] = b"maple-agent-journal-authoritative-projection-v1"; +const INGRESS_EVENT_NAMESPACE_DOMAIN: &[u8] = b"maple-agent-journal-ingress-event-namespace-v1"; +const AMBIGUOUS_EVENT_ID_DOMAIN: &[u8] = b"maple-agent-journal-ambiguous-event-id-v1"; + +/// Production defaults intentionally retain only a short live suffix. +/// +/// The history pager remains the durable source of truth. These bounds cover +/// ordinary phone background/foreground gaps without allowing streaming output +/// to grow a second unbounded history store. +pub(crate) const DEFAULT_LIVE_EVENT_JOURNAL_LIMITS: LiveEventJournalLimits = + LiveEventJournalLimits { + max_entries: 2_048, + max_payload_bytes: 256 * 1_024, + max_total_payload_bytes: 8 * 1_024 * 1_024, + max_replay_entries: 50, + max_replay_payload_bytes: 700 * 1_024, + }; + +/// Create the dedicated private directory that must directly contain the live +/// event journal root. The broader app-local-data directory may be readable by +/// other principals, but neither it nor its trusted canonical ancestry may +/// grant cross-principal rename authority. +pub(crate) fn prepare_live_event_journal_parent(path: &Path) -> Result<(), LiveEventJournalError> { + ensure_supported_platform()?; + let file_name = path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + let requested_parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + let canonical_parent = fs::canonicalize(requested_parent) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + verify_safe_directory_ancestry(&canonical_parent)?; + let dedicated_parent = canonical_parent.join(file_name); + match fs::symlink_metadata(&dedicated_parent) { + Ok(metadata) => { + if !metadata.file_type().is_dir() + || metadata.file_type().is_symlink() + || !metadata_owned_by_effective_user(&metadata) + { + return Err(LiveEventJournalError::StorageUnavailable); + } + } + Err(error) if error.kind() == ErrorKind::NotFound => { + create_owner_only_directory(&dedicated_parent)?; + } + Err(_) => return Err(LiveEventJournalError::StorageUnavailable), + } + let directory = open_directory_no_follow(&dedicated_parent)?; + set_owner_only_directory(&directory)?; + sync_directory_path(&canonical_parent)?; + verify_private_parent_directory(&dedicated_parent) +} + +/// A payload admitted to the durable live-event journal. +/// +/// Implement this only for a Maple-owned, presentation-safe wire projection. +/// Raw Goose events, provider messages, prompts, tool contexts, credentials, +/// and arbitrary `serde_json::Value` payloads are not suitable implementations. +/// Serialization must also be canonical for equal values: payloads must not +/// contain unordered maps or serializers whose output varies between calls or +/// processes, because the bytes feed the durable event commitment. +pub(crate) trait LiveReplayPayload: + Clone + Serialize + DeserializeOwned + Send + Sync + 'static +{ + /// Stable account-wide ID for exactly one projected event. A caller retry + /// after an ambiguous storage error must reuse this ID. Timeline updates + /// need an event/revision ID, not merely the timeline item's stable row ID. + fn live_replay_event_id(&self) -> &str; + + /// Revalidate semantic bounds before both persistence and replay. + fn validate_live_replay_payload(&self) -> Result<(), LiveEventJournalError>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LiveEventJournalLimits { + pub(crate) max_entries: usize, + pub(crate) max_payload_bytes: usize, + pub(crate) max_total_payload_bytes: usize, + pub(crate) max_replay_entries: usize, + /// Low-level response safety bound, separate from history page semantics. + /// Metadata overhead is additionally bounded by `max_replay_entries`. + pub(crate) max_replay_payload_bytes: usize, +} + +impl LiveEventJournalLimits { + fn validate(self) -> Result { + if self.max_entries == 0 + || self.max_payload_bytes == 0 + || self.max_total_payload_bytes < self.max_payload_bytes + || self.max_replay_entries == 0 + || self.max_replay_entries > self.max_entries + || self.max_replay_payload_bytes < self.max_payload_bytes + || self.max_replay_payload_bytes > self.max_total_payload_bytes + { + return Err(LiveEventJournalError::InvalidLimits); + } + self.max_disk_bytes() + .ok_or(LiveEventJournalError::InvalidLimits)?; + Ok(self) + } + + fn max_disk_bytes(self) -> Option { + let framed_entry_overhead = + MAX_RECORD_OVERHEAD_BYTES.checked_add(DISK_FRAME_HEADER_BYTES)?; + let entry_overhead = self.max_entries.checked_mul(framed_entry_overhead)?; + let total = DISK_PREFIX_BYTES + .checked_add(MAX_HEADER_BYTES)? + .checked_add(self.max_total_payload_bytes)? + .checked_add(entry_overhead)? + // One append can durably reach disk immediately before a required + // compaction on process interruption. + .checked_add(self.max_payload_bytes)? + .checked_add(framed_entry_overhead)?; + u64::try_from(total).ok() + } +} + +/// Exact account owner required for every journal operation. +/// +/// The raw account scope is hashed immediately and is never written to disk. +/// `account_generation` is Maple's revocable, process-local data generation; a +/// stale handle cannot append to or replay the current in-memory journal. It is +/// intentionally not persisted because Maple's current generation resets when +/// the process restarts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LiveEventAccountOwner { + account_key: String, + account_generation: u64, +} + +/// Process-local authority for one exact active journal owner. +/// +/// The random token is deliberately opaque, never serialized, and changes +/// across destructive owner transitions. Possessing only an account key and +/// process generation is not authority to reopen, mutate, or recreate a +/// retired journal. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct LiveEventJournalLease { + owner: LiveEventAccountOwner, + operation_token: [u8; PROCESS_TOKEN_BYTES], +} + +/// Opaque producer capability bound to one exact active journal generation. +/// +/// A coordinator may clone this for all events emitted by one admitted run, +/// but must never hand the broader activation lease to a producer. Rollover, +/// reseed, retirement, and account rotation all revoke this capability. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct LiveEventJournalIngressLease { + owner: LiveEventAccountOwner, + operation_token: [u8; PROCESS_TOKEN_BYTES], + journal_id: [u8; JOURNAL_ID_BYTES], +} + +impl fmt::Debug for LiveEventJournalLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveEventJournalLease") + .field("account_generation", &self.owner.account_generation) + .field("operation_token", &"") + .finish_non_exhaustive() + } +} + +impl LiveEventJournalLease { + pub(crate) const fn account_generation(&self) -> u64 { + self.owner.account_generation + } +} + +impl fmt::Debug for LiveEventJournalIngressLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveEventJournalIngressLease") + .field("account_generation", &self.owner.account_generation) + .field("operation_token", &"") + .field("journal_id", &"") + .finish_non_exhaustive() + } +} + +impl LiveEventJournalIngressLease { + /// Stable, non-reversible namespace commitment for producer event IDs + /// within this journal generation. It is read-only and never sufficient + /// to reconstruct either the journal ID or this capability. + pub(crate) fn event_namespace_commitment(&self) -> [u8; 32] { + sha256_parts( + INGRESS_EVENT_NAMESPACE_DOMAIN, + &[ + self.owner.account_key.as_bytes(), + &self.owner.account_generation.to_le_bytes(), + &self.journal_id, + ], + ) + } +} + +/// One-use, process-local proof that a FIFO actor sealed an exact journal head +/// before retirement began. The token cannot be constructed from renderer or +/// wire data and cannot authorize a later activation of the same stable key. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct LiveEventJournalRetirementToken { + owner: LiveEventAccountOwner, + operation_token: [u8; PROCESS_TOKEN_BYTES], + retirement_nonce: [u8; PROCESS_TOKEN_BYTES], + journal_id: String, + head_sequence: u64, +} + +/// Move-only proof that the coordinator sealed its FIFO and paused every +/// subscriber at one exact durable head before beginning a journal-generation +/// rollover. The preselected replacement ID makes an ambiguous atomic replace +/// retryable without ever blessing a different generation. +pub(crate) struct LiveEventJournalRolloverObligation { + owner: LiveEventAccountOwner, + operation_token: [u8; PROCESS_TOKEN_BYTES], + new_operation_token: [u8; PROCESS_TOKEN_BYTES], + rollover_nonce: [u8; PROCESS_TOKEN_BYTES], + journal_id: String, + head_sequence: u64, + checkpoint_commitment: [u8; 32], + new_journal_id: String, +} + +/// Opaque observation of the exact account-file generation that could not be +/// activated. Its random process token prevents a caller from fabricating a +/// reseed request from an owner and path alone. +#[derive(PartialEq, Eq)] +pub(crate) struct LiveEventJournalReseedRequired { + owner: LiveEventAccountOwner, + observed: ObservedJournalGeneration, + observation_token: [u8; PROCESS_TOKEN_BYTES], +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum LiveEventJournalActivationError { + Journal(LiveEventJournalError), + ReseedRequired(LiveEventJournalReseedRequired), +} + +impl From for LiveEventJournalActivationError { + fn from(error: LiveEventJournalError) -> Self { + Self::Journal(error) + } +} + +impl fmt::Debug for LiveEventJournalReseedRequired { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveEventJournalReseedRequired") + .field("account_generation", &self.owner.account_generation) + .field("observed", &self.observed) + .field("observation_token", &"") + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ObservedJournalGeneration { + Missing, + V3 { + file_nonce: [u8; PROCESS_TOKEN_BYTES], + journal_id: [u8; JOURNAL_ID_BYTES], + head_sequence: u64, + committed_end: u64, + digest: [u8; 32], + file_identity: FileIdentity, + }, + LegacyOrCorrupt { + length: u64, + digest: [u8; 32], + file_identity: FileIdentity, + }, +} + +impl LiveEventJournalReseedRequired { + pub(crate) fn owner(&self) -> &LiveEventAccountOwner { + &self.owner + } +} + +/// Two-phase reseed obligation prepared from host-only verified authority. +/// Commit remains impossible until the host has FIFO-sealed publication and +/// calls `mark_reseed_sealed` on this exact non-Clone obligation. +pub(crate) struct LiveEventJournalReseedObligation { + owner: LiveEventAccountOwner, + observed: ObservedJournalGeneration, + observation_token: [u8; PROCESS_TOKEN_BYTES], + authority_nonce: [u8; 32], + durable_head_commitment: [u8; 32], + projection_digest: [u8; 32], + projection_bytes: Box<[u8]>, + new_journal_id: String, + sealed: bool, +} + +impl fmt::Debug for LiveEventJournalReseedObligation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveEventJournalReseedObligation") + .field("account_generation", &self.owner.account_generation) + .field("observed", &self.observed) + .field("authority_nonce", &"") + .field("durable_head_commitment", &"") + .field("projection_digest", &"") + .field("projection_bytes", &"") + .field("new_journal_id", &self.new_journal_id) + .field("sealed", &self.sealed) + .finish_non_exhaustive() + } +} + +pub(crate) struct LiveEventJournalActivation { + pub(crate) lease: LiveEventJournalLease, + pub(crate) cursor: LiveEventCursor, +} + +impl LiveEventJournalActivation { + pub(crate) fn into_parts(self) -> (LiveEventJournalLease, LiveEventCursor) { + (self.lease, self.cursor) + } +} + +impl fmt::Debug for LiveEventJournalActivation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveEventJournalActivation") + .field("lease", &self.lease) + .field("cursor", &self.cursor) + .finish() + } +} + +impl fmt::Debug for LiveEventJournalRetirementToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveEventJournalRetirementToken") + .field("account_generation", &self.owner.account_generation) + .field("journal_id", &self.journal_id) + .field("head_sequence", &self.head_sequence) + .field("operation_token", &"") + .field("new_operation_token", &"") + .field("retirement_nonce", &"") + .finish_non_exhaustive() + } +} + +impl fmt::Debug for LiveEventJournalRolloverObligation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LiveEventJournalRolloverObligation") + .field("account_generation", &self.owner.account_generation) + .field("journal_id", &self.journal_id) + .field("head_sequence", &self.head_sequence) + .field("new_journal_id", &self.new_journal_id) + .field("operation_token", &"") + .field("rollover_nonce", &"") + .field("checkpoint_commitment", &"") + .finish_non_exhaustive() + } +} + +mod journal_authority { + pub(crate) trait Sealed {} +} + +/// Sealed so production callers cannot manufacture a substitute for the +/// opaque lease. The owner-only implementation exists solely to keep this +/// module's white-box tests compact; it is absent from production builds. +pub(crate) trait LiveEventJournalAuthority: journal_authority::Sealed { + fn journal_owner(&self) -> &LiveEventAccountOwner; + fn matches_operation_token(&self, expected: &[u8; PROCESS_TOKEN_BYTES]) -> bool; + #[cfg(test)] + fn allows_test_auto_claim(&self) -> bool { + false + } +} + +impl journal_authority::Sealed for LiveEventJournalLease {} + +impl LiveEventJournalAuthority for LiveEventJournalLease { + fn journal_owner(&self) -> &LiveEventAccountOwner { + &self.owner + } + + fn matches_operation_token(&self, expected: &[u8; PROCESS_TOKEN_BYTES]) -> bool { + constant_time_token_eq(&self.operation_token, expected) + } +} + +#[cfg(test)] +impl journal_authority::Sealed for LiveEventAccountOwner {} + +#[cfg(test)] +impl LiveEventJournalAuthority for LiveEventAccountOwner { + fn journal_owner(&self) -> &LiveEventAccountOwner { + self + } + + fn matches_operation_token(&self, _expected: &[u8; PROCESS_TOKEN_BYTES]) -> bool { + true + } + + fn allows_test_auto_claim(&self) -> bool { + true + } +} + +impl LiveEventAccountOwner { + pub(crate) fn new( + opaque_account_scope: &str, + account_generation: u64, + ) -> Result { + validate_nonempty_bounded( + opaque_account_scope, + MAX_ACCOUNT_SCOPE_BYTES, + LiveEventJournalError::InvalidAccountOwner, + )?; + let digest = Sha256::digest(opaque_account_scope.as_bytes()); + Ok(Self { + account_key: encode_hex(&digest), + account_generation, + }) + } + + pub(crate) const fn account_generation(&self) -> u64 { + self.account_generation + } +} + +/// Cursor for the live replay suffix only. It must never be accepted by a +/// persisted-history pagination endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LiveEventCursor { + journal_id: String, + sequence: u64, +} + +impl LiveEventCursor { + fn new(journal_id: String, sequence: u64) -> Self { + Self { + journal_id, + sequence, + } + } + + pub(crate) fn journal_id(&self) -> &str { + &self.journal_id + } + + pub(crate) const fn sequence(&self) -> u64 { + self.sequence + } + + pub(crate) fn try_from_parts( + journal_id: String, + sequence: u64, + ) -> Result { + let cursor = Self::new(journal_id, sequence); + cursor.validate()?; + Ok(cursor) + } + + /// Return the start cursor for this exact journal generation. This is for + /// reconstructing retained live state after a process restart; it does not + /// weaken the private cursor constructor or cross a history-page boundary. + pub(crate) fn beginning(&self) -> Self { + Self::new(self.journal_id.clone(), 0) + } + + pub(crate) fn validate(&self) -> Result<(), LiveEventJournalError> { + if self.sequence > MAX_CURSOR_SEQUENCE + || self.journal_id.len() != JOURNAL_ID_HEX_BYTES + || !self + .journal_id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(LiveEventJournalError::InvalidCursor); + } + Ok(()) + } +} + +/// One account-owned replay entry. Session and optional run identifiers are +/// stored outside the payload so routing ownership cannot be omitted by a new +/// payload variant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LiveReplayEntry { + cursor: LiveEventCursor, + session_id: String, + run_id: Option, + payload: T, +} + +impl LiveReplayEntry { + pub(crate) fn cursor(&self) -> &LiveEventCursor { + &self.cursor + } + + pub(crate) fn session_id(&self) -> &str { + &self.session_id + } + + pub(crate) fn run_id(&self) -> Option<&str> { + self.run_id.as_deref() + } + + pub(crate) fn payload(&self) -> &T { + &self.payload + } + + pub(crate) fn into_parts(self) -> (LiveEventCursor, String, Option, T) { + (self.cursor, self.session_id, self.run_id, self.payload) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SnapshotRequiredReason { + JournalReplaced, + RetentionGap, + CursorAhead, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SnapshotRequired { + pub(crate) reason: SnapshotRequiredReason, + /// A checkpoint the caller may retain only after rebuilding from paged + /// history. It is not permission to skip the required snapshot. + pub(crate) current_cursor: LiveEventCursor, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum LiveReplayRead { + Events { + entries: Vec>, + next_cursor: LiveEventCursor, + has_more: bool, + }, + SnapshotRequired(SnapshotRequired), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EventAdmission { + New, + Duplicate { + event_cursor: LiveEventCursor, + head_cursor: LiveEventCursor, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AppendOutcome { + Inserted(LiveEventCursor), + Duplicate { + event_cursor: LiveEventCursor, + head_cursor: LiveEventCursor, + }, +} + +impl AppendOutcome { + pub(crate) fn cursor(&self) -> &LiveEventCursor { + match self { + Self::Inserted(cursor) => cursor, + Self::Duplicate { event_cursor, .. } => event_cursor, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LiveProjectionCheckpoint { + pub(crate) through_cursor: LiveEventCursor, + pub(crate) bytes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LiveEventJournalError { + InvalidLimits, + InvalidAccountOwner, + InvalidEventOwner, + InvalidCursor, + InvalidReplayLimit, + PayloadTooLarge, + EventIdConflict, + JournalReplaced, + ReseedRequired, + HeadChanged, + CheckpointRequired, + InvalidCheckpoint, + IdempotencyCapacityExceeded, + SequenceExhausted, + OwnerGenerationMismatch, + JournalRetired, + OwnerTransitionIncomplete, + AlreadyOpen, + UnsupportedPlatform, + StorageCorrupt, + StorageUnavailable, + LockUnavailable, +} + +impl fmt::Display for LiveEventJournalError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidLimits => "live event journal limits are invalid", + Self::InvalidAccountOwner => "live event account owner is invalid", + Self::InvalidEventOwner => "live event session or run owner is invalid", + Self::InvalidCursor => "live event cursor is invalid", + Self::InvalidReplayLimit => "live event replay limit is invalid", + Self::PayloadTooLarge => "live event payload exceeds the journal bound", + Self::EventIdConflict => "live event ID was reused with different content or ownership", + Self::JournalReplaced => "live event journal generation was replaced", + Self::ReseedRequired => { + "live event journal requires an authoritative absolute projection reseed" + } + Self::HeadChanged => "live event journal head changed before the operation", + Self::CheckpointRequired => { + "live event projection checkpoint must advance before retention compaction" + } + Self::InvalidCheckpoint => "live event projection checkpoint is invalid", + Self::IdempotencyCapacityExceeded => { + "live event idempotency capacity requires a FIFO-sealed journal rollover" + } + Self::SequenceExhausted => "live event sequence is exhausted", + Self::OwnerGenerationMismatch => { + "live event account generation no longer owns this journal" + } + Self::JournalRetired => "live event journal lease was retired or replaced", + Self::OwnerTransitionIncomplete => { + "live event account journal rotation requires an authorized clear" + } + Self::AlreadyOpen => "live event journal is already open by another host", + Self::UnsupportedPlatform => { + "durable live event journals are unsupported on this platform" + } + Self::StorageCorrupt => "live event journal storage is corrupt", + Self::StorageUnavailable => "live event journal storage is unavailable", + Self::LockUnavailable => "live event journal lock is unavailable", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for LiveEventJournalError {} + +#[derive(Clone)] +pub(crate) struct LiveEventJournal { + inner: Arc>, +} + +struct LiveEventJournalInner { + root: PathBuf, + limits: LiveEventJournalLimits, + root_guard: JournalRootGuard, + /// Ownership transitions and journal mutation share one synchronization + /// boundary. This prevents an old append from passing its generation check + /// immediately before clear/rotation advances the owner. + state: Mutex>, + /// Test-only crash boundary immediately after an appended record reached + /// durable storage but before the caller received an acknowledgement. + #[cfg(test)] + fail_next_append_after_sync: AtomicBool, + /// Test-only ambiguous atomic-replacement boundary after rename/persist but + /// before the root-directory durability acknowledgement. + #[cfg(test)] + fail_next_replace_at: AtomicU8, + /// Test-only durable-retirement crash boundary. + #[cfg(test)] + fail_next_retirement_at: AtomicU8, +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum ReplaceFailureBoundary { + None = 0, + BeforeFileSync = 1, + AfterFileSync = 2, + AfterPersist = 3, + AfterDirectorySync = 4, +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum RetirementFailureBoundary { + None = 0, + BeforeRename = 1, + AfterRename = 2, + AfterRenameDirectorySync = 3, + AfterUnlink = 4, + AfterFinalDirectorySync = 5, +} + +struct JournalState { + /// This small process-lifetime authority map is deliberately independent + /// from the evictable decoded payload cache. An ambiguous I/O failure must + /// not let a stale handle claim the account again. + owners: HashMap, + accounts: HashMap>, +} + +/// Exact append identity retained only while one storage result is ambiguous. +/// It prevents an unrelated event from using the producer capability to clear +/// the recovery fence before the original operation has been classified. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AmbiguousAppend { + journal_id: [u8; JOURNAL_ID_BYTES], + expected_sequence: u64, + event_id_commitment: [u8; 32], + event_commitment: [u8; 32], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum JournalOwnerState { + ReseedRequired { + generation: u64, + observation_token: [u8; PROCESS_TOKEN_BYTES], + }, + Active { + generation: u64, + operation_token: [u8; PROCESS_TOKEN_BYTES], + /// A prior operation may have reached disk but failed before proving + /// durability. The recovered file must be rewritten and synced before + /// any cursor can be returned or replayed. + needs_resync: bool, + ambiguous_append: Option, + }, + /// Generation authority advanced, but journal rotation did not prove a + /// durable empty replacement. Normal operations fail until an authorized + /// current-generation clear or rotation retry resolves it. + TransitionIncomplete { + generation: u64, + operation_token: [u8; PROCESS_TOKEN_BYTES], + }, + /// The coordinator has sealed publication at the captured head. Ordinary + /// journal operations remain fenced until this exact obligation commits or + /// the process restarts and observes the atomically old-or-new file. + RolloverPending { + generation: u64, + operation_token: [u8; PROCESS_TOKEN_BYTES], + new_operation_token: [u8; PROCESS_TOKEN_BYTES], + rollover_nonce: [u8; PROCESS_TOKEN_BYTES], + journal_id: [u8; JOURNAL_ID_BYTES], + head_sequence: u64, + checkpoint_commitment: [u8; 32], + new_journal_id: [u8; JOURNAL_ID_BYTES], + }, + /// FIFO publication is sealed and every ordinary operation is fenced. + /// `rename_committed` becomes true only after the pending-retirement name + /// has been synced into the journal root. + Retiring { + generation: u64, + operation_token: [u8; PROCESS_TOKEN_BYTES], + retirement_nonce: [u8; PROCESS_TOKEN_BYTES], + journal_id: [u8; JOURNAL_ID_BYTES], + head_sequence: u64, + rename_committed: bool, + }, + Reseeding { + generation: u64, + observation_token: [u8; PROCESS_TOKEN_BYTES], + authority_nonce: [u8; 32], + durable_head_commitment: [u8; 32], + projection_digest: [u8; 32], + new_journal_id: [u8; JOURNAL_ID_BYTES], + sealed: bool, + }, +} + +struct JournalRootLock { + file: File, + identity: FileIdentity, +} + +struct JournalRootGuard { + directory: File, + identity: FileIdentity, + lock: JournalRootLock, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileIdentity { + device: u64, + inode: u64, +} + +impl Drop for JournalRootLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +impl JournalRootGuard { + fn verify(&self, path: &Path) -> Result<(), LiveEventJournalError> { + let path_metadata = + fs::symlink_metadata(path).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + let descriptor_metadata = self + .directory + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !path_metadata.file_type().is_dir() + || path_metadata.file_type().is_symlink() + || file_identity(&path_metadata) != self.identity + || file_identity(&descriptor_metadata) != self.identity + { + return Err(LiveEventJournalError::StorageUnavailable); + } + self.lock.verify(path) + } + + fn sync(&self) -> Result<(), LiveEventJournalError> { + self.directory + .sync_all() + .map_err(|_| LiveEventJournalError::StorageUnavailable) + } + + fn scavenge_owned_temporary_files( + &self, + path: &Path, + max_disk_bytes: u64, + ) -> Result<(), LiveEventJournalError> { + self.verify(path)?; + let mut account_files = 0usize; + let mut account_keys = HashSet::new(); + let mut pending_keys = HashSet::new(); + let mut pending_files = Vec::new(); + let mut removed_file = false; + for entry in fs::read_dir(path).map_err(|_| LiveEventJournalError::StorageUnavailable)? { + let entry = entry.map_err(|_| LiveEventJournalError::StorageUnavailable)?; + let file_name = entry.file_name(); + let file_name = file_name + .to_str() + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if file_name == "host.lock" { + continue; + } + let metadata = entry + .path() + .symlink_metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if file_name.starts_with(TEMP_FILE_PREFIX) { + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(LiveEventJournalError::StorageCorrupt); + } + fs::remove_file(entry.path()) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + removed_file = true; + continue; + } + if is_account_journal_file_name(file_name) && metadata.file_type().is_file() { + let account_file = open_read_no_follow(&entry.path())?; + set_owner_only_file(&account_file)?; + let account_key = account_key_from_journal_file_name(file_name) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if !account_keys.insert(account_key.to_string()) { + return Err(LiveEventJournalError::StorageCorrupt); + } + account_files = account_files + .checked_add(1) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + continue; + } + if let Some((account_key, _nonce)) = parse_retiring_file_name(file_name) { + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || !metadata_owned_by_effective_user(&metadata) + || !pending_keys.insert(account_key.clone()) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let pending_file = open_read_no_follow(&entry.path())?; + set_owner_only_file(&pending_file)?; + let identity = read_v3_disk_identity(&entry.path(), max_disk_bytes)?; + if identity.account_key != account_key || identity.committed_end != metadata.len() { + return Err(LiveEventJournalError::StorageCorrupt); + } + pending_files.push(entry.path()); + continue; + } + return Err(LiveEventJournalError::StorageCorrupt); + } + if account_files + .checked_add(pending_files.len()) + .is_none_or(|count| count > MAX_ACCOUNT_JOURNAL_FILES) + || pending_keys + .iter() + .any(|account_key| account_keys.contains(account_key)) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + // A visible, validated pending name after restart is the durable + // retirement commit. Finish only those bounded names; absence remains + // non-authority and never causes an account journal to be recreated. + for pending in pending_files { + fs::remove_file(pending).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + removed_file = true; + } + if removed_file { + self.sync()?; + } + self.verify(path) + } +} + +impl JournalRootLock { + fn verify(&self, root: &Path) -> Result<(), LiveEventJournalError> { + let path_metadata = fs::symlink_metadata(root.join("host.lock")) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + let descriptor_metadata = self + .file + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !path_metadata.file_type().is_file() + || path_metadata.file_type().is_symlink() + || file_identity(&path_metadata) != self.identity + || file_identity(&descriptor_metadata) != self.identity + { + return Err(LiveEventJournalError::StorageUnavailable); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct JournalHeader { + version: u8, + journal_id: String, + account_key: String, + head_sequence: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + checkpoint: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + event_ids: Vec, + integrity: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JournalHeaderIntegrity<'a> { + version: u8, + journal_id: &'a str, + account_key: &'a str, + head_sequence: u64, + checkpoint: &'a Option, + event_ids: &'a [StoredEventId], +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoredCheckpoint { + schema: String, + through_sequence: u64, + #[serde(with = "base64_bytes")] + bytes: Vec, + commitment: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StoredEventId { + event_id: String, + sequence: u64, + commitment: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde( + rename_all = "camelCase", + deny_unknown_fields, + bound(serialize = "T: Serialize", deserialize = "T: DeserializeOwned") +)] +struct StoredEntry { + sequence: u64, + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + run_id: Option, + payload: T, + commitment: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DiskAnchor { + slot_index: u8, + revision: u64, + file_nonce: [u8; 16], + journal_id: String, + account_key: String, + snapshot_offset: u64, + snapshot_len: u64, + data_start: u64, + committed_end: u64, + snapshot_head_sequence: u64, + committed_head_sequence: u64, + committed_frame_count: u64, + snapshot_hash: [u8; 32], + committed_chain_hash: [u8; 32], +} + +#[derive(Clone)] +struct AccountJournal { + journal_id: String, + account_generation: u64, + head_sequence: u64, + entries: VecDeque>, + total_payload_bytes: usize, + checkpoint: Option, + event_ids: HashMap, + event_id_metadata_bytes: usize, + /// Exact durable head of the v3 account file. New accounts receive this + /// state only after their first atomic replacement commits. + disk_anchor: Option, +} + +impl LiveEventJournal { + pub(crate) fn open( + root: PathBuf, + limits: LiveEventJournalLimits, + ) -> Result { + ensure_supported_platform()?; + let limits = limits.validate()?; + let root = canonical_journal_root_path(&root)?; + ensure_private_directory(&root)?; + let root_guard = open_and_lock_journal_root(&root)?; + root_guard.scavenge_owned_temporary_files( + &root, + limits + .max_disk_bytes() + .ok_or(LiveEventJournalError::InvalidLimits)?, + )?; + Ok(Self { + inner: Arc::new(LiveEventJournalInner { + root, + limits, + root_guard, + state: Mutex::new(JournalState { + owners: HashMap::new(), + accounts: HashMap::new(), + }), + #[cfg(test)] + fail_next_append_after_sync: AtomicBool::new(false), + #[cfg(test)] + fail_next_replace_at: AtomicU8::new(ReplaceFailureBoundary::None as u8), + #[cfg(test)] + fail_next_retirement_at: AtomicU8::new(RetirementFailureBoundary::None as u8), + }), + }) + } + + pub(crate) fn max_replay_entries(&self) -> usize { + self.inner.limits.max_replay_entries + } + + pub(crate) const fn max_checkpoint_bytes(&self) -> usize { + MAX_CHECKPOINT_BYTES + } + + /// Activate one exact owner under the host's verified binding lifecycle + /// lock. This is the only production path that may load or create an + /// account file and mint its process-local operation capability. + pub(crate) fn activate_account( + &self, + owner: &LiveEventAccountOwner, + ) -> Result { + let mut state = self.lock_state()?; + self.verify_storage_root()?; + self.ensure_owner_capacity(&state, owner)?; + self.ensure_account_file_capacity_for(owner)?; + let operation_token = match state.owners.get(&owner.account_key).copied() { + Some(JournalOwnerState::Active { + generation, + operation_token, + .. + }) if generation == owner.account_generation => operation_token, + Some(JournalOwnerState::TransitionIncomplete { generation, .. }) + if generation == owner.account_generation => + { + return Err(LiveEventJournalError::OwnerTransitionIncomplete.into()); + } + Some(JournalOwnerState::RolloverPending { generation, .. }) + if generation == owner.account_generation => + { + return Err(LiveEventJournalError::OwnerTransitionIncomplete.into()); + } + Some(JournalOwnerState::Retiring { generation, .. }) + if generation == owner.account_generation => + { + return Err(LiveEventJournalError::JournalRetired.into()); + } + Some(JournalOwnerState::ReseedRequired { + generation, + observation_token, + }) if generation == owner.account_generation => { + let observed = self.observe_journal_generation(owner)?; + return Err(LiveEventJournalActivationError::ReseedRequired( + LiveEventJournalReseedRequired { + owner: owner.clone(), + observed, + observation_token, + }, + )); + } + Some(JournalOwnerState::Reseeding { generation, .. }) + if generation == owner.account_generation => + { + return Err(LiveEventJournalError::OwnerTransitionIncomplete.into()); + } + Some(_) => return Err(LiveEventJournalError::OwnerGenerationMismatch.into()), + None => { + let operation_token = new_process_token()?; + state.owners.insert( + owner.account_key.clone(), + JournalOwnerState::Active { + generation: owner.account_generation, + operation_token, + needs_resync: false, + ambiguous_append: None, + }, + ); + operation_token + } + }; + let lease = LiveEventJournalLease { + owner: owner.clone(), + operation_token, + }; + match self.prepare_account(&mut state, &lease) { + Ok(_) => Ok(lease), + Err(LiveEventJournalError::StorageCorrupt) => { + state.accounts.remove(&owner.account_key); + let observation_token = new_process_token()?; + state.owners.insert( + owner.account_key.clone(), + JournalOwnerState::ReseedRequired { + generation: owner.account_generation, + observation_token, + }, + ); + let observed = self.observe_journal_generation(owner)?; + Err(LiveEventJournalActivationError::ReseedRequired( + LiveEventJournalReseedRequired { + owner: owner.clone(), + observed, + observation_token, + }, + )) + } + Err(error) => Err(error.into()), + } + } + + /// Return an account checkpoint to capture before loading paged history. + /// Events emitted during that load can then be replayed from this cursor. + pub(crate) fn checkpoint( + &self, + authority: &A, + ) -> Result { + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_account(&mut state, authority)?; + Ok(current_cursor(account)) + } + + /// Mint a producer capability for the activation lease's exact current + /// journal generation. Coordinators call this during explicit producer/run + /// admission and carry the returned lease with every queued event; publish + /// paths never auto-refresh it. + pub(crate) fn bind_ingress( + &self, + lease: &LiveEventJournalLease, + ) -> Result { + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_account(&mut state, lease)?; + Ok(LiveEventJournalIngressLease { + owner: lease.owner.clone(), + operation_token: lease.operation_token, + journal_id: decode_hex_array::(&account.journal_id)?, + }) + } + + pub(crate) fn classify_event( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + payload: &T, + ) -> Result { + expected_head.validate()?; + validate_event_for_append(session_id, run_id, payload, self.inner.limits)?; + let commitment = event_commitment(session_id, run_id, payload)?; + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_ingress_account( + &mut state, + ingress, + expected_head, + payload.live_replay_event_id(), + &commitment, + )?; + ensure_expected_journal(account, expected_head)?; + let admission = + classify_account_event(account, payload.live_replay_event_id(), &commitment)?; + if matches!(admission, EventAdmission::New) { + ensure_expected_sequence(account, expected_head)?; + } + Ok(admission) + } + + /// Test convenience wrapper. Production callers must carry their actor's + /// exact expected head through [`Self::append_outcome`]. + #[cfg(test)] + pub(crate) fn append( + &self, + owner: &LiveEventAccountOwner, + session_id: &str, + run_id: Option<&str>, + payload: T, + ) -> Result { + let lease = self.activate_account(owner).map_err(|error| match error { + LiveEventJournalActivationError::Journal(error) => error, + LiveEventJournalActivationError::ReseedRequired(_) => { + LiveEventJournalError::ReseedRequired + } + })?; + let ingress = self.bind_ingress(&lease)?; + loop { + let expected_head = self.checkpoint(&lease)?; + match self.append_outcome( + &ingress, + &expected_head, + session_id, + run_id, + payload.clone(), + ) { + Err(LiveEventJournalError::HeadChanged) => continue, + result => return result.map(|outcome| outcome.cursor().clone()), + } + } + } + + /// Persist one already-sanitized event before publishing it remotely. + /// The outcome explicitly separates a new durable insert from an exact + /// retry that was already committed, even after its payload was compacted. + pub(crate) fn append_outcome( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + payload: T, + ) -> Result { + expected_head.validate()?; + let payload_bytes = + validate_event_for_append(session_id, run_id, &payload, self.inner.limits)?; + let commitment = event_commitment(session_id, run_id, &payload)?; + let owner = &ingress.owner; + + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_ingress_account( + &mut state, + ingress, + expected_head, + payload.live_replay_event_id(), + &commitment, + )?; + let ambiguous_append = ambiguous_append_identity( + ingress, + expected_head, + payload.live_replay_event_id(), + &commitment, + )?; + ensure_expected_journal(account, expected_head)?; + if let EventAdmission::Duplicate { + event_cursor, + head_cursor, + } = classify_account_event(account, payload.live_replay_event_id(), &commitment)? + { + return Ok(AppendOutcome::Duplicate { + event_cursor, + head_cursor, + }); + } + ensure_expected_sequence(account, expected_head)?; + if account.event_ids.len() >= MAX_IDEMPOTENCY_EVENT_IDS { + return Err(LiveEventJournalError::IdempotencyCapacityExceeded); + } + let sequence = account + .head_sequence + .checked_add(1) + .ok_or(LiveEventJournalError::SequenceExhausted)?; + if sequence > MAX_CURSOR_SEQUENCE { + return Err(LiveEventJournalError::SequenceExhausted); + } + let entry = StoredEntry { + sequence, + session_id: session_id.to_string(), + run_id: run_id.map(str::to_string), + payload, + commitment: commitment.clone(), + }; + let event_id_record = StoredEventId { + event_id: entry.payload.live_replay_event_id().to_string(), + sequence, + commitment, + }; + let event_id_metadata_bytes = encoded_event_id_bytes(&event_id_record)?; + if account + .event_id_metadata_bytes + .checked_add(event_id_metadata_bytes) + .is_none_or(|total| total > MAX_IDEMPOTENCY_METADATA_BYTES) + { + return Err(LiveEventJournalError::IdempotencyCapacityExceeded); + } + + let needs_compaction = account.entries.len() >= self.inner.limits.max_entries + || account + .total_payload_bytes + .checked_add(payload_bytes) + .is_none_or(|total| total > self.inner.limits.max_total_payload_bytes); + + if needs_compaction { + let mut retained = account.entries.clone(); + let mut retained_payload_bytes = account.total_payload_bytes; + retained.push_back(entry.clone()); + retained_payload_bytes = retained_payload_bytes + .checked_add(payload_bytes) + .ok_or(LiveEventJournalError::PayloadTooLarge)?; + let evict_through = account + .checkpoint + .as_ref() + .map_or(0, |checkpoint| checkpoint.through_sequence); + trim_compaction_low_watermark( + &mut retained, + &mut retained_payload_bytes, + self.inner.limits, + evict_through, + )?; + trim_retention( + &mut retained, + &mut retained_payload_bytes, + self.inner.limits, + evict_through, + )?; + if retained.len() > self.inner.limits.max_entries + || retained_payload_bytes > self.inner.limits.max_total_payload_bytes + { + return Err(LiveEventJournalError::CheckpointRequired); + } + let mut replacement = account.clone(); + replacement.entries = retained; + replacement.total_payload_bytes = retained_payload_bytes; + replacement.head_sequence = sequence; + replacement + .event_ids + .insert(event_id_record.event_id.clone(), event_id_record); + replacement.event_id_metadata_bytes += event_id_metadata_bytes; + if let Err(error) = self.replace_account_file(owner, &mut replacement) { + // The atomic replacement may have reached disk before a final + // directory sync failed. Force the next operation to reload + // instead of appending from a possibly stale cached sequence. + mark_ingress_owner_indeterminate(&mut state.owners, ingress, ambiguous_append)?; + state.accounts.remove(&owner.account_key); + return Err(error); + } + *account = replacement; + } else { + if let Err(error) = self.append_record(owner, account, &entry) { + // A failed sync can leave an unanchored frame tail, a newly + // committed anchor, or a torn nonzero anchor slot. Reloading + // accepts only exact anchored EOF; every ambiguous extra byte + // and every torn nonzero slot fails closed. + mark_ingress_owner_indeterminate(&mut state.owners, ingress, ambiguous_append)?; + state.accounts.remove(&owner.account_key); + return Err(error); + } + account.entries.push_back(entry); + account.total_payload_bytes += payload_bytes; + account.head_sequence = sequence; + account + .event_ids + .insert(event_id_record.event_id.clone(), event_id_record); + account.event_id_metadata_bytes += event_id_metadata_bytes; + } + + Ok(AppendOutcome::Inserted(LiveEventCursor::new( + account.journal_id.clone(), + sequence, + ))) + } + + /// Replay the account-wide event suffix after `cursor`. + /// + /// Entries retain exact session/run ownership so the caller can route all + /// background task updates without weakening account isolation. Pagination + /// here is over the short live suffix and is unrelated to history pages. + pub(crate) fn replay_after( + &self, + authority: &A, + cursor: &LiveEventCursor, + limit: usize, + ) -> Result, LiveEventJournalError> { + cursor.validate()?; + if limit == 0 || limit > self.inner.limits.max_replay_entries { + return Err(LiveEventJournalError::InvalidReplayLimit); + } + + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_account(&mut state, authority)?; + let current = current_cursor(account); + if cursor.journal_id != account.journal_id { + return Ok(LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::JournalReplaced, + current_cursor: current, + })); + } + if cursor.sequence > current.sequence { + return Ok(LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::CursorAhead, + current_cursor: current, + })); + } + if account.entries.front().is_some_and(|first| { + cursor + .sequence + .checked_add(1) + .is_none_or(|expected| expected < first.sequence) + }) || (cursor.sequence < current.sequence + && account + .entries + .front() + .is_none_or(|first| first.sequence > cursor.sequence.saturating_add(1))) + { + return Ok(LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::RetentionGap, + current_cursor: current, + })); + } + + let mut entries = Vec::new(); + let mut replay_payload_bytes = 0usize; + let mut has_more = false; + for entry in account + .entries + .iter() + .filter(|entry| entry.sequence > cursor.sequence) + { + let payload_bytes = serialized_payload_bytes(&entry.payload)?; + entry.payload.validate_live_replay_payload()?; + validate_event_id(entry.payload.live_replay_event_id())?; + let exceeds_count = entries.len() == limit; + let exceeds_bytes = replay_payload_bytes + .checked_add(payload_bytes) + .is_none_or(|total| total > self.inner.limits.max_replay_payload_bytes); + if exceeds_count || exceeds_bytes { + has_more = true; + break; + } + replay_payload_bytes += payload_bytes; + entries.push(LiveReplayEntry { + cursor: LiveEventCursor::new(account.journal_id.clone(), entry.sequence), + session_id: entry.session_id.clone(), + run_id: entry.run_id.clone(), + payload: entry.payload.clone(), + }); + } + let next_cursor = entries + .last() + .map(|entry| entry.cursor.clone()) + .unwrap_or_else(|| cursor.clone()); + Ok(LiveReplayRead::Events { + entries, + next_cursor, + has_more, + }) + } + + pub(crate) fn load_checkpoint( + &self, + authority: &A, + ) -> Result, LiveEventJournalError> { + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_account(&mut state, authority)?; + Ok(account + .checkpoint + .as_ref() + .map(|checkpoint| LiveProjectionCheckpoint { + through_cursor: LiveEventCursor::new( + account.journal_id.clone(), + checkpoint.through_sequence, + ), + bytes: checkpoint.bytes.clone(), + })) + } + + /// CAS-install an absolute projection at the exact current durable head. + /// The coordinator prepares and validates the safe DTO before this call. + pub(crate) fn store_checkpoint( + &self, + authority: &A, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result { + expected_head.validate()?; + if bytes.is_empty() || bytes.len() > MAX_CHECKPOINT_BYTES { + return Err(LiveEventJournalError::InvalidCheckpoint); + } + let owner = authority.journal_owner(); + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_account(&mut state, authority)?; + let current = current_cursor(account); + ensure_expected_head(account, expected_head)?; + let mut replacement = account.clone(); + replacement.checkpoint = Some(StoredCheckpoint { + schema: CHECKPOINT_SCHEMA.to_string(), + through_sequence: current.sequence, + bytes: bytes.to_vec(), + commitment: bytes_commitment(bytes), + }); + // Keep the useful recent replay suffix for phone reconnects. Future + // retention compaction may evict only entries now covered by this + // checkpoint; checkpointing itself never creates a client gap. + if let Err(error) = self.replace_account_file(owner, &mut replacement) { + mark_owner_indeterminate(&mut state.owners, authority)?; + state.accounts.remove(&owner.account_key); + return Err(error); + } + *account = replacement; + Ok(current) + } + + /// Fence ordinary operations and prepare one exact generation rollover. + /// + /// The coordinator may call this only after sealing its FIFO and pausing + /// subscribers. Requiring the concrete lease, the exact current head, and + /// the already-stored absolute checkpoint means an arbitrary owner or + /// projection cannot manufacture a destructive rollover. The returned + /// move-only obligation preselects the replacement journal ID for exact + /// retry after an ambiguous atomic-replace acknowledgement. + pub(crate) fn prepare_rollover( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result { + expected_head.validate()?; + if bytes.is_empty() || bytes.len() > MAX_CHECKPOINT_BYTES { + return Err(LiveEventJournalError::InvalidCheckpoint); + } + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_account(&mut state, lease)?; + ensure_expected_head(account, expected_head)?; + let checkpoint_commitment: [u8; 32] = Sha256::digest(bytes).into(); + let Some(previous_checkpoint) = account.checkpoint.as_ref() else { + return Err(LiveEventJournalError::InvalidCheckpoint); + }; + if previous_checkpoint.schema != CHECKPOINT_SCHEMA + || previous_checkpoint.through_sequence != expected_head.sequence + || previous_checkpoint.commitment != encode_hex(&checkpoint_commitment) + || previous_checkpoint.bytes != bytes + { + return Err(LiveEventJournalError::InvalidCheckpoint); + } + let rollover_nonce = new_process_token()?; + let new_operation_token = new_process_token()?; + if constant_time_token_eq(&new_operation_token, &lease.operation_token) { + return Err(LiveEventJournalError::StorageUnavailable); + } + let new_journal_id = new_journal_id()?; + if new_journal_id == account.journal_id { + // A random collision is extraordinarily unlikely, but accepting + // it would silently defeat the generation fence. + return Err(LiveEventJournalError::StorageUnavailable); + } + let journal_id = account.journal_id.clone(); + let journal_id_bytes = decode_hex_array::(&journal_id)?; + let new_journal_id_bytes = decode_hex_array::(&new_journal_id)?; + let head_sequence = account.head_sequence; + state.owners.insert( + lease.owner.account_key.clone(), + JournalOwnerState::RolloverPending { + generation: lease.owner.account_generation, + operation_token: lease.operation_token, + new_operation_token, + rollover_nonce, + journal_id: journal_id_bytes, + head_sequence, + checkpoint_commitment, + new_journal_id: new_journal_id_bytes, + }, + ); + state.accounts.remove(&lease.owner.account_key); + Ok(LiveEventJournalRolloverObligation { + owner: lease.owner.clone(), + operation_token: lease.operation_token, + new_operation_token, + rollover_nonce, + journal_id, + head_sequence, + checkpoint_commitment, + new_journal_id, + }) + } + + /// Atomically start a fresh journal generation from one FIFO-sealed + /// obligation. `bytes` is the exact absolute projection, never a delta. + /// + /// The replacement stores that projection at sequence zero and clears the + /// prior generation's replay suffix and durable event-ID commitments. An + /// append actor carrying its pre-rollover journal ID is therefore fenced + /// with `JournalReplaced`. The obligation is borrowed so an ambiguous + /// storage result can be retried. An exact replay after the internal + /// `RolloverPending -> Active` transition returns the same preselected + /// activation capability without performing another disk replacement. + pub(crate) fn commit_rollover( + &self, + obligation: &LiveEventJournalRolloverObligation, + bytes: &[u8], + ) -> Result { + let supplied_commitment: [u8; 32] = Sha256::digest(bytes).into(); + if bytes.is_empty() + || bytes.len() > MAX_CHECKPOINT_BYTES + || !constant_time_digest_eq(&supplied_commitment, &obligation.checkpoint_commitment) + { + return Err(LiveEventJournalError::InvalidCheckpoint); + } + let expected_journal_id = decode_hex_array::(&obligation.journal_id)?; + let expected_new_journal_id = + decode_hex_array::(&obligation.new_journal_id)?; + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let already_committed = match state.owners.get(&obligation.owner.account_key).copied() { + Some(JournalOwnerState::RolloverPending { + generation, + operation_token, + new_operation_token, + rollover_nonce, + journal_id, + head_sequence, + checkpoint_commitment, + new_journal_id, + }) if generation == obligation.owner.account_generation + && constant_time_token_eq(&operation_token, &obligation.operation_token) + && constant_time_token_eq( + &new_operation_token, + &obligation.new_operation_token, + ) + && constant_time_token_eq(&rollover_nonce, &obligation.rollover_nonce) + && journal_id == expected_journal_id + && head_sequence == obligation.head_sequence + && constant_time_digest_eq( + &checkpoint_commitment, + &obligation.checkpoint_commitment, + ) + && new_journal_id == expected_new_journal_id => + { + false + } + Some(JournalOwnerState::Active { + generation, + operation_token, + .. + }) if generation == obligation.owner.account_generation + && constant_time_token_eq(&operation_token, &obligation.new_operation_token) => + { + true + } + Some(JournalOwnerState::RolloverPending { generation, .. }) + | Some(JournalOwnerState::Active { generation, .. }) + | Some(JournalOwnerState::TransitionIncomplete { generation, .. }) + | Some(JournalOwnerState::Retiring { generation, .. }) + | Some(JournalOwnerState::ReseedRequired { generation, .. }) + | Some(JournalOwnerState::Reseeding { generation, .. }) + if generation == obligation.owner.account_generation => + { + return Err(LiveEventJournalError::JournalReplaced); + } + Some(_) => return Err(LiveEventJournalError::OwnerGenerationMismatch), + None => return Err(LiveEventJournalError::JournalReplaced), + }; + + let current = self.load_account(&obligation.owner)?; + let replacement = + if !already_committed && is_exact_rollover_source(¤t, obligation, bytes) { + let checkpoint = StoredCheckpoint { + schema: CHECKPOINT_SCHEMA.to_string(), + through_sequence: 0, + bytes: bytes.to_vec(), + commitment: encode_hex(&obligation.checkpoint_commitment), + }; + let mut replacement = AccountJournal { + journal_id: obligation.new_journal_id.clone(), + account_generation: obligation.owner.account_generation, + head_sequence: 0, + entries: VecDeque::new(), + total_payload_bytes: 0, + checkpoint: Some(checkpoint), + event_ids: HashMap::new(), + event_id_metadata_bytes: 0, + disk_anchor: None, + }; + if let Err(error) = self.replace_account_file(&obligation.owner, &mut replacement) { + // The same obligation can distinguish the exact old file from + // its preselected exact replacement on a subsequent retry. + state.accounts.remove(&obligation.owner.account_key); + return Err(error); + } + replacement + } else if is_exact_rollover_replacement(¤t, obligation, bytes) { + // A prior rename may be visible even though its directory-sync + // acknowledgement was lost. Re-establish that durability barrier + // before treating the preselected replacement as committed. An + // already-Active exact replay performs no further disk write. + if !already_committed { + self.inner.root_guard.sync()?; + self.verify_storage_root()?; + } + current + } else { + return Err(LiveEventJournalError::JournalReplaced); + }; + let operation_token = obligation.new_operation_token; + let lease = LiveEventJournalLease { + owner: obligation.owner.clone(), + operation_token, + }; + let cursor = current_cursor(&replacement); + state.owners.insert( + obligation.owner.account_key.clone(), + JournalOwnerState::Active { + generation: obligation.owner.account_generation, + operation_token, + needs_resync: false, + ambiguous_append: None, + }, + ); + state + .accounts + .insert(obligation.owner.account_key.clone(), replacement); + Ok(LiveEventJournalActivation { lease, cursor }) + } + + /// White-box recovery helper for corruption and format tests. Production + /// code must use a host-authorized reseed, account-generation rotation, or + /// FIFO-sealed rollover; an active lease alone never exposes this reset. + #[cfg(test)] + fn clear_account( + &self, + authority: &A, + ) -> Result { + let owner = authority.journal_owner(); + let mut state = self.lock_state()?; + self.verify_storage_root()?; + self.ensure_owner_capacity(&state, owner)?; + self.ensure_account_file_capacity_for(owner)?; + let operation_token = authorize_clear(&state.owners, authority)?; + state.owners.insert( + owner.account_key.clone(), + JournalOwnerState::TransitionIncomplete { + generation: owner.account_generation, + operation_token, + }, + ); + state.accounts.remove(&owner.account_key); + let mut replacement = self.empty_account(owner)?; + self.replace_account_file(owner, &mut replacement)?; + let cursor = current_cursor(&replacement); + let operation_token = new_process_token()?; + state.owners.insert( + owner.account_key.clone(), + JournalOwnerState::Active { + generation: owner.account_generation, + operation_token, + needs_resync: false, + ambiguous_append: None, + }, + ); + state + .accounts + .insert(owner.account_key.clone(), replacement); + Ok(cursor) + } + + /// Atomically rotate an account journal across Maple's one-step process + /// generation advance. Requiring both adjacent owners lets a stale handle + /// fail once another clear has already rebound the in-memory journal. + pub(crate) fn rotate_account_generation( + &self, + previous_authority: &A, + current_owner: &LiveEventAccountOwner, + ) -> Result { + let previous_owner = previous_authority.journal_owner(); + if previous_owner.account_key != current_owner.account_key + || previous_owner + .account_generation + .checked_add(1) + .is_none_or(|next| next != current_owner.account_generation) + { + return Err(LiveEventJournalError::InvalidAccountOwner); + } + let mut state = self.lock_state()?; + self.verify_storage_root()?; + self.ensure_owner_capacity(&state, current_owner)?; + self.ensure_account_file_capacity_for(current_owner)?; + let operation_token = authorize_rotation(&state.owners, previous_authority, current_owner)?; + state.owners.insert( + current_owner.account_key.clone(), + JournalOwnerState::TransitionIncomplete { + generation: current_owner.account_generation, + operation_token, + }, + ); + state.accounts.remove(¤t_owner.account_key); + let mut replacement = self.empty_account(current_owner)?; + self.replace_account_file(current_owner, &mut replacement)?; + let cursor = current_cursor(&replacement); + let operation_token = new_process_token()?; + state.owners.insert( + current_owner.account_key.clone(), + JournalOwnerState::Active { + generation: current_owner.account_generation, + operation_token, + needs_resync: false, + ambiguous_append: None, + }, + ); + state + .accounts + .insert(current_owner.account_key.clone(), replacement); + Ok(cursor) + } + + /// Evict decoded payloads for an inactive account while retaining its + /// process-generation fence and durable bounded suffix. + pub(crate) fn unload_account( + &self, + authority: &A, + ) -> Result<(), LiveEventJournalError> { + let owner = authority.journal_owner(); + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let (needs_resync, ambiguous_append) = + authorize_active_owner(&mut state.owners, authority)?; + if needs_resync || ambiguous_append.is_some() { + return Err(LiveEventJournalError::OwnerTransitionIncomplete); + } + state.accounts.remove(&owner.account_key); + Ok(()) + } + + /// Fence an exact active journal after its coordinator FIFO has sealed. + /// No disk mutation happens here; once this returns, every ordinary + /// operation through this or any cloned lease fails with `JournalRetired`. + pub(crate) fn seal_for_retirement( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + ) -> Result { + expected_head.validate()?; + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let account = self.prepare_account(&mut state, lease)?; + ensure_expected_head(account, expected_head)?; + let retirement_nonce = new_process_token()?; + let journal_id = decode_hex_array::(&account.journal_id)?; + let journal_id_string = account.journal_id.clone(); + let head_sequence = account.head_sequence; + let token = LiveEventJournalRetirementToken { + owner: lease.owner.clone(), + operation_token: lease.operation_token, + retirement_nonce, + journal_id: journal_id_string, + head_sequence, + }; + state.owners.insert( + lease.owner.account_key.clone(), + JournalOwnerState::Retiring { + generation: lease.owner.account_generation, + operation_token: lease.operation_token, + retirement_nonce, + journal_id, + head_sequence, + rename_committed: false, + }, + ); + state.accounts.remove(&lease.owner.account_key); + Ok(token) + } + + /// Durably retire a FIFO-sealed account journal. + /// + /// Renaming to a validated pending name and syncing the root is the commit + /// point. Startup finishes only such well-formed pending retirements. The + /// unlink and second directory sync make quota recovery durable before + /// success is acknowledged. Ambiguous errors keep the process-local state + /// fenced and are retryable with this exact opaque token. + pub(crate) fn retire_account( + &self, + token: &LiveEventJournalRetirementToken, + ) -> Result<(), LiveEventJournalError> { + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let expected_journal_id = decode_hex_array::(&token.journal_id)?; + let rename_committed = match state.owners.get(&token.owner.account_key).copied() { + Some(JournalOwnerState::Retiring { + generation, + operation_token, + retirement_nonce, + journal_id, + head_sequence, + rename_committed, + }) if generation == token.owner.account_generation + && constant_time_token_eq(&operation_token, &token.operation_token) + && constant_time_token_eq(&retirement_nonce, &token.retirement_nonce) + && journal_id == expected_journal_id + && head_sequence == token.head_sequence => + { + rename_committed + } + Some(JournalOwnerState::Retiring { generation, .. }) + | Some(JournalOwnerState::Active { generation, .. }) + | Some(JournalOwnerState::TransitionIncomplete { generation, .. }) + | Some(JournalOwnerState::RolloverPending { generation, .. }) + if generation == token.owner.account_generation => + { + return Err(LiveEventJournalError::JournalRetired); + } + Some(_) => return Err(LiveEventJournalError::OwnerGenerationMismatch), + None => return Err(LiveEventJournalError::JournalRetired), + }; + + let source = self.journal_path(&token.owner); + let pending = self.retirement_path(&token.owner, &token.retirement_nonce); + let source_state = account_file_state(&source)?; + let pending_state = account_file_state(&pending)?; + if matches!(source_state, AccountFileState::Present) + && matches!(pending_state, AccountFileState::Present) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + + let max_disk_bytes = self + .inner + .limits + .max_disk_bytes() + .ok_or(LiveEventJournalError::InvalidLimits)?; + if matches!(source_state, AccountFileState::Present) { + if rename_committed { + return Err(LiveEventJournalError::StorageCorrupt); + } + validate_retirement_identity(&source, token, max_disk_bytes)?; + #[cfg(test)] + if self.take_retirement_failure(RetirementFailureBoundary::BeforeRename) { + return Err(LiveEventJournalError::StorageUnavailable); + } + fs::rename(&source, &pending).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + #[cfg(test)] + if self.take_retirement_failure(RetirementFailureBoundary::AfterRename) { + return Err(LiveEventJournalError::StorageUnavailable); + } + } else if matches!(pending_state, AccountFileState::Present) { + validate_retirement_identity(&pending, token, max_disk_bytes)?; + } else if !rename_committed { + return Err(LiveEventJournalError::StorageUnavailable); + } + + let pending_exists = matches!(account_file_state(&pending)?, AccountFileState::Present); + if pending_exists { + self.inner.root_guard.sync()?; + if let Some(JournalOwnerState::Retiring { + rename_committed, .. + }) = state.owners.get_mut(&token.owner.account_key) + { + *rename_committed = true; + } + #[cfg(test)] + if self.take_retirement_failure(RetirementFailureBoundary::AfterRenameDirectorySync) { + return Err(LiveEventJournalError::StorageUnavailable); + } + fs::remove_file(&pending).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + #[cfg(test)] + if self.take_retirement_failure(RetirementFailureBoundary::AfterUnlink) { + return Err(LiveEventJournalError::StorageUnavailable); + } + } + + self.inner.root_guard.sync()?; + #[cfg(test)] + if self.take_retirement_failure(RetirementFailureBoundary::AfterFinalDirectorySync) { + return Err(LiveEventJournalError::StorageUnavailable); + } + self.verify_storage_root()?; + state.accounts.remove(&token.owner.account_key); + state.owners.remove(&token.owner.account_key); + Ok(()) + } + + /// Prepare a one-use reseed obligation only from the host's concrete, + /// non-forgeable durable-history authority and this exact observed broken + /// file generation. This does not mutate disk and does not itself claim + /// that the coordinator FIFO has been sealed. + pub(crate) fn prepare_reseed( + &self, + required: LiveEventJournalReseedRequired, + authority: VerifiedJournalReseedAuthority, + ) -> Result { + let result = self.prepare_reseed_parts( + required, + authority.owner(), + authority.projection_bytes(), + *authority.durable_head_commitment(), + *authority.nonce(), + ); + drop(authority); + result + } + + fn prepare_reseed_parts( + &self, + required: LiveEventJournalReseedRequired, + authority_owner: &LiveEventAccountOwner, + authority_projection: &[u8], + durable_head_commitment: [u8; 32], + authority_nonce: [u8; 32], + ) -> Result { + if authority_owner != &required.owner + || authority_projection.is_empty() + || authority_projection.len() > MAX_CHECKPOINT_BYTES + { + return Err(LiveEventJournalError::InvalidCheckpoint); + } + if durable_head_commitment.iter().all(|byte| *byte == 0) + || authority_nonce.iter().all(|byte| *byte == 0) + { + return Err(LiveEventJournalError::InvalidCheckpoint); + } + let projection_digest = projection_digest(authority_projection); + let new_journal_id = new_journal_id()?; + let new_journal_id_bytes = decode_hex_array::(&new_journal_id)?; + let mut state = self.lock_state()?; + self.verify_storage_root()?; + let observed = self.observe_journal_generation(&required.owner)?; + if observed != required.observed { + return Err(LiveEventJournalError::JournalReplaced); + } + match state.owners.get(&required.owner.account_key).copied() { + Some(JournalOwnerState::ReseedRequired { + generation, + observation_token, + }) if generation == required.owner.account_generation + && constant_time_token_eq(&observation_token, &required.observation_token) => {} + Some(JournalOwnerState::ReseedRequired { generation, .. }) + | Some(JournalOwnerState::Reseeding { generation, .. }) + | Some(JournalOwnerState::Active { generation, .. }) + | Some(JournalOwnerState::TransitionIncomplete { generation, .. }) + | Some(JournalOwnerState::RolloverPending { generation, .. }) + | Some(JournalOwnerState::Retiring { generation, .. }) + if generation == required.owner.account_generation => + { + return Err(LiveEventJournalError::JournalReplaced); + } + Some(_) => return Err(LiveEventJournalError::OwnerGenerationMismatch), + None => return Err(LiveEventJournalError::JournalReplaced), + } + state.owners.insert( + required.owner.account_key.clone(), + JournalOwnerState::Reseeding { + generation: required.owner.account_generation, + observation_token: required.observation_token, + authority_nonce, + durable_head_commitment, + projection_digest, + new_journal_id: new_journal_id_bytes, + sealed: false, + }, + ); + Ok(LiveEventJournalReseedObligation { + owner: required.owner, + observed, + observation_token: required.observation_token, + authority_nonce, + durable_head_commitment, + projection_digest, + projection_bytes: authority_projection.to_vec().into_boxed_slice(), + new_journal_id, + sealed: false, + }) + } + + /// Mark the obligation sealed only after the host has closed the exact + /// coordinator and all live subscribers under its lifecycle lock. + pub(crate) fn mark_reseed_sealed( + &self, + obligation: &mut LiveEventJournalReseedObligation, + ) -> Result<(), LiveEventJournalError> { + let mut state = self.lock_state()?; + self.verify_storage_root()?; + match state.owners.get_mut(&obligation.owner.account_key) { + Some(JournalOwnerState::Reseeding { + generation, + observation_token, + authority_nonce, + durable_head_commitment, + projection_digest, + new_journal_id, + sealed, + }) if *generation == obligation.owner.account_generation + && constant_time_token_eq(observation_token, &obligation.observation_token) + && constant_time_digest_eq(authority_nonce, &obligation.authority_nonce) + && constant_time_digest_eq( + durable_head_commitment, + &obligation.durable_head_commitment, + ) + && constant_time_digest_eq(projection_digest, &obligation.projection_digest) + && *new_journal_id + == decode_hex_array::(&obligation.new_journal_id)? => + { + *sealed = true; + obligation.sealed = true; + Ok(()) + } + Some(_) => Err(LiveEventJournalError::JournalReplaced), + None => Err(LiveEventJournalError::JournalReplaced), + } + } + + /// Atomically replace the exact observed broken generation with a fresh v3 + /// journal carrying the authoritative absolute projection at sequence + /// zero. The replacement's normal file+rename+directory durability barrier + /// runs before a fresh process lease is returned. + pub(crate) fn commit_reseed( + &self, + obligation: &LiveEventJournalReseedObligation, + ) -> Result { + if !obligation.sealed { + return Err(LiveEventJournalError::OwnerTransitionIncomplete); + } + let mut state = self.lock_state()?; + self.verify_storage_root()?; + match state.owners.get(&obligation.owner.account_key).copied() { + Some(JournalOwnerState::Reseeding { + generation, + observation_token, + authority_nonce, + durable_head_commitment, + projection_digest, + new_journal_id, + sealed: true, + }) if generation == obligation.owner.account_generation + && constant_time_token_eq(&observation_token, &obligation.observation_token) + && constant_time_digest_eq(&authority_nonce, &obligation.authority_nonce) + && constant_time_digest_eq( + &durable_head_commitment, + &obligation.durable_head_commitment, + ) + && constant_time_digest_eq(&projection_digest, &obligation.projection_digest) + && new_journal_id + == decode_hex_array::(&obligation.new_journal_id)? => {} + Some(_) => return Err(LiveEventJournalError::JournalReplaced), + None => return Err(LiveEventJournalError::JournalReplaced), + } + if !constant_time_digest_eq( + &projection_digest(&obligation.projection_bytes), + &obligation.projection_digest, + ) { + return Err(LiveEventJournalError::InvalidCheckpoint); + } + let current_observation = self.observe_journal_generation(&obligation.owner)?; + let replacement = if current_observation == obligation.observed { + let checkpoint_bytes = obligation.projection_bytes.to_vec(); + let mut replacement = self.empty_account(&obligation.owner)?; + replacement + .journal_id + .clone_from(&obligation.new_journal_id); + replacement.checkpoint = Some(StoredCheckpoint { + schema: CHECKPOINT_SCHEMA.to_string(), + through_sequence: 0, + commitment: bytes_commitment(&checkpoint_bytes), + bytes: checkpoint_bytes, + }); + if let Err(error) = self.replace_account_file(&obligation.owner, &mut replacement) { + // Preserve the sealed obligation state for an exact retry. The + // next commit re-observes old or new and accepts only this + // preselected journal ID and exact absolute projection. + return Err(error); + } + replacement + } else { + let replacement = self.load_account(&obligation.owner)?; + if !is_exact_reseed_replacement(&replacement, obligation) { + return Err(LiveEventJournalError::JournalReplaced); + } + // The exact replacement can be visible after a lost post-rename + // acknowledgement. Never issue a fresh lease until the directory + // entry has crossed the durability barrier on this retry. + self.inner.root_guard.sync()?; + self.verify_storage_root()?; + replacement + }; + let operation_token = new_process_token()?; + let lease = LiveEventJournalLease { + owner: obligation.owner.clone(), + operation_token, + }; + let cursor = current_cursor(&replacement); + state.owners.insert( + obligation.owner.account_key.clone(), + JournalOwnerState::Active { + generation: obligation.owner.account_generation, + operation_token, + needs_resync: false, + ambiguous_append: None, + }, + ); + state + .accounts + .insert(obligation.owner.account_key.clone(), replacement); + Ok(LiveEventJournalActivation { lease, cursor }) + } + + fn lock_state(&self) -> Result>, LiveEventJournalError> { + self.inner + .state + .lock() + .map_err(|_| LiveEventJournalError::LockUnavailable) + } + + fn verify_storage_root(&self) -> Result<(), LiveEventJournalError> { + self.inner.root_guard.verify(&self.inner.root) + } + + fn observe_journal_generation( + &self, + owner: &LiveEventAccountOwner, + ) -> Result { + self.verify_storage_root()?; + let path = self.journal_path(owner); + if matches!(account_file_state(&path)?, AccountFileState::Missing) { + return Ok(ObservedJournalGeneration::Missing); + } + let file = open_read_no_follow(&path)?; + let metadata = file + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !metadata.file_type().is_file() || !metadata_owned_by_effective_user(&metadata) { + return Err(LiveEventJournalError::StorageCorrupt); + } + let max_read = self + .inner + .limits + .max_disk_bytes() + .ok_or(LiveEventJournalError::InvalidLimits)? + .checked_add(1) + .ok_or(LiveEventJournalError::InvalidLimits)?; + let capacity = usize::try_from(metadata.len().min(max_read)) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + let mut bytes = Vec::with_capacity(capacity); + file.take(max_read) + .read_to_end(&mut bytes) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + self.verify_storage_root()?; + let digest = observed_file_digest(metadata.len(), &bytes); + let identity = file_identity(&metadata); + if metadata.len() <= max_read.saturating_sub(1) && bytes.len() >= DISK_PREFIX_BYTES { + if let Ok(anchor) = select_v3_anchor(&bytes) { + if anchor.committed_end == metadata.len() { + return Ok(ObservedJournalGeneration::V3 { + file_nonce: anchor.file_nonce, + journal_id: decode_hex_array::(&anchor.journal_id)?, + head_sequence: anchor.committed_head_sequence, + committed_end: anchor.committed_end, + digest, + file_identity: identity, + }); + } + } + } + Ok(ObservedJournalGeneration::LegacyOrCorrupt { + length: metadata.len(), + digest, + file_identity: identity, + }) + } + + fn prepare_account<'a, A: LiveEventJournalAuthority>( + &self, + state: &'a mut JournalState, + authority: &A, + ) -> Result<&'a mut AccountJournal, LiveEventJournalError> { + let owner = authority.journal_owner(); + self.verify_storage_root()?; + let (needs_resync, ambiguous_append) = + authorize_active_owner(&mut state.owners, authority)?; + if ambiguous_append.is_some() { + // A generic activation operation cannot decide the outcome of an + // ambiguous producer append. Only an exact retry carrying the + // original ingress capability and event identity may reconcile it. + return Err(LiveEventJournalError::OwnerTransitionIncomplete); + } + if !state.accounts.contains_key(&owner.account_key) { + let account = match account_file_state(&self.journal_path(owner))? { + AccountFileState::Present => self.load_account(owner), + AccountFileState::Missing => self.create_account(owner), + }; + let account = match account { + Ok(account) => account, + Err(error) => { + mark_owner_indeterminate(&mut state.owners, authority)?; + state.accounts.remove(&owner.account_key); + return Err(error); + } + }; + state.accounts.insert(owner.account_key.clone(), account); + } + + { + let account = state + .accounts + .get_mut(&owner.account_key) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + ensure_owner_generation(account, owner)?; + if needs_resync { + if let Err(error) = self.replace_account_file(owner, account) { + state.accounts.remove(&owner.account_key); + return Err(error); + } + } + } + if needs_resync { + mark_owner_resynced(&mut state.owners, authority)?; + } + state + .accounts + .get_mut(&owner.account_key) + .ok_or(LiveEventJournalError::StorageUnavailable) + } + + fn prepare_ingress_account<'a>( + &self, + state: &'a mut JournalState, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + event_id: &str, + event_commitment: &str, + ) -> Result<&'a mut AccountJournal, LiveEventJournalError> { + self.verify_storage_root()?; + // Authenticate the producer generation before consulting any durable + // event-ID record. A stale producer therefore cannot probe or mutate a + // replacement journal, even when it reuses an old event ID. + let (needs_resync, pending_append) = authorize_active_ingress(&state.owners, ingress)?; + let supplied_append = + ambiguous_append_identity(ingress, expected_head, event_id, event_commitment)?; + if needs_resync && pending_append != Some(supplied_append) { + return Err(LiveEventJournalError::OwnerTransitionIncomplete); + } + if !state.accounts.contains_key(&ingress.owner.account_key) { + let account = match account_file_state(&self.journal_path(&ingress.owner))? { + AccountFileState::Present => self.load_account(&ingress.owner), + // An ingress lease is never authority to recreate a missing + // account file. Only activation under the host lifecycle may + // create storage for a newly admitted owner. + AccountFileState::Missing => Err(LiveEventJournalError::JournalReplaced), + }; + let account = match account { + Ok(account) => account, + Err(error) => { + if needs_resync { + // Keep normal operations fenced. A torn or otherwise + // ambiguous durable image advances to the explicit + // host-authorized reseed path. + state.accounts.remove(&ingress.owner.account_key); + if matches!(error, LiveEventJournalError::StorageCorrupt) { + let observation_token = new_process_token()?; + state.owners.insert( + ingress.owner.account_key.clone(), + JournalOwnerState::ReseedRequired { + generation: ingress.owner.account_generation, + observation_token, + }, + ); + } + } + return Err(error); + } + }; + state + .accounts + .insert(ingress.owner.account_key.clone(), account); + } + { + let account = state + .accounts + .get_mut(&ingress.owner.account_key) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + ensure_owner_generation(account, &ingress.owner)?; + let account_journal_id = decode_hex_array::(&account.journal_id)?; + if account_journal_id != ingress.journal_id { + return Err(LiveEventJournalError::JournalReplaced); + } + if needs_resync { + // Rewriting the exact recovered generation re-establishes the + // directory durability barrier for either the old head or the + // exact committed retry. It never adopts an unanchored tail. + if let Err(error) = self.replace_account_file(&ingress.owner, account) { + state.accounts.remove(&ingress.owner.account_key); + return Err(error); + } + } + } + if needs_resync { + mark_ingress_owner_resynced(&mut state.owners, ingress)?; + } + state + .accounts + .get_mut(&ingress.owner.account_key) + .ok_or(LiveEventJournalError::StorageUnavailable) + } + + fn create_account( + &self, + owner: &LiveEventAccountOwner, + ) -> Result, LiveEventJournalError> { + self.ensure_account_file_capacity_for(owner)?; + let mut account = self.empty_account(owner)?; + self.replace_account_file(owner, &mut account)?; + Ok(account) + } + + fn empty_account( + &self, + owner: &LiveEventAccountOwner, + ) -> Result, LiveEventJournalError> { + Ok(AccountJournal { + journal_id: new_journal_id()?, + account_generation: owner.account_generation, + head_sequence: 0, + entries: VecDeque::new(), + total_payload_bytes: 0, + checkpoint: None, + event_ids: HashMap::new(), + event_id_metadata_bytes: 0, + disk_anchor: None, + }) + } + + fn load_account( + &self, + owner: &LiveEventAccountOwner, + ) -> Result, LiveEventJournalError> { + self.verify_storage_root()?; + let path = self.journal_path(owner); + let file = open_read_no_follow(&path)?; + let metadata = file + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !metadata.file_type().is_file() + || metadata.len() + > self + .inner + .limits + .max_disk_bytes() + .ok_or(LiveEventJournalError::InvalidLimits)? + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let mut bytes = Vec::with_capacity( + usize::try_from(metadata.len()).map_err(|_| LiveEventJournalError::StorageCorrupt)?, + ); + let max_read = self + .inner + .limits + .max_disk_bytes() + .ok_or(LiveEventJournalError::InvalidLimits)? + .checked_add(1) + .ok_or(LiveEventJournalError::InvalidLimits)?; + file.take(max_read) + .read_to_end(&mut bytes) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if u64::try_from(bytes.len()).is_ok_and(|length| length == max_read) { + return Err(LiveEventJournalError::StorageCorrupt); + } + if bytes.len() < DISK_PREFIX_BYTES { + return Err(LiveEventJournalError::StorageCorrupt); + } + let actual_length = + u64::try_from(bytes.len()).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + let anchor = select_v3_anchor(&bytes)?; + if anchor.account_key != owner.account_key + || anchor.committed_end != actual_length + || anchor.committed_end + > self + .inner + .limits + .max_disk_bytes() + .ok_or(LiveEventJournalError::InvalidLimits)? + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let snapshot_start = usize::try_from(anchor.snapshot_offset) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + let snapshot_end = usize::try_from(anchor.data_start) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + let committed_end = usize::try_from(anchor.committed_end) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + let snapshot = bytes + .get(snapshot_start..snapshot_end) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if sha256_parts(DISK_SNAPSHOT_HASH_DOMAIN, &[snapshot]) != anchor.snapshot_hash { + return Err(LiveEventJournalError::StorageCorrupt); + } + let header: JournalHeader = + serde_json::from_slice(snapshot).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + validate_header(&header, owner)?; + if header.journal_id != anchor.journal_id + || header.head_sequence != anchor.snapshot_head_sequence + { + return Err(LiveEventJournalError::StorageCorrupt); + } + + let mut entries = VecDeque::new(); + let mut total_payload_bytes = 0usize; + let mut previous_sequence = None; + let mut frame_offset = snapshot_end; + let mut chain_hash = v3_chain_base(&anchor)?; + while frame_offset < committed_end { + if entries.len() == self.inner.limits.max_entries { + return Err(LiveEventJournalError::StorageCorrupt); + } + let (entry, next_offset, frame_hash) = decode_v3_frame( + &bytes, + frame_offset, + committed_end, + &chain_hash, + self.inner + .limits + .max_payload_bytes + .saturating_add(MAX_RECORD_OVERHEAD_BYTES), + )?; + validate_stored_entry(&entry, previous_sequence, self.inner.limits)?; + let payload_bytes = serialized_payload_bytes(&entry.payload)?; + total_payload_bytes = total_payload_bytes + .checked_add(payload_bytes) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if total_payload_bytes > self.inner.limits.max_total_payload_bytes { + return Err(LiveEventJournalError::StorageCorrupt); + } + previous_sequence = Some(entry.sequence); + entries.push_back(entry); + frame_offset = next_offset; + chain_hash = frame_hash; + } + if frame_offset != committed_end + || chain_hash != anchor.committed_chain_hash + || u64::try_from(entries.len()).map_err(|_| LiveEventJournalError::StorageCorrupt)? + != anchor.committed_frame_count + { + return Err(LiveEventJournalError::StorageCorrupt); + } + self.verify_storage_root()?; + + let head_sequence = anchor.committed_head_sequence; + let snapshot_head_sequence = header.head_sequence; + let checkpoint = header.checkpoint; + let checkpoint_sequence = checkpoint + .as_ref() + .map_or(0, |checkpoint| checkpoint.through_sequence); + let first_sequence = entries.front().map(|entry| entry.sequence); + let last_sequence = entries.back().map(|entry| entry.sequence); + let suffix_covers_restart = if checkpoint.is_some() { + head_sequence == checkpoint_sequence + || first_sequence + .is_some_and(|first| first <= checkpoint_sequence.saturating_add(1)) + } else { + head_sequence == 0 || first_sequence == Some(1) + }; + if checkpoint.as_ref().is_some_and(|checkpoint| { + checkpoint.schema != CHECKPOINT_SCHEMA + || checkpoint.bytes.is_empty() + || checkpoint.bytes.len() > MAX_CHECKPOINT_BYTES + || checkpoint.through_sequence > snapshot_head_sequence + || checkpoint.through_sequence > MAX_CURSOR_SEQUENCE + || checkpoint.commitment != bytes_commitment(&checkpoint.bytes) + }) || head_sequence > MAX_CURSOR_SEQUENCE + || head_sequence > MAX_IDEMPOTENCY_EVENT_IDS as u64 + || snapshot_head_sequence > head_sequence + || header.event_ids.len() > MAX_IDEMPOTENCY_EVENT_IDS + || !suffix_covers_restart + || (entries.is_empty() && head_sequence != checkpoint_sequence) + || last_sequence.is_some_and(|last| last != head_sequence) + || entries + .iter() + .find(|entry| entry.sequence > snapshot_head_sequence) + .is_some_and(|entry| entry.sequence != snapshot_head_sequence.saturating_add(1)) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let mut event_ids = HashMap::with_capacity(header.event_ids.len()); + let mut event_sequences = vec![ + false; + usize::try_from(head_sequence) + .map_err(|_| LiveEventJournalError::StorageCorrupt)? + .saturating_add(1) + ]; + let mut event_id_metadata_bytes = 0usize; + for record in header.event_ids { + validate_stored_event_id(&record, snapshot_head_sequence)?; + let sequence = usize::try_from(record.sequence) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + if event_ids.contains_key(&record.event_id) + || event_sequences.get(sequence).copied() != Some(false) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + event_id_metadata_bytes = event_id_metadata_bytes + .checked_add(encoded_event_id_bytes(&record)?) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + event_sequences[sequence] = true; + event_ids.insert(record.event_id.clone(), record); + } + if event_ids.len() + != usize::try_from(snapshot_head_sequence) + .map_err(|_| LiveEventJournalError::StorageCorrupt)? + { + return Err(LiveEventJournalError::StorageCorrupt); + } + for entry in &entries { + let record = stored_event_id(entry)?; + match event_ids.get(&record.event_id) { + Some(existing) + if existing.sequence != record.sequence + || existing.commitment != record.commitment => + { + return Err(LiveEventJournalError::StorageCorrupt); + } + Some(_) => {} + None => { + let sequence = usize::try_from(record.sequence) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + if event_sequences.get(sequence).copied() != Some(false) { + return Err(LiveEventJournalError::StorageCorrupt); + } + event_id_metadata_bytes = event_id_metadata_bytes + .checked_add(encoded_event_id_bytes(&record)?) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + event_sequences[sequence] = true; + event_ids.insert(record.event_id.clone(), record); + } + } + } + if event_ids.len() > MAX_IDEMPOTENCY_EVENT_IDS + || event_ids.len() + != usize::try_from(head_sequence) + .map_err(|_| LiveEventJournalError::StorageCorrupt)? + || event_sequences.iter().skip(1).any(|seen| !seen) + || event_id_metadata_bytes > MAX_IDEMPOTENCY_METADATA_BYTES + || event_ids + .values() + .any(|record| validate_stored_event_id(record, head_sequence).is_err()) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + + let account = AccountJournal { + journal_id: header.journal_id, + // The persisted file proves the account key, not an ephemeral + // process generation. The caller must already hold the current + // verified owner when this process first loads the account. + account_generation: owner.account_generation, + head_sequence, + entries, + total_payload_bytes, + checkpoint, + event_ids, + event_id_metadata_bytes, + disk_anchor: Some(anchor.clone()), + }; + Ok(account) + } + + fn append_record( + &self, + owner: &LiveEventAccountOwner, + account: &mut AccountJournal, + entry: &StoredEntry, + ) -> Result<(), LiveEventJournalError> { + self.verify_storage_root()?; + let current_anchor = account + .disk_anchor + .as_ref() + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if current_anchor.journal_id != account.journal_id + || current_anchor.account_key != owner.account_key + || current_anchor.committed_head_sequence != account.head_sequence + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let (encoded, frame_hash) = encode_v3_frame(entry, ¤t_anchor.committed_chain_hash)?; + let file = open_read_write_no_follow(&self.journal_path(owner))?; + set_owner_only_file(&file)?; + let current_length = file + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + .len(); + if current_length != current_anchor.committed_end { + return Err(LiveEventJournalError::StorageCorrupt); + } + let encoded_length = + u64::try_from(encoded.len()).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if current_anchor + .committed_end + .checked_add(encoded_length) + .is_none_or(|length| length > self.inner.limits.max_disk_bytes().unwrap_or_default()) + { + return Err(LiveEventJournalError::StorageUnavailable); + } + let committed_end = current_anchor + .committed_end + .checked_add(encoded_length) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + write_all_at(&file, &encoded, current_anchor.committed_end)?; + file.sync_data() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + + let mut next_anchor = current_anchor.clone(); + next_anchor.revision = next_anchor + .revision + .checked_add(1) + .ok_or(LiveEventJournalError::SequenceExhausted)?; + next_anchor.slot_index = (next_anchor.revision % DISK_ANCHOR_SLOT_COUNT as u64) as u8; + next_anchor.committed_end = committed_end; + next_anchor.committed_head_sequence = entry.sequence; + next_anchor.committed_frame_count = next_anchor + .committed_frame_count + .checked_add(1) + .ok_or(LiveEventJournalError::SequenceExhausted)?; + next_anchor.committed_chain_hash = frame_hash; + let encoded_anchor = encode_v3_anchor(&next_anchor)?; + let anchor_offset = DISK_SUPERBLOCK_BYTES + .checked_add(usize::from(next_anchor.slot_index) * DISK_ANCHOR_SLOT_BYTES) + .and_then(|offset| u64::try_from(offset).ok()) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + write_all_at(&file, &encoded_anchor, anchor_offset)?; + file.sync_data() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + account.disk_anchor = Some(next_anchor); + #[cfg(test)] + if self + .inner + .fail_next_append_after_sync + .swap(false, Ordering::SeqCst) + { + return Err(LiveEventJournalError::StorageUnavailable); + } + self.verify_storage_root() + } + + #[cfg(test)] + fn fail_next_append_after_sync(&self) { + self.inner + .fail_next_append_after_sync + .store(true, Ordering::SeqCst); + } + + fn replace_account_file( + &self, + owner: &LiveEventAccountOwner, + account: &mut AccountJournal, + ) -> Result<(), LiveEventJournalError> { + self.verify_storage_root()?; + let mut header = JournalHeader { + version: JOURNAL_FORMAT_VERSION, + journal_id: account.journal_id.clone(), + account_key: owner.account_key.clone(), + head_sequence: account.head_sequence, + checkpoint: account.checkpoint.clone(), + event_ids: { + let mut event_ids = account.event_ids.values().cloned().collect::>(); + event_ids.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + event_ids + }, + integrity: String::new(), + }; + header.integrity = journal_header_integrity(&header)?; + let encoded = encode_v3_journal(&header, &account.entries, new_file_nonce()?)?; + if u64::try_from(encoded.bytes.len()).is_err() + || u64::try_from(encoded.bytes.len()) + .is_ok_and(|length| length > self.inner.limits.max_disk_bytes().unwrap_or_default()) + { + return Err(LiveEventJournalError::StorageUnavailable); + } + let mut temporary = tempfile::Builder::new() + .prefix(TEMP_FILE_PREFIX) + .tempfile_in(&self.inner.root) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + set_owner_only_file(temporary.as_file())?; + temporary + .write_all(&encoded.bytes) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + #[cfg(test)] + if self.take_replace_failure(ReplaceFailureBoundary::BeforeFileSync) { + return Err(LiveEventJournalError::StorageUnavailable); + } + temporary + .as_file_mut() + .sync_all() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + #[cfg(test)] + if self.take_replace_failure(ReplaceFailureBoundary::AfterFileSync) { + return Err(LiveEventJournalError::StorageUnavailable); + } + temporary + .persist(self.journal_path(owner)) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + #[cfg(test)] + if self.take_replace_failure(ReplaceFailureBoundary::AfterPersist) { + return Err(LiveEventJournalError::StorageUnavailable); + } + self.inner.root_guard.sync()?; + #[cfg(test)] + if self.take_replace_failure(ReplaceFailureBoundary::AfterDirectorySync) { + return Err(LiveEventJournalError::StorageUnavailable); + } + self.verify_storage_root()?; + account.disk_anchor = Some(encoded.anchor); + Ok(()) + } + + #[cfg(test)] + fn fail_next_replace_at(&self, boundary: ReplaceFailureBoundary) { + self.inner + .fail_next_replace_at + .store(boundary as u8, Ordering::SeqCst); + } + + #[cfg(test)] + fn take_replace_failure(&self, boundary: ReplaceFailureBoundary) -> bool { + self.inner + .fail_next_replace_at + .compare_exchange( + boundary as u8, + ReplaceFailureBoundary::None as u8, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + } + + fn ensure_account_file_capacity_for( + &self, + owner: &LiveEventAccountOwner, + ) -> Result<(), LiveEventJournalError> { + self.verify_storage_root()?; + let mut account_files = 0usize; + for entry in + fs::read_dir(&self.inner.root).map_err(|_| LiveEventJournalError::StorageUnavailable)? + { + let entry = entry.map_err(|_| LiveEventJournalError::StorageUnavailable)?; + let name = entry.file_name(); + let name = name.to_str().ok_or(LiveEventJournalError::StorageCorrupt)?; + if is_account_journal_file_name(name) || parse_retiring_file_name(name).is_some() { + account_files = account_files + .checked_add(1) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + } + } + self.verify_storage_root()?; + let target_exists = matches!( + account_file_state(&self.journal_path(owner))?, + AccountFileState::Present + ); + if account_files >= MAX_ACCOUNT_JOURNAL_FILES && !target_exists { + Err(LiveEventJournalError::StorageUnavailable) + } else { + Ok(()) + } + } + + fn ensure_owner_capacity( + &self, + state: &JournalState, + owner: &LiveEventAccountOwner, + ) -> Result<(), LiveEventJournalError> { + if state.owners.len() >= MAX_ACCOUNT_JOURNAL_FILES + && !state.owners.contains_key(&owner.account_key) + { + Err(LiveEventJournalError::StorageUnavailable) + } else { + Ok(()) + } + } + + fn journal_path(&self, owner: &LiveEventAccountOwner) -> PathBuf { + self.inner + .root + .join(format!("{}.events", owner.account_key)) + } + + fn retirement_path( + &self, + owner: &LiveEventAccountOwner, + retirement_nonce: &[u8; PROCESS_TOKEN_BYTES], + ) -> PathBuf { + self.inner.root.join(format!( + "{RETIRING_FILE_PREFIX}{}-{}", + owner.account_key, + encode_hex(retirement_nonce) + )) + } + + #[cfg(test)] + fn fail_next_retirement_at(&self, boundary: RetirementFailureBoundary) { + self.inner + .fail_next_retirement_at + .store(boundary as u8, Ordering::SeqCst); + } + + #[cfg(test)] + fn take_retirement_failure(&self, boundary: RetirementFailureBoundary) -> bool { + self.inner + .fail_next_retirement_at + .compare_exchange( + boundary as u8, + RetirementFailureBoundary::None as u8, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_ok() + } +} + +fn authorize_active_owner( + owners: &mut HashMap, + authority: &A, +) -> Result<(bool, Option), LiveEventJournalError> { + let owner = authority.journal_owner(); + match owners.get(&owner.account_key).copied() { + Some(JournalOwnerState::Active { + generation, + operation_token, + needs_resync, + ambiguous_append, + }) if generation == owner.account_generation + && authority.matches_operation_token(&operation_token) => + { + Ok((needs_resync, ambiguous_append)) + } + Some(JournalOwnerState::TransitionIncomplete { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::OwnerTransitionIncomplete) + } + Some(JournalOwnerState::RolloverPending { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::OwnerTransitionIncomplete) + } + Some(JournalOwnerState::Retiring { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::JournalRetired) + } + Some(JournalOwnerState::ReseedRequired { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::ReseedRequired) + } + Some(JournalOwnerState::Reseeding { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::OwnerTransitionIncomplete) + } + Some(JournalOwnerState::Active { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::JournalRetired) + } + Some(_) => Err(LiveEventJournalError::OwnerGenerationMismatch), + #[cfg(not(test))] + None => Err(LiveEventJournalError::JournalRetired), + #[cfg(test)] + None => { + if !authority.allows_test_auto_claim() { + return Err(LiveEventJournalError::JournalRetired); + } + if owners.len() >= MAX_ACCOUNT_JOURNAL_FILES { + return Err(LiveEventJournalError::StorageUnavailable); + } + let operation_token = new_process_token()?; + owners.insert( + owner.account_key.clone(), + JournalOwnerState::Active { + generation: owner.account_generation, + operation_token, + needs_resync: false, + ambiguous_append: None, + }, + ); + Ok((false, None)) + } + } +} + +fn authorize_active_ingress( + owners: &HashMap, + ingress: &LiveEventJournalIngressLease, +) -> Result<(bool, Option), LiveEventJournalError> { + match owners.get(&ingress.owner.account_key).copied() { + Some(JournalOwnerState::Active { + generation, + operation_token, + needs_resync, + ambiguous_append, + }) if generation == ingress.owner.account_generation + && constant_time_token_eq(&operation_token, &ingress.operation_token) => + { + Ok((needs_resync, ambiguous_append)) + } + Some(JournalOwnerState::Active { generation, .. }) + if generation == ingress.owner.account_generation => + { + Err(LiveEventJournalError::JournalReplaced) + } + Some(JournalOwnerState::TransitionIncomplete { generation, .. }) + | Some(JournalOwnerState::RolloverPending { generation, .. }) + | Some(JournalOwnerState::Reseeding { generation, .. }) + if generation == ingress.owner.account_generation => + { + Err(LiveEventJournalError::OwnerTransitionIncomplete) + } + Some(JournalOwnerState::Retiring { generation, .. }) + if generation == ingress.owner.account_generation => + { + Err(LiveEventJournalError::JournalRetired) + } + Some(JournalOwnerState::ReseedRequired { generation, .. }) + if generation == ingress.owner.account_generation => + { + Err(LiveEventJournalError::ReseedRequired) + } + Some(_) => Err(LiveEventJournalError::OwnerGenerationMismatch), + None => Err(LiveEventJournalError::JournalReplaced), + } +} + +fn authorize_clear( + owners: &HashMap, + authority: &A, +) -> Result<[u8; PROCESS_TOKEN_BYTES], LiveEventJournalError> { + let owner = authority.journal_owner(); + match owners.get(&owner.account_key).copied() { + Some(JournalOwnerState::Active { + generation, + operation_token, + .. + }) if generation == owner.account_generation + && authority.matches_operation_token(&operation_token) => + { + Ok(operation_token) + } + Some(JournalOwnerState::TransitionIncomplete { + generation, + operation_token, + }) if generation == owner.account_generation + && authority.matches_operation_token(&operation_token) => + { + Ok(operation_token) + } + Some(JournalOwnerState::Retiring { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::JournalRetired) + } + Some(JournalOwnerState::RolloverPending { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::OwnerTransitionIncomplete) + } + Some(JournalOwnerState::Active { generation, .. }) + | Some(JournalOwnerState::TransitionIncomplete { generation, .. }) + if generation == owner.account_generation => + { + Err(LiveEventJournalError::JournalRetired) + } + Some(_) => Err(LiveEventJournalError::OwnerGenerationMismatch), + #[cfg(not(test))] + None => Err(LiveEventJournalError::JournalRetired), + #[cfg(test)] + None if authority.allows_test_auto_claim() => new_process_token(), + #[cfg(test)] + None => Err(LiveEventJournalError::JournalRetired), + } +} + +fn authorize_rotation( + owners: &HashMap, + previous_authority: &A, + current_owner: &LiveEventAccountOwner, +) -> Result<[u8; PROCESS_TOKEN_BYTES], LiveEventJournalError> { + let previous_owner = previous_authority.journal_owner(); + match owners.get(&previous_owner.account_key).copied() { + Some(JournalOwnerState::Active { + generation, + operation_token, + .. + }) if generation == previous_owner.account_generation + && previous_authority.matches_operation_token(&operation_token) => + { + Ok(operation_token) + } + Some(JournalOwnerState::TransitionIncomplete { + generation, + operation_token, + }) if generation == current_owner.account_generation + && previous_authority.matches_operation_token(&operation_token) => + { + // Retrying the exact adjacent generation transition is the only + // normal operation admitted while rotation is incomplete. + Ok(operation_token) + } + Some(JournalOwnerState::Retiring { .. }) => Err(LiveEventJournalError::JournalRetired), + Some(JournalOwnerState::RolloverPending { generation, .. }) + if generation == previous_owner.account_generation => + { + Err(LiveEventJournalError::OwnerTransitionIncomplete) + } + Some(JournalOwnerState::Active { generation, .. }) + if generation == previous_owner.account_generation => + { + Err(LiveEventJournalError::JournalRetired) + } + #[cfg(not(test))] + None => Err(LiveEventJournalError::JournalRetired), + #[cfg(test)] + None if previous_authority.allows_test_auto_claim() => new_process_token(), + #[cfg(test)] + None => Err(LiveEventJournalError::JournalRetired), + _ => Err(LiveEventJournalError::OwnerGenerationMismatch), + } +} + +fn mark_owner_indeterminate( + owners: &mut HashMap, + authority: &A, +) -> Result<(), LiveEventJournalError> { + let owner = authority.journal_owner(); + match owners.get_mut(&owner.account_key) { + Some(JournalOwnerState::Active { + generation, + operation_token, + needs_resync, + ambiguous_append, + }) if *generation == owner.account_generation + && authority.matches_operation_token(operation_token) => + { + *needs_resync = true; + *ambiguous_append = None; + Ok(()) + } + Some(JournalOwnerState::Active { generation, .. }) + if *generation == owner.account_generation => + { + Err(LiveEventJournalError::JournalRetired) + } + _ => Err(LiveEventJournalError::OwnerGenerationMismatch), + } +} + +fn mark_ingress_owner_indeterminate( + owners: &mut HashMap, + ingress: &LiveEventJournalIngressLease, + append: AmbiguousAppend, +) -> Result<(), LiveEventJournalError> { + match owners.get_mut(&ingress.owner.account_key) { + Some(JournalOwnerState::Active { + generation, + operation_token, + needs_resync, + ambiguous_append, + }) if *generation == ingress.owner.account_generation + && constant_time_token_eq(operation_token, &ingress.operation_token) => + { + *needs_resync = true; + *ambiguous_append = Some(append); + Ok(()) + } + Some(JournalOwnerState::Active { generation, .. }) + if *generation == ingress.owner.account_generation => + { + Err(LiveEventJournalError::JournalReplaced) + } + _ => Err(LiveEventJournalError::OwnerGenerationMismatch), + } +} + +fn mark_owner_resynced( + owners: &mut HashMap, + authority: &A, +) -> Result<(), LiveEventJournalError> { + let owner = authority.journal_owner(); + match owners.get_mut(&owner.account_key) { + Some(JournalOwnerState::Active { + generation, + operation_token, + needs_resync, + ambiguous_append, + }) if *generation == owner.account_generation + && authority.matches_operation_token(operation_token) => + { + *needs_resync = false; + *ambiguous_append = None; + Ok(()) + } + Some(JournalOwnerState::Active { generation, .. }) + if *generation == owner.account_generation => + { + Err(LiveEventJournalError::JournalRetired) + } + _ => Err(LiveEventJournalError::OwnerGenerationMismatch), + } +} + +fn mark_ingress_owner_resynced( + owners: &mut HashMap, + ingress: &LiveEventJournalIngressLease, +) -> Result<(), LiveEventJournalError> { + match owners.get_mut(&ingress.owner.account_key) { + Some(JournalOwnerState::Active { + generation, + operation_token, + needs_resync, + ambiguous_append, + }) if *generation == ingress.owner.account_generation + && constant_time_token_eq(operation_token, &ingress.operation_token) => + { + *needs_resync = false; + *ambiguous_append = None; + Ok(()) + } + Some(JournalOwnerState::Active { generation, .. }) + if *generation == ingress.owner.account_generation => + { + Err(LiveEventJournalError::JournalReplaced) + } + _ => Err(LiveEventJournalError::OwnerGenerationMismatch), + } +} + +fn validate_header( + header: &JournalHeader, + owner: &LiveEventAccountOwner, +) -> Result<(), LiveEventJournalError> { + if header.version != JOURNAL_FORMAT_VERSION + || header.account_key != owner.account_key + || header.account_key.len() != ACCOUNT_KEY_HEX_BYTES + || !header.account_key.bytes().all(is_lower_hex) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + LiveEventCursor::new(header.journal_id.clone(), 0) + .validate() + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + if header.integrity.len() != ACCOUNT_KEY_HEX_BYTES + || !header.integrity.bytes().all(is_lower_hex) + || header.integrity != journal_header_integrity(header)? + || header + .event_ids + .windows(2) + .any(|records| records[0].event_id >= records[1].event_id) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok(()) +} + +fn validate_stored_entry( + entry: &StoredEntry, + previous_sequence: Option, + limits: LiveEventJournalLimits, +) -> Result<(), LiveEventJournalError> { + let expected_sequence = previous_sequence.and_then(|previous| previous.checked_add(1)); + let sequence_is_invalid = entry.sequence == 0 + || entry.sequence > MAX_CURSOR_SEQUENCE + || previous_sequence.is_some() && expected_sequence != Some(entry.sequence); + if sequence_is_invalid { + return Err(LiveEventJournalError::StorageCorrupt); + } + validate_event_owner(&entry.session_id, entry.run_id.as_deref()) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + entry + .payload + .validate_live_replay_payload() + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + validate_event_id(entry.payload.live_replay_event_id()) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + if serialized_payload_bytes(&entry.payload)? > limits.max_payload_bytes { + return Err(LiveEventJournalError::StorageCorrupt); + } + if entry.commitment.len() != ACCOUNT_KEY_HEX_BYTES + || !entry.commitment.bytes().all(is_lower_hex) + || entry.commitment + != event_commitment(&entry.session_id, entry.run_id.as_deref(), &entry.payload)? + { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok(()) +} + +fn validate_stored_event_id( + record: &StoredEventId, + head_sequence: u64, +) -> Result<(), LiveEventJournalError> { + validate_event_id(&record.event_id).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + if record.sequence == 0 + || record.sequence > head_sequence + || record.commitment.len() != ACCOUNT_KEY_HEX_BYTES + || !record.commitment.bytes().all(is_lower_hex) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok(()) +} + +fn stored_event_id( + entry: &StoredEntry, +) -> Result { + Ok(StoredEventId { + event_id: entry.payload.live_replay_event_id().to_string(), + sequence: entry.sequence, + commitment: entry.commitment.clone(), + }) +} + +fn encoded_event_id_bytes(record: &StoredEventId) -> Result { + encode_record(record).map(|encoded| encoded.len()) +} + +fn validate_event_for_append( + session_id: &str, + run_id: Option<&str>, + payload: &T, + limits: LiveEventJournalLimits, +) -> Result { + validate_event_owner(session_id, run_id)?; + payload.validate_live_replay_payload()?; + validate_event_id(payload.live_replay_event_id())?; + let payload_bytes = serialized_payload_bytes(payload)?; + if payload_bytes > limits.max_payload_bytes { + return Err(LiveEventJournalError::PayloadTooLarge); + } + Ok(payload_bytes) +} + +fn event_commitment( + session_id: &str, + run_id: Option<&str>, + payload: &T, +) -> Result { + #[derive(Serialize)] + struct Commitment<'a, T> { + session_id: &'a str, + run_id: Option<&'a str>, + payload: &'a T, + } + let encoded = serde_json::to_vec(&Commitment { + session_id, + run_id, + payload, + }) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + Ok(encode_hex(&Sha256::digest(encoded))) +} + +fn ambiguous_append_identity( + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + event_id: &str, + event_commitment: &str, +) -> Result { + let expected_journal_id = decode_hex_array::(&expected_head.journal_id)?; + if expected_journal_id != ingress.journal_id { + return Err(LiveEventJournalError::JournalReplaced); + } + Ok(AmbiguousAppend { + journal_id: ingress.journal_id, + expected_sequence: expected_head.sequence, + event_id_commitment: sha256_parts(AMBIGUOUS_EVENT_ID_DOMAIN, &[event_id.as_bytes()]), + event_commitment: decode_hex_array::<32>(event_commitment)?, + }) +} + +fn bytes_commitment(bytes: &[u8]) -> String { + encode_hex(&Sha256::digest(bytes)) +} + +fn journal_header_integrity(header: &JournalHeader) -> Result { + let encoded = serde_json::to_vec(&JournalHeaderIntegrity { + version: header.version, + journal_id: &header.journal_id, + account_key: &header.account_key, + head_sequence: header.head_sequence, + checkpoint: &header.checkpoint, + event_ids: &header.event_ids, + }) + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + Ok(bytes_commitment(&encoded)) +} + +fn classify_account_event( + account: &AccountJournal, + event_id: &str, + commitment: &str, +) -> Result { + match account.event_ids.get(event_id) { + Some(record) if record.commitment == commitment => Ok(EventAdmission::Duplicate { + event_cursor: LiveEventCursor::new(account.journal_id.clone(), record.sequence), + head_cursor: current_cursor(account), + }), + Some(_) => Err(LiveEventJournalError::EventIdConflict), + None => Ok(EventAdmission::New), + } +} + +fn ensure_expected_head( + account: &AccountJournal, + expected_head: &LiveEventCursor, +) -> Result<(), LiveEventJournalError> { + ensure_expected_journal(account, expected_head)?; + ensure_expected_sequence(account, expected_head) +} + +fn ensure_expected_journal( + account: &AccountJournal, + expected_head: &LiveEventCursor, +) -> Result<(), LiveEventJournalError> { + if expected_head.journal_id == account.journal_id { + Ok(()) + } else { + Err(LiveEventJournalError::JournalReplaced) + } +} + +fn ensure_expected_sequence( + account: &AccountJournal, + expected_head: &LiveEventCursor, +) -> Result<(), LiveEventJournalError> { + if expected_head.sequence == account.head_sequence { + Ok(()) + } else { + Err(LiveEventJournalError::HeadChanged) + } +} + +fn validate_event_owner( + session_id: &str, + run_id: Option<&str>, +) -> Result<(), LiveEventJournalError> { + validate_nonempty_bounded( + session_id, + MAX_EVENT_OWNER_ID_BYTES, + LiveEventJournalError::InvalidEventOwner, + )?; + if let Some(run_id) = run_id { + validate_nonempty_bounded( + run_id, + MAX_EVENT_OWNER_ID_BYTES, + LiveEventJournalError::InvalidEventOwner, + )?; + } + Ok(()) +} + +fn validate_event_id(event_id: &str) -> Result<(), LiveEventJournalError> { + validate_nonempty_bounded( + event_id, + MAX_EVENT_OWNER_ID_BYTES, + LiveEventJournalError::InvalidEventOwner, + ) +} + +fn validate_nonempty_bounded( + value: &str, + max_bytes: usize, + error: LiveEventJournalError, +) -> Result<(), LiveEventJournalError> { + if value.is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) { + Err(error) + } else { + Ok(()) + } +} + +fn ensure_owner_generation( + account: &AccountJournal, + owner: &LiveEventAccountOwner, +) -> Result<(), LiveEventJournalError> { + if account.account_generation == owner.account_generation { + Ok(()) + } else { + Err(LiveEventJournalError::OwnerGenerationMismatch) + } +} + +fn is_exact_reseed_replacement( + account: &AccountJournal, + obligation: &LiveEventJournalReseedObligation, +) -> bool { + account.journal_id == obligation.new_journal_id + && account.head_sequence == 0 + && account.entries.is_empty() + && account.total_payload_bytes == 0 + && account.event_ids.is_empty() + && account.event_id_metadata_bytes == 0 + && account.checkpoint.as_ref().is_some_and(|checkpoint| { + checkpoint.schema == CHECKPOINT_SCHEMA + && checkpoint.through_sequence == 0 + && checkpoint.bytes.as_slice() == obligation.projection_bytes.as_ref() + && checkpoint.commitment == bytes_commitment(&checkpoint.bytes) + && constant_time_digest_eq( + &projection_digest(&checkpoint.bytes), + &obligation.projection_digest, + ) + }) +} + +fn is_exact_rollover_source( + account: &AccountJournal, + obligation: &LiveEventJournalRolloverObligation, + bytes: &[u8], +) -> bool { + account.journal_id == obligation.journal_id + && account.head_sequence == obligation.head_sequence + && account.checkpoint.as_ref().is_some_and(|checkpoint| { + checkpoint.schema == CHECKPOINT_SCHEMA + && checkpoint.through_sequence == obligation.head_sequence + && checkpoint.bytes == bytes + && checkpoint.commitment == encode_hex(&obligation.checkpoint_commitment) + }) +} + +fn is_exact_rollover_replacement( + account: &AccountJournal, + obligation: &LiveEventJournalRolloverObligation, + bytes: &[u8], +) -> bool { + account.journal_id == obligation.new_journal_id + && account.head_sequence == 0 + && account.entries.is_empty() + && account.total_payload_bytes == 0 + && account.event_ids.is_empty() + && account.event_id_metadata_bytes == 0 + && account.checkpoint.as_ref().is_some_and(|checkpoint| { + checkpoint.schema == CHECKPOINT_SCHEMA + && checkpoint.through_sequence == 0 + && checkpoint.bytes == bytes + && checkpoint.commitment == encode_hex(&obligation.checkpoint_commitment) + }) +} + +fn current_cursor(account: &AccountJournal) -> LiveEventCursor { + LiveEventCursor::new(account.journal_id.clone(), account.head_sequence) +} + +fn trim_retention( + entries: &mut VecDeque>, + total_payload_bytes: &mut usize, + limits: LiveEventJournalLimits, + evict_through: u64, +) -> Result<(), LiveEventJournalError> { + while entries.len() > limits.max_entries + || *total_payload_bytes > limits.max_total_payload_bytes + { + if entries + .front() + .is_none_or(|entry| entry.sequence > evict_through) + { + break; + } + let removed = entries + .pop_front() + .ok_or(LiveEventJournalError::StorageCorrupt)?; + let removed_bytes = serialized_payload_bytes(&removed.payload)?; + *total_payload_bytes = total_payload_bytes + .checked_sub(removed_bytes) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + } + Ok(()) +} + +fn trim_compaction_low_watermark( + entries: &mut VecDeque>, + total_payload_bytes: &mut usize, + limits: LiveEventJournalLimits, + evict_through: u64, +) -> Result<(), LiveEventJournalError> { + let entry_low_watermark = if limits.max_entries < 4 { + limits.max_entries + } else { + limits.max_entries.saturating_mul(3) / 4 + }; + let newest_payload_bytes = entries + .back() + .map(|entry| serialized_payload_bytes(&entry.payload)) + .transpose()? + .unwrap_or_default(); + let payload_low_watermark = limits + .max_total_payload_bytes + .saturating_mul(3) + .checked_div(4) + .unwrap_or_default() + .max(newest_payload_bytes); + while entries.len() > 1 + && (entries.len() > entry_low_watermark || *total_payload_bytes > payload_low_watermark) + { + if entries + .front() + .is_none_or(|entry| entry.sequence > evict_through) + { + break; + } + let removed = entries + .pop_front() + .ok_or(LiveEventJournalError::StorageCorrupt)?; + let removed_bytes = serialized_payload_bytes(&removed.payload)?; + *total_payload_bytes = total_payload_bytes + .checked_sub(removed_bytes) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + } + Ok(()) +} + +fn serialized_payload_bytes(payload: &T) -> Result { + serialized_payload(payload).map(|encoded| encoded.len()) +} + +fn serialized_payload(payload: &T) -> Result, LiveEventJournalError> { + serde_json::to_vec(payload).map_err(|_| LiveEventJournalError::StorageCorrupt) +} + +fn encode_record(value: &T) -> Result, LiveEventJournalError> { + let mut encoded = + serde_json::to_vec(value).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + encoded.push(b'\n'); + Ok(encoded) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct V3DiskIdentity { + account_key: String, + journal_id: String, + committed_head_sequence: u64, + committed_end: u64, + anchor: DiskAnchor, +} + +struct EncodedV3Journal { + bytes: Vec, + anchor: DiskAnchor, +} + +fn sha256_parts(domain: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(domain); + for part in parts { + hasher.update(part); + } + let digest = hasher.finalize(); + let mut result = [0u8; 32]; + result.copy_from_slice(&digest); + result +} + +fn observed_file_digest(length: u64, bytes: &[u8]) -> [u8; 32] { + sha256_parts(OBSERVED_FILE_DIGEST_DOMAIN, &[&length.to_le_bytes(), bytes]) +} + +fn projection_digest(bytes: &[u8]) -> [u8; 32] { + sha256_parts(PROJECTION_DIGEST_DOMAIN, &[bytes]) +} + +fn constant_time_digest_eq(left: &[u8; 32], right: &[u8; 32]) -> bool { + left.iter() + .zip(right) + .fold(0u8, |difference, (left, right)| difference | (left ^ right)) + == 0 +} + +fn new_file_nonce() -> Result<[u8; 16], LiveEventJournalError> { + let mut nonce = [0u8; 16]; + fill_random(&mut nonce).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + Ok(nonce) +} + +fn put_u32(target: &mut [u8], offset: usize, value: u32) { + target[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn put_u64(target: &mut [u8], offset: usize, value: u64) { + target[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +fn get_u32(source: &[u8], offset: usize) -> Result { + let bytes = source + .get(offset..offset + 4) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + Ok(u32::from_le_bytes( + bytes + .try_into() + .map_err(|_| LiveEventJournalError::StorageCorrupt)?, + )) +} + +fn get_u64(source: &[u8], offset: usize) -> Result { + let bytes = source + .get(offset..offset + 8) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + Ok(u64::from_le_bytes( + bytes + .try_into() + .map_err(|_| LiveEventJournalError::StorageCorrupt)?, + )) +} + +fn array_at( + source: &[u8], + offset: usize, +) -> Result<[u8; N], LiveEventJournalError> { + source + .get(offset..offset + N) + .ok_or(LiveEventJournalError::StorageCorrupt)? + .try_into() + .map_err(|_| LiveEventJournalError::StorageCorrupt) +} + +fn decode_hex_array(value: &str) -> Result<[u8; N], LiveEventJournalError> { + if value.len() != N * 2 || !value.bytes().all(is_lower_hex) { + return Err(LiveEventJournalError::StorageCorrupt); + } + let mut decoded = [0u8; N]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = decode_hex_nibble(pair[0]).ok_or(LiveEventJournalError::StorageCorrupt)?; + let low = decode_hex_nibble(pair[1]).ok_or(LiveEventJournalError::StorageCorrupt)?; + decoded[index] = (high << 4) | low; + } + Ok(decoded) +} + +fn decode_hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +fn encode_v3_superblock(file_nonce: &[u8; 16]) -> [u8; DISK_SUPERBLOCK_BYTES] { + let mut encoded = [0u8; DISK_SUPERBLOCK_BYTES]; + encoded[0..8].copy_from_slice(DISK_SUPERBLOCK_MAGIC); + put_u32(&mut encoded, 8, DISK_SUPERBLOCK_VERSION); + put_u32(&mut encoded, 12, DISK_SUPERBLOCK_BYTES_U32); + put_u32(&mut encoded, 16, DISK_PREFIX_BYTES_U32); + put_u32(&mut encoded, 20, DISK_ANCHOR_SLOT_BYTES_U32); + encoded[24..40].copy_from_slice(file_nonce); + let checksum = sha256_parts( + DISK_SUPERBLOCK_CHECKSUM_DOMAIN, + &[&encoded[..DISK_SUPERBLOCK_HASHED_BYTES]], + ); + encoded[DISK_SUPERBLOCK_HASHED_BYTES..DISK_SUPERBLOCK_BYTES].copy_from_slice(&checksum); + encoded +} + +fn decode_v3_superblock(bytes: &[u8]) -> Result<[u8; 16], LiveEventJournalError> { + let superblock = bytes + .get(..DISK_SUPERBLOCK_BYTES) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if superblock.get(0..8) != Some(DISK_SUPERBLOCK_MAGIC.as_slice()) + || get_u32(superblock, 8)? != DISK_SUPERBLOCK_VERSION + || get_u32(superblock, 12)? != DISK_SUPERBLOCK_BYTES_U32 + || get_u32(superblock, 16)? != DISK_PREFIX_BYTES_U32 + || get_u32(superblock, 20)? != DISK_ANCHOR_SLOT_BYTES_U32 + || superblock[40..48].iter().any(|byte| *byte != 0) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let expected = sha256_parts( + DISK_SUPERBLOCK_CHECKSUM_DOMAIN, + &[&superblock[..DISK_SUPERBLOCK_HASHED_BYTES]], + ); + if superblock[DISK_SUPERBLOCK_HASHED_BYTES..DISK_SUPERBLOCK_BYTES] != expected { + return Err(LiveEventJournalError::StorageCorrupt); + } + array_at(superblock, 24) +} + +fn encode_v3_anchor( + anchor: &DiskAnchor, +) -> Result<[u8; DISK_ANCHOR_SLOT_BYTES], LiveEventJournalError> { + if usize::from(anchor.slot_index) >= DISK_ANCHOR_SLOT_COUNT + || anchor.slot_index != (anchor.revision % DISK_ANCHOR_SLOT_COUNT as u64) as u8 + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let journal_id = decode_hex_array::(&anchor.journal_id)?; + let account_key = decode_hex_array::<32>(&anchor.account_key)?; + let mut encoded = [0u8; DISK_ANCHOR_SLOT_BYTES]; + encoded[0..8].copy_from_slice(DISK_ANCHOR_MAGIC); + put_u32(&mut encoded, 8, DISK_ANCHOR_VERSION); + encoded[12] = anchor.slot_index; + put_u64(&mut encoded, 16, anchor.revision); + encoded[24..40].copy_from_slice(&anchor.file_nonce); + encoded[40..56].copy_from_slice(&journal_id); + encoded[56..88].copy_from_slice(&account_key); + put_u64(&mut encoded, 88, anchor.snapshot_offset); + put_u64(&mut encoded, 96, anchor.snapshot_len); + put_u64(&mut encoded, 104, anchor.data_start); + put_u64(&mut encoded, 112, anchor.committed_end); + put_u64(&mut encoded, 120, anchor.snapshot_head_sequence); + put_u64(&mut encoded, 128, anchor.committed_head_sequence); + put_u64(&mut encoded, 136, anchor.committed_frame_count); + encoded[144..176].copy_from_slice(&anchor.snapshot_hash); + encoded[176..208].copy_from_slice(&anchor.committed_chain_hash); + let checksum = sha256_parts( + DISK_ANCHOR_CHECKSUM_DOMAIN, + &[&encoded[..DISK_ANCHOR_HASHED_BYTES]], + ); + encoded[DISK_ANCHOR_HASHED_BYTES..DISK_ANCHOR_SLOT_BYTES].copy_from_slice(&checksum); + Ok(encoded) +} + +fn decode_v3_anchor_slot( + bytes: &[u8], + slot_index: u8, + file_nonce: &[u8; 16], +) -> Result, LiveEventJournalError> { + let offset = DISK_SUPERBLOCK_BYTES + .checked_add(usize::from(slot_index) * DISK_ANCHOR_SLOT_BYTES) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + let slot = bytes + .get(offset..offset + DISK_ANCHOR_SLOT_BYTES) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if slot.iter().all(|byte| *byte == 0) { + return Ok(None); + } + let expected = sha256_parts( + DISK_ANCHOR_CHECKSUM_DOMAIN, + &[&slot[..DISK_ANCHOR_HASHED_BYTES]], + ); + if slot[DISK_ANCHOR_HASHED_BYTES..] != expected { + // A nonzero slot proves that an in-place anchor write took effect, but + // a bad checksum cannot distinguish a pre-acknowledgement tear from + // post-acknowledgement damage. Never reinterpret it as an absent slot + // and silently roll back to the other anchor. + return Err(LiveEventJournalError::StorageCorrupt); + } + if slot.get(0..8) != Some(DISK_ANCHOR_MAGIC.as_slice()) + || get_u32(slot, 8)? != DISK_ANCHOR_VERSION + || slot[12] != slot_index + || slot[13..16].iter().any(|byte| *byte != 0) + || slot[208..224].iter().any(|byte| *byte != 0) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let revision = get_u64(slot, 16)?; + if slot_index != (revision % DISK_ANCHOR_SLOT_COUNT as u64) as u8 { + return Err(LiveEventJournalError::StorageCorrupt); + } + let stored_nonce = array_at::<16>(slot, 24)?; + if &stored_nonce != file_nonce { + return Err(LiveEventJournalError::StorageCorrupt); + } + let anchor = DiskAnchor { + slot_index, + revision, + file_nonce: stored_nonce, + journal_id: encode_hex(&array_at::(slot, 40)?), + account_key: encode_hex(&array_at::<32>(slot, 56)?), + snapshot_offset: get_u64(slot, 88)?, + snapshot_len: get_u64(slot, 96)?, + data_start: get_u64(slot, 104)?, + committed_end: get_u64(slot, 112)?, + snapshot_head_sequence: get_u64(slot, 120)?, + committed_head_sequence: get_u64(slot, 128)?, + committed_frame_count: get_u64(slot, 136)?, + snapshot_hash: array_at(slot, 144)?, + committed_chain_hash: array_at(slot, 176)?, + }; + validate_v3_anchor_geometry(&anchor)?; + Ok(Some(anchor)) +} + +fn validate_v3_anchor_geometry(anchor: &DiskAnchor) -> Result<(), LiveEventJournalError> { + let prefix = + u64::try_from(DISK_PREFIX_BYTES).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + let expected_data_start = anchor + .snapshot_offset + .checked_add(anchor.snapshot_len) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if anchor.snapshot_offset != prefix + || anchor.snapshot_len == 0 + || anchor.snapshot_len + > u64::try_from(MAX_HEADER_BYTES).map_err(|_| LiveEventJournalError::StorageCorrupt)? + || anchor.data_start != expected_data_start + || anchor.committed_end < anchor.data_start + || anchor.snapshot_head_sequence > anchor.committed_head_sequence + || anchor.committed_head_sequence > MAX_CURSOR_SEQUENCE + || anchor.committed_head_sequence > MAX_IDEMPOTENCY_EVENT_IDS as u64 + || anchor.committed_frame_count > MAX_CURSOR_SEQUENCE + { + return Err(LiveEventJournalError::StorageCorrupt); + } + LiveEventCursor::new(anchor.journal_id.clone(), anchor.committed_head_sequence) + .validate() + .map_err(|_| LiveEventJournalError::StorageCorrupt)?; + if anchor.account_key.len() != ACCOUNT_KEY_HEX_BYTES + || !anchor.account_key.bytes().all(is_lower_hex) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok(()) +} + +fn select_v3_anchor(bytes: &[u8]) -> Result { + let file_nonce = decode_v3_superblock(bytes)?; + let first = decode_v3_anchor_slot(bytes, 0, &file_nonce)?; + let second = decode_v3_anchor_slot(bytes, 1, &file_nonce)?; + match (first, second) { + (None, None) => Err(LiveEventJournalError::StorageCorrupt), + (Some(anchor), None) if anchor.revision == 0 && anchor.slot_index == 0 => Ok(anchor), + (Some(_), None) | (None, Some(_)) => Err(LiveEventJournalError::StorageCorrupt), + (Some(left), Some(right)) => { + let (older, newer) = if left.revision < right.revision { + (&left, &right) + } else if right.revision < left.revision { + (&right, &left) + } else { + return Err(LiveEventJournalError::StorageCorrupt); + }; + if newer.revision != older.revision.saturating_add(1) + || newer.file_nonce != older.file_nonce + || newer.journal_id != older.journal_id + || newer.account_key != older.account_key + || newer.snapshot_offset != older.snapshot_offset + || newer.snapshot_len != older.snapshot_len + || newer.data_start != older.data_start + || newer.snapshot_head_sequence != older.snapshot_head_sequence + || newer.snapshot_hash != older.snapshot_hash + || newer.committed_head_sequence != older.committed_head_sequence.saturating_add(1) + || newer.committed_frame_count != older.committed_frame_count.saturating_add(1) + || newer.committed_end <= older.committed_end + { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok(newer.clone()) + } + } +} + +/// Payload-independent identity read for startup retirement and ownership +/// reconciliation. It proves the v3 superblock and selected terminal anchor, +/// including that the anchored end exists, without deserializing `T`. Full +/// journal loading must still verify the snapshot and frame hash chain. +fn read_v3_disk_identity( + path: &Path, + max_disk_bytes: u64, +) -> Result { + let mut file = open_read_no_follow(path)?; + let metadata = file + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !metadata.file_type().is_file() + || metadata.len() > max_disk_bytes + || metadata.len() + < u64::try_from(DISK_PREFIX_BYTES).map_err(|_| LiveEventJournalError::StorageCorrupt)? + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let mut prefix = vec![0u8; DISK_PREFIX_BYTES]; + file.read_exact(&mut prefix).map_err(|error| { + if error.kind() == ErrorKind::UnexpectedEof { + LiveEventJournalError::StorageCorrupt + } else { + LiveEventJournalError::StorageUnavailable + } + })?; + let anchor = select_v3_anchor(&prefix)?; + if anchor.committed_end != metadata.len() || anchor.committed_end > max_disk_bytes { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok(V3DiskIdentity { + account_key: anchor.account_key.clone(), + journal_id: anchor.journal_id.clone(), + committed_head_sequence: anchor.committed_head_sequence, + committed_end: anchor.committed_end, + anchor, + }) +} + +fn validate_retirement_identity( + path: &Path, + token: &LiveEventJournalRetirementToken, + max_disk_bytes: u64, +) -> Result<(), LiveEventJournalError> { + let metadata = path + .symlink_metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || !metadata_owned_by_effective_user(&metadata) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let identity = read_v3_disk_identity(path, max_disk_bytes)?; + if identity.account_key != token.owner.account_key + || identity.journal_id != token.journal_id + || identity.committed_head_sequence != token.head_sequence + || identity.committed_end != metadata.len() + { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok(()) +} + +fn v3_chain_base(anchor: &DiskAnchor) -> Result<[u8; 32], LiveEventJournalError> { + let journal_id = decode_hex_array::(&anchor.journal_id)?; + let account_key = decode_hex_array::<32>(&anchor.account_key)?; + Ok(sha256_parts( + DISK_CHAIN_BASE_DOMAIN, + &[ + &anchor.file_nonce, + &journal_id, + &account_key, + &anchor.snapshot_hash, + &anchor.data_start.to_le_bytes(), + ], + )) +} + +fn encode_v3_frame( + entry: &StoredEntry, + previous_hash: &[u8; 32], +) -> Result<(Vec, [u8; 32]), LiveEventJournalError> { + let body = serde_json::to_vec(entry).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + let body_len = u32::try_from(body.len()).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + let mut header = [0u8; DISK_FRAME_HEADER_BYTES]; + header[0..4].copy_from_slice(DISK_FRAME_MAGIC); + header[4] = DISK_FRAME_VERSION; + put_u32(&mut header, 8, body_len); + put_u32(&mut header, 12, !body_len); + put_u64(&mut header, 16, entry.sequence); + header[24..56].copy_from_slice(previous_hash); + let frame_hash = sha256_parts(DISK_FRAME_HASH_DOMAIN, &[&header[..56], &body]); + header[56..88].copy_from_slice(&frame_hash); + let mut encoded = Vec::with_capacity(DISK_FRAME_HEADER_BYTES + body.len()); + encoded.extend_from_slice(&header); + encoded.extend_from_slice(&body); + Ok((encoded, frame_hash)) +} + +fn decode_v3_frame( + bytes: &[u8], + offset: usize, + committed_end: usize, + previous_hash: &[u8; 32], + max_body_bytes: usize, +) -> Result<(StoredEntry, usize, [u8; 32]), LiveEventJournalError> { + let header_end = offset + .checked_add(DISK_FRAME_HEADER_BYTES) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + let header = bytes + .get(offset..header_end) + .filter(|_| header_end <= committed_end) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + if header.get(0..4) != Some(DISK_FRAME_MAGIC.as_slice()) + || header[4] != DISK_FRAME_VERSION + || header[5..8].iter().any(|byte| *byte != 0) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let body_len = get_u32(header, 8)?; + if get_u32(header, 12)? != !body_len + || usize::try_from(body_len).is_err() + || usize::try_from(body_len).is_ok_and(|length| length > max_body_bytes) + { + return Err(LiveEventJournalError::StorageCorrupt); + } + let stored_previous = array_at::<32>(header, 24)?; + if &stored_previous != previous_hash { + return Err(LiveEventJournalError::StorageCorrupt); + } + let body_end = header_end + .checked_add(usize::try_from(body_len).map_err(|_| LiveEventJournalError::StorageCorrupt)?) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + let body = bytes + .get(header_end..body_end) + .filter(|_| body_end <= committed_end) + .ok_or(LiveEventJournalError::StorageCorrupt)?; + let expected_hash = sha256_parts(DISK_FRAME_HASH_DOMAIN, &[&header[..56], body]); + let stored_hash = array_at::<32>(header, 56)?; + if stored_hash != expected_hash { + return Err(LiveEventJournalError::StorageCorrupt); + } + let entry: StoredEntry = + serde_json::from_slice(body).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + if entry.sequence != get_u64(header, 16)? { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok((entry, body_end, stored_hash)) +} + +fn encode_v3_journal( + header: &JournalHeader, + entries: &VecDeque>, + file_nonce: [u8; 16], +) -> Result { + let snapshot = serde_json::to_vec(header).map_err(|_| LiveEventJournalError::StorageCorrupt)?; + if snapshot.is_empty() || snapshot.len() > MAX_HEADER_BYTES { + return Err(LiveEventJournalError::StorageUnavailable); + } + let snapshot_offset = + u64::try_from(DISK_PREFIX_BYTES).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + let snapshot_len = + u64::try_from(snapshot.len()).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + let data_start = snapshot_offset + .checked_add(snapshot_len) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + let snapshot_hash = sha256_parts(DISK_SNAPSHOT_HASH_DOMAIN, &[&snapshot]); + let mut anchor = DiskAnchor { + slot_index: 0, + revision: 0, + file_nonce, + journal_id: header.journal_id.clone(), + account_key: header.account_key.clone(), + snapshot_offset, + snapshot_len, + data_start, + committed_end: data_start, + snapshot_head_sequence: header.head_sequence, + committed_head_sequence: header.head_sequence, + committed_frame_count: 0, + snapshot_hash, + committed_chain_hash: [0u8; 32], + }; + let mut chain_hash = v3_chain_base(&anchor)?; + let mut frames = Vec::new(); + for entry in entries { + let (encoded, frame_hash) = encode_v3_frame(entry, &chain_hash)?; + frames + .len() + .checked_add(encoded.len()) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + frames.extend_from_slice(&encoded); + chain_hash = frame_hash; + } + anchor.committed_frame_count = + u64::try_from(entries.len()).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + anchor.committed_end = data_start + .checked_add( + u64::try_from(frames.len()).map_err(|_| LiveEventJournalError::StorageUnavailable)?, + ) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + anchor.committed_chain_hash = chain_hash; + let superblock = encode_v3_superblock(&file_nonce); + let anchor_slot = encode_v3_anchor(&anchor)?; + let total_len = usize::try_from(anchor.committed_end) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + let mut bytes = Vec::with_capacity(total_len); + bytes.extend_from_slice(&superblock); + bytes.extend_from_slice(&anchor_slot); + bytes.resize(DISK_PREFIX_BYTES, 0); + bytes.extend_from_slice(&snapshot); + bytes.extend_from_slice(&frames); + if bytes.len() != total_len { + return Err(LiveEventJournalError::StorageCorrupt); + } + Ok(EncodedV3Journal { bytes, anchor }) +} + +mod base64_bytes { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub(super) fn serialize(bytes: &[u8], serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let encoded = String::deserialize(deserializer)?; + STANDARD.decode(encoded).map_err(serde::de::Error::custom) + } +} + +fn new_journal_id() -> Result { + let mut bytes = [0u8; JOURNAL_ID_BYTES]; + fill_random(&mut bytes).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + Ok(encode_hex(&bytes)) +} + +fn new_process_token() -> Result<[u8; PROCESS_TOKEN_BYTES], LiveEventJournalError> { + loop { + let mut bytes = [0u8; PROCESS_TOKEN_BYTES]; + fill_random(&mut bytes).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if bytes.iter().any(|byte| *byte != 0) { + return Ok(bytes); + } + } +} + +fn constant_time_token_eq( + left: &[u8; PROCESS_TOKEN_BYTES], + right: &[u8; PROCESS_TOKEN_BYTES], +) -> bool { + left.iter() + .zip(right) + .fold(0u8, |difference, (left, right)| difference | (left ^ right)) + == 0 +} + +fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +fn is_lower_hex(byte: u8) -> bool { + byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AccountFileState { + Present, + Missing, +} + +fn account_file_state(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(AccountFileState::Present), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(AccountFileState::Missing), + Err(_) => Err(LiveEventJournalError::StorageUnavailable), + } +} + +fn is_account_journal_file_name(file_name: &str) -> bool { + account_key_from_journal_file_name(file_name).is_some() +} + +fn account_key_from_journal_file_name(file_name: &str) -> Option<&str> { + file_name.strip_suffix(".events").filter(|account_key| { + account_key.len() == ACCOUNT_KEY_HEX_BYTES && account_key.bytes().all(is_lower_hex) + }) +} + +fn parse_retiring_file_name(file_name: &str) -> Option<(String, [u8; PROCESS_TOKEN_BYTES])> { + let suffix = file_name.strip_prefix(RETIRING_FILE_PREFIX)?; + let separator = suffix.get(ACCOUNT_KEY_HEX_BYTES..ACCOUNT_KEY_HEX_BYTES + 1)?; + if separator != "-" { + return None; + } + let account_key = suffix.get(..ACCOUNT_KEY_HEX_BYTES)?; + let nonce = suffix.get(ACCOUNT_KEY_HEX_BYTES + 1..)?; + if account_key.len() != ACCOUNT_KEY_HEX_BYTES + || !account_key.bytes().all(is_lower_hex) + || nonce.len() != PROCESS_TOKEN_HEX_BYTES + || !nonce.bytes().all(is_lower_hex) + { + return None; + } + let nonce = decode_hex_array::(nonce).ok()?; + if nonce.iter().all(|byte| *byte == 0) { + return None; + } + Some((account_key.to_string(), nonce)) +} + +fn open_read_no_follow(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + options + .open(path) + .map_err(|_| LiveEventJournalError::StorageUnavailable) +} + +fn open_append_no_follow(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + options + .open(path) + .map_err(|_| LiveEventJournalError::StorageUnavailable) +} + +fn open_read_write_no_follow(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + options + .open(path) + .map_err(|_| LiveEventJournalError::StorageUnavailable) +} + +#[cfg(unix)] +fn write_all_at( + file: &File, + mut bytes: &[u8], + mut offset: u64, +) -> Result<(), LiveEventJournalError> { + use std::os::unix::fs::FileExt; + + while !bytes.is_empty() { + match file.write_at(bytes, offset) { + Ok(0) => return Err(LiveEventJournalError::StorageUnavailable), + Ok(written) => { + bytes = &bytes[written..]; + offset = offset + .checked_add( + u64::try_from(written) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?, + ) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + } + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(_) => return Err(LiveEventJournalError::StorageUnavailable), + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn write_all_at(_file: &File, _bytes: &[u8], _offset: u64) -> Result<(), LiveEventJournalError> { + Err(LiveEventJournalError::UnsupportedPlatform) +} + +fn open_and_lock_journal_root(path: &Path) -> Result { + let directory = open_directory_no_follow(path)?; + set_owner_only_directory(&directory)?; + let directory_metadata = directory + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !directory_metadata.file_type().is_dir() { + return Err(LiveEventJournalError::StorageUnavailable); + } + let identity = file_identity(&directory_metadata); + let lock = lock_journal_root(path)?; + let guard = JournalRootGuard { + directory, + identity, + lock, + }; + guard.verify(path)?; + // Persist the lock-file directory entry before construction succeeds. A + // crash may release the advisory lock, but must not make this process + // claim a path whose directory identity was never durably established. + guard.sync()?; + guard.verify(path)?; + Ok(guard) +} + +fn open_directory_no_follow(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW); + } + options + .open(path) + .map_err(|_| LiveEventJournalError::StorageUnavailable) +} + +fn lock_journal_root(path: &Path) -> Result { + let lock_path = path.join("host.lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(lock_path) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + set_owner_only_file(&file)?; + file.try_lock_exclusive() + .map_err(|_| LiveEventJournalError::AlreadyOpen)?; + let metadata = file + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !metadata.file_type().is_file() { + return Err(LiveEventJournalError::StorageUnavailable); + } + Ok(JournalRootLock { + identity: file_identity(&metadata), + file, + }) +} + +fn ensure_private_directory(path: &Path) -> Result<(), LiveEventJournalError> { + ensure_private_directory_with_parent_sync(path, sync_directory_path) +} + +fn canonical_journal_root_path(path: &Path) -> Result { + let file_name = path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + let canonical_parent = + fs::canonicalize(parent).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + verify_private_parent_directory(&canonical_parent)?; + verify_safe_parent_ancestry(&canonical_parent)?; + Ok(canonical_parent.join(file_name)) +} + +fn ensure_private_directory_with_parent_sync( + path: &Path, + sync_parent: impl FnOnce(&Path) -> Result<(), LiveEventJournalError>, +) -> Result<(), LiveEventJournalError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or(LiveEventJournalError::StorageUnavailable)?; + verify_private_parent_directory(parent)?; + match fs::symlink_metadata(path) { + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => { + create_owner_only_directory(path)?; + } + Err(_) => return Err(LiveEventJournalError::StorageUnavailable), + } + let metadata = + fs::symlink_metadata(path).map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + return Err(LiveEventJournalError::StorageUnavailable); + } + // Sync on every open, including after an earlier ambiguous sync failure. + // This re-establishes durability for the root's parent directory entry. + sync_parent(parent) +} + +#[cfg(unix)] +fn verify_private_parent_directory(path: &Path) -> Result<(), LiveEventJournalError> { + use std::os::unix::fs::PermissionsExt; + + let directory = open_directory_no_follow(path)?; + let metadata = directory + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + // The caller must place this root directly under an app-private directory + // owned by Maple's effective user. Cross-principal parent rename authority + // would invalidate the root/lock identity contract. + if !metadata.file_type().is_dir() + || !metadata_owned_by_effective_user(&metadata) + || metadata.permissions().mode() & 0o777 != 0o700 + { + return Err(LiveEventJournalError::StorageUnavailable); + } + #[cfg(target_os = "macos")] + if macos_acl::has_extended_entries(&directory) + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + { + return Err(LiveEventJournalError::StorageUnavailable); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_safe_parent_ancestry(private_parent: &Path) -> Result<(), LiveEventJournalError> { + verify_safe_ancestor_directories(private_parent.ancestors().skip(1)) +} + +#[cfg(unix)] +fn verify_safe_directory_ancestry(directory: &Path) -> Result<(), LiveEventJournalError> { + verify_safe_ancestor_directories(directory.ancestors()) +} + +#[cfg(unix)] +fn verify_safe_ancestor_directories<'a>( + ancestors: impl Iterator, +) -> Result<(), LiveEventJournalError> { + use std::os::unix::fs::PermissionsExt; + + let effective_uid = unsafe { libc::geteuid() }; + for ancestor in ancestors { + let directory = open_directory_no_follow(ancestor)?; + let metadata = directory + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + let mode = metadata.permissions().mode(); + let owned_by_trusted_principal = + matches!(metadata.uid(), 0) || metadata.uid() == effective_uid; + let cross_principal_writable = mode & 0o022 != 0; + let sticky = mode & (libc::S_ISVTX as u32) != 0; + if !metadata.file_type().is_dir() + || !owned_by_trusted_principal + || (cross_principal_writable && !sticky) + { + return Err(LiveEventJournalError::StorageUnavailable); + } + #[cfg(target_os = "macos")] + if macos_acl::has_allow_entries(&directory) + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + { + return Err(LiveEventJournalError::StorageUnavailable); + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn verify_safe_parent_ancestry(_private_parent: &Path) -> Result<(), LiveEventJournalError> { + Err(LiveEventJournalError::UnsupportedPlatform) +} + +#[cfg(not(unix))] +fn verify_safe_directory_ancestry(_directory: &Path) -> Result<(), LiveEventJournalError> { + Err(LiveEventJournalError::UnsupportedPlatform) +} + +#[cfg(unix)] +fn metadata_owned_by_effective_user(metadata: &fs::Metadata) -> bool { + metadata.uid() == unsafe { libc::geteuid() } +} + +#[cfg(not(unix))] +fn metadata_owned_by_effective_user(_metadata: &fs::Metadata) -> bool { + false +} + +#[cfg(not(unix))] +fn verify_private_parent_directory(_path: &Path) -> Result<(), LiveEventJournalError> { + Err(LiveEventJournalError::UnsupportedPlatform) +} + +#[cfg(unix)] +fn create_owner_only_directory(path: &Path) -> Result<(), LiveEventJournalError> { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder + .create(path) + .map_err(|_| LiveEventJournalError::StorageUnavailable) +} + +#[cfg(not(unix))] +fn create_owner_only_directory(_path: &Path) -> Result<(), LiveEventJournalError> { + Err(LiveEventJournalError::UnsupportedPlatform) +} + +#[cfg(unix)] +fn set_owner_only_directory(file: &File) -> Result<(), LiveEventJournalError> { + use std::os::unix::fs::PermissionsExt; + let mut repaired = false; + let metadata = file + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if metadata.permissions().mode() & 0o7777 != 0o700 { + file.set_permissions(fs::Permissions::from_mode(0o700)) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + repaired = true; + } + #[cfg(target_os = "macos")] + if macos_acl::has_extended_entries(file) + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + { + strip_extended_acl(file)?; + repaired = true; + } + if repaired { + file.sync_all() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn set_owner_only_directory(_file: &File) -> Result<(), LiveEventJournalError> { + Err(LiveEventJournalError::UnsupportedPlatform) +} + +#[cfg(unix)] +fn set_owner_only_file(file: &File) -> Result<(), LiveEventJournalError> { + use std::os::unix::fs::PermissionsExt; + let mut repaired = false; + let metadata = file + .metadata() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + if metadata.permissions().mode() & 0o7777 != 0o600 { + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + repaired = true; + } + #[cfg(target_os = "macos")] + if macos_acl::has_extended_entries(file) + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + { + strip_extended_acl(file)?; + repaired = true; + } + if repaired { + file.sync_all() + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn set_owner_only_file(_file: &File) -> Result<(), LiveEventJournalError> { + Err(LiveEventJournalError::UnsupportedPlatform) +} + +fn sync_directory_path(path: &Path) -> Result<(), LiveEventJournalError> { + let directory = open_directory_no_follow(path)?; + directory + .sync_all() + .map_err(|_| LiveEventJournalError::StorageUnavailable) +} + +fn ensure_supported_platform() -> Result<(), LiveEventJournalError> { + if durable_journal_platform_supported(std::env::consts::OS) { + Ok(()) + } else { + Err(LiveEventJournalError::UnsupportedPlatform) + } +} + +fn durable_journal_platform_supported(target_os: &str) -> bool { + matches!(target_os, "macos" | "linux") +} + +#[cfg(unix)] +fn file_identity(metadata: &fs::Metadata) -> FileIdentity { + FileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + } +} + +#[cfg(not(unix))] +fn file_identity(_metadata: &fs::Metadata) -> FileIdentity { + FileIdentity { + device: 0, + inode: 0, + } +} + +#[cfg(target_os = "macos")] +fn strip_extended_acl(file: &File) -> Result<(), LiveEventJournalError> { + macos_acl::strip(file).map_err(|_| LiveEventJournalError::StorageUnavailable) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn strip_extended_acl(_file: &File) -> Result<(), LiveEventJournalError> { + Ok(()) +} + +#[cfg(target_os = "macos")] +mod macos_acl { + use std::ptr; + use std::{ffi::c_void, fs::File, io, os::fd::AsRawFd}; + + type Acl = *mut c_void; + const ACL_TYPE_EXTENDED: i32 = 0x0000_0100; + + unsafe extern "C" { + fn acl_init(count: i32) -> Acl; + fn acl_free(object: *mut c_void) -> i32; + fn acl_set_fd_np(fd: i32, acl: Acl, acl_type: i32) -> i32; + fn acl_get_fd_np(fd: i32, acl_type: i32) -> Acl; + fn acl_get_entry(acl: Acl, entry_id: i32, entry: *mut *mut c_void) -> i32; + fn acl_get_tag_type(entry: *mut c_void, tag_type: *mut i32) -> i32; + } + + pub(super) fn strip(file: &File) -> io::Result<()> { + // SAFETY: `acl_init` returns an owned ACL handle or null. The handle is + // passed only to macOS ACL functions and is freed exactly once below. + let acl = unsafe { acl_init(0) }; + if acl.is_null() { + return Err(io::Error::last_os_error()); + } + // An empty ACL removes all extended entries, including inherited + // entries that chmod alone leaves effective on macOS. + // SAFETY: `file` owns a valid descriptor and `acl` is live here. + let set_result = unsafe { acl_set_fd_np(file.as_raw_fd(), acl, ACL_TYPE_EXTENDED) }; + let set_error = (set_result != 0).then(io::Error::last_os_error); + // SAFETY: `acl` was allocated by `acl_init` and has not been freed. + let free_result = unsafe { acl_free(acl) }; + if let Some(error) = set_error { + return Err(error); + } + if free_result != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + pub(super) fn has_extended_entries(file: &File) -> io::Result { + has_entry_matching(file, |_| true) + } + + pub(super) fn has_allow_entries(file: &File) -> io::Result { + const ACL_EXTENDED_ALLOW: i32 = 1; + has_entry_matching(file, |tag_type| tag_type == ACL_EXTENDED_ALLOW) + } + + fn has_entry_matching(file: &File, predicate: impl Fn(i32) -> bool) -> io::Result { + // SAFETY: the descriptor remains valid for this call. ENOENT is the + // native "no extended ACL exists" result and therefore means empty. + let acl = unsafe { acl_get_fd_np(file.as_raw_fd(), ACL_TYPE_EXTENDED) }; + if acl.is_null() { + let error = io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ENOENT) { + Ok(false) + } else { + Err(error) + }; + } + let mut entry = ptr::null_mut(); + let mut entry_id = 0; + let mut matched = false; + let mut iteration_error = None; + loop { + // SAFETY: `acl` is live and `entry` points to writable storage. + let get_result = unsafe { acl_get_entry(acl, entry_id, &mut entry) }; + if get_result > 0 { + break; + } + if get_result < 0 { + iteration_error = Some(io::Error::last_os_error()); + break; + } + let mut tag_type = 0; + // SAFETY: a successful `acl_get_entry` returned a live entry. + if unsafe { acl_get_tag_type(entry, &mut tag_type) } != 0 { + iteration_error = Some(io::Error::last_os_error()); + break; + } + if predicate(tag_type) { + matched = true; + break; + } + entry_id = -1; + } + // SAFETY: `acl` was returned by `acl_get_fd_np` and is freed once. + let free_result = unsafe { acl_free(acl) }; + if let Some(error) = iteration_error { + return Err(error); + } + if free_result != 0 { + return Err(io::Error::last_os_error()); + } + Ok(matched) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::{collections::BTreeSet, sync::Barrier, thread}; + + // These capabilities are deliberately move-only. This coherence check is + // a compile-time assertion: adding `Clone` makes the marker selection + // ambiguous and fails the test build before a caller can duplicate either + // a corrupt-generation observation or its prepared one-shot obligation. + const _: fn() = || { + struct AmbiguousIfClone; + trait NotCloneMarker { + fn assert_not_clone() {} + } + impl NotCloneMarker<()> for T {} + impl NotCloneMarker for T {} + + let _ = >::assert_not_clone; + let _ = >::assert_not_clone; + let _ = >::assert_not_clone; + let _ = >::assert_not_clone; + }; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct TestPayload { + event_id: String, + kind: String, + value: String, + } + + impl TestPayload { + fn new(value: impl Into) -> Self { + let value = value.into(); + let event_id = format!("event-{}", encode_hex(&Sha256::digest(value.as_bytes()))); + Self { + event_id, + kind: "timeline_item".to_string(), + value, + } + } + } + + impl LiveReplayPayload for TestPayload { + fn live_replay_event_id(&self) -> &str { + &self.event_id + } + + fn validate_live_replay_payload(&self) -> Result<(), LiveEventJournalError> { + if self.kind == "timeline_item" + && self.value.len() <= 1_024 + && self.event_id.len() <= MAX_EVENT_OWNER_ID_BYTES + { + Ok(()) + } else { + Err(LiveEventJournalError::PayloadTooLarge) + } + } + } + + fn limits(max_entries: usize) -> LiveEventJournalLimits { + LiveEventJournalLimits { + max_entries, + max_payload_bytes: 2_048, + max_total_payload_bytes: 8_192, + max_replay_entries: max_entries, + max_replay_payload_bytes: 8_192, + } + } + + fn private_tempdir() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap(); + } + directory + } + + fn owner(scope: &str, generation: u64) -> LiveEventAccountOwner { + LiveEventAccountOwner::new(scope, generation).unwrap() + } + + fn ingress( + journal: &LiveEventJournal, + owner: &LiveEventAccountOwner, + ) -> LiveEventJournalIngressLease { + let lease = journal.activate_account(owner).unwrap(); + journal.bind_ingress(&lease).unwrap() + } + + fn reseed_required( + result: Result, + ) -> LiveEventJournalReseedRequired { + match result { + Err(LiveEventJournalActivationError::ReseedRequired(required)) => required, + result => panic!("expected authoritative reseed requirement, got {result:?}"), + } + } + + fn read_v3_parts( + path: &Path, + ) -> ( + JournalHeader, + VecDeque>, + DiskAnchor, + ) { + let bytes = fs::read(path).unwrap(); + let anchor = select_v3_anchor(&bytes).unwrap(); + let snapshot_start = usize::try_from(anchor.snapshot_offset).unwrap(); + let snapshot_end = usize::try_from(anchor.data_start).unwrap(); + let committed_end = usize::try_from(anchor.committed_end).unwrap(); + let header = serde_json::from_slice(&bytes[snapshot_start..snapshot_end]).unwrap(); + let mut entries = VecDeque::new(); + let mut offset = snapshot_end; + let mut chain_hash = v3_chain_base(&anchor).unwrap(); + while offset < committed_end { + let (entry, next, frame_hash) = decode_v3_frame( + &bytes, + offset, + committed_end, + &chain_hash, + MAX_CHECKPOINT_BYTES, + ) + .unwrap(); + entries.push_back(entry); + offset = next; + chain_hash = frame_hash; + } + assert_eq!(offset, committed_end); + assert_eq!(chain_hash, anchor.committed_chain_hash); + (header, entries, anchor) + } + + fn write_v3_parts( + path: &Path, + header: &JournalHeader, + entries: &VecDeque>, + ) { + write_v3_parts_at_head(path, header, entries, header.head_sequence); + } + + fn write_v3_parts_at_head( + path: &Path, + header: &JournalHeader, + entries: &VecDeque>, + committed_head_sequence: u64, + ) { + let mut encoded = encode_v3_journal(header, entries, new_file_nonce().unwrap()).unwrap(); + encoded.anchor.committed_head_sequence = committed_head_sequence; + let anchor = encode_v3_anchor(&encoded.anchor).unwrap(); + encoded.bytes[DISK_SUPERBLOCK_BYTES..DISK_SUPERBLOCK_BYTES + DISK_ANCHOR_SLOT_BYTES] + .copy_from_slice(&anchor); + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .open(path) + .unwrap(); + file.write_all(&encoded.bytes).unwrap(); + file.sync_all().unwrap(); + } + + fn rewrite_header(path: &Path, mutate: impl FnOnce(&mut JournalHeader)) -> JournalHeader { + let (mut header, entries, _) = read_v3_parts(path); + mutate(&mut header); + header.integrity = journal_header_integrity(&header).unwrap(); + write_v3_parts(path, &header, &entries); + header + } + + fn events(read: LiveReplayRead) -> (Vec>, LiveEventCursor, bool) { + match read { + LiveReplayRead::Events { + entries, + next_cursor, + has_more, + } => (entries, next_cursor, has_more), + LiveReplayRead::SnapshotRequired(snapshot) => { + panic!("unexpected snapshot requirement: {snapshot:?}") + } + } + } + + #[test] + fn append_is_monotonic_and_replay_keeps_session_run_ownership() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let owner = owner("opaque-account-a", 7); + let checkpoint = journal.checkpoint(&owner).unwrap(); + assert_eq!(checkpoint.sequence(), 0); + + let first = journal + .append( + &owner, + "session-a", + Some("run-a"), + TestPayload::new("first"), + ) + .unwrap(); + let second = journal + .append(&owner, "session-b", None, TestPayload::new("second")) + .unwrap(); + assert_eq!((first.sequence(), second.sequence()), (1, 2)); + assert_eq!(first.journal_id(), second.journal_id()); + + let (entries, next, has_more) = + events(journal.replay_after(&owner, &checkpoint, 10).unwrap()); + assert!(!has_more); + assert_eq!(next, second); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].session_id(), "session-a"); + assert_eq!(entries[0].run_id(), Some("run-a")); + assert_eq!(entries[0].payload().value, "first"); + assert_eq!(entries[1].session_id(), "session-b"); + assert_eq!(entries[1].run_id(), None); + } + + #[test] + fn journal_survives_reopen_and_continues_the_sequence() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let first = { + let journal = LiveEventJournal::open(path.clone(), limits(10)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("first")) + .unwrap() + }; + let journal = LiveEventJournal::::open(path, limits(10)).unwrap(); + let second = journal + .append(&owner, "session", None, TestPayload::new("second")) + .unwrap(); + assert_eq!(second.sequence(), 2); + assert_eq!(second.journal_id(), first.journal_id()); + let (entries, _, _) = events(journal.replay_after(&owner, &first, 10).unwrap()); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].payload().value, "second"); + } + + #[test] + fn process_local_generation_is_rebound_after_restart() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let prior_process_owner = owner("opaque-account", 9); + let first = { + let journal = LiveEventJournal::open(path.clone(), limits(10)).unwrap(); + journal + .append( + &prior_process_owner, + "session", + None, + TestPayload::new("first"), + ) + .unwrap() + }; + + let current_process_owner = owner("opaque-account", 0); + let journal = LiveEventJournal::::open(path, limits(10)).unwrap(); + let checkpoint = journal.checkpoint(¤t_process_owner).unwrap(); + assert_eq!(checkpoint, first); + assert_eq!( + journal.checkpoint(&prior_process_owner), + Err(LiveEventJournalError::OwnerGenerationMismatch) + ); + } + + #[test] + fn generation_rotation_resets_sequence_and_rejects_the_old_owner() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let previous = owner("opaque-account", 0); + let old_cursor = journal + .append(&previous, "session", None, TestPayload::new("old")) + .unwrap(); + let current = owner("opaque-account", 1); + let reset = journal + .rotate_account_generation(&previous, ¤t) + .unwrap(); + assert_eq!(reset.sequence(), 0); + assert_ne!(reset.journal_id(), old_cursor.journal_id()); + assert_eq!( + journal.checkpoint(&previous), + Err(LiveEventJournalError::OwnerGenerationMismatch) + ); + assert_eq!( + journal.replay_after(¤t, &old_cursor, 10).unwrap(), + LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::JournalReplaced, + current_cursor: reset, + }) + ); + assert_eq!( + journal + .append(¤t, "session", None, TestPayload::new("new")) + .unwrap() + .sequence(), + 1 + ); + } + + #[test] + fn generation_rotation_can_be_the_accounts_first_journal_operation() { + let root = private_tempdir(); + let journal = + LiveEventJournal::::open(root.path().join("journal"), limits(10)).unwrap(); + let previous = owner("opaque-account", 0); + let current = owner("opaque-account", 1); + let reset = journal + .rotate_account_generation(&previous, ¤t) + .unwrap(); + assert_eq!(reset.sequence(), 0); + assert_eq!(journal.checkpoint(¤t).unwrap(), reset); + assert_eq!( + journal.checkpoint(&previous), + Err(LiveEventJournalError::OwnerGenerationMismatch) + ); + } + + #[test] + fn retention_gap_requires_snapshot_instead_of_silently_skipping() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(2)).unwrap(); + let owner = owner("opaque-account", 0); + let before = journal.checkpoint(&owner).unwrap(); + let first = journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("two")) + .unwrap(); + let projection_head = journal.checkpoint(&owner).unwrap(); + journal + .store_checkpoint(&owner, &projection_head, b"absolute projection") + .unwrap(); + let current = journal + .append(&owner, "session", None, TestPayload::new("three")) + .unwrap(); + + assert_eq!( + journal.replay_after(&owner, &before, 2).unwrap(), + LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::RetentionGap, + current_cursor: current.clone(), + }) + ); + let (entries, next, _) = events(journal.replay_after(&owner, &first, 2).unwrap()); + assert_eq!(entries.len(), 2); + assert_eq!(next, current); + } + + #[test] + fn clearing_an_account_rotates_the_journal_and_invalidates_old_cursor() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let owner = owner("opaque-account", 0); + let old = journal + .append(&owner, "session", None, TestPayload::new("old")) + .unwrap(); + journal.clear_account(&owner).unwrap(); + let current = journal.checkpoint(&owner).unwrap(); + assert_ne!(current.journal_id(), old.journal_id()); + assert_eq!( + journal.replay_after(&owner, &old, 10).unwrap(), + LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::JournalReplaced, + current_cursor: current, + }) + ); + } + + #[test] + fn account_and_generation_ownership_fail_closed() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let account_a = owner("opaque-account-a", 3); + let account_b = owner("opaque-account-b", 3); + let a_checkpoint = journal.checkpoint(&account_a).unwrap(); + let b_checkpoint = journal.checkpoint(&account_b).unwrap(); + journal + .append(&account_a, "session-a", None, TestPayload::new("a")) + .unwrap(); + journal + .append(&account_b, "session-b", None, TestPayload::new("b")) + .unwrap(); + let (a_entries, _, _) = + events(journal.replay_after(&account_a, &a_checkpoint, 10).unwrap()); + let (b_entries, _, _) = + events(journal.replay_after(&account_b, &b_checkpoint, 10).unwrap()); + assert_eq!(a_entries[0].payload().value, "a"); + assert_eq!(b_entries[0].payload().value, "b"); + + let stale_generation = owner("opaque-account-a", 2); + let newer_generation = owner("opaque-account-a", 4); + assert_eq!( + journal.checkpoint(&stale_generation), + Err(LiveEventJournalError::OwnerGenerationMismatch) + ); + assert_eq!( + journal.append( + &newer_generation, + "session-a", + None, + TestPayload::new("new") + ), + Err(LiveEventJournalError::OwnerGenerationMismatch) + ); + + journal.unload_account(&account_a).unwrap(); + assert_eq!( + journal.checkpoint(&stale_generation), + Err(LiveEventJournalError::OwnerGenerationMismatch) + ); + } + + #[test] + fn stable_event_id_makes_append_retry_idempotent_and_conflicts_fail_closed() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let owner = owner("opaque-account", 0); + let payload = TestPayload::new("same event"); + let first = journal + .append(&owner, "session", Some("run"), payload.clone()) + .unwrap(); + let retried = journal + .append(&owner, "session", Some("run"), payload.clone()) + .unwrap(); + assert_eq!(retried, first); + let (entries, _, _) = events( + journal + .replay_after( + &owner, + &LiveEventCursor::new(first.journal_id().to_string(), 0), + 10, + ) + .unwrap(), + ); + assert_eq!(entries.len(), 1); + + let mut conflicting = TestPayload::new("different event"); + conflicting.event_id = payload.event_id.clone(); + assert_eq!( + journal.append(&owner, "session", Some("run"), conflicting), + Err(LiveEventJournalError::EventIdConflict) + ); + assert_eq!( + journal.append(&owner, "other-session", Some("run"), payload), + Err(LiveEventJournalError::EventIdConflict) + ); + } + + #[test] + fn expected_head_fences_classification_append_and_checkpoint() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let owner = owner("opaque-account", 0); + let stale = journal.checkpoint(&owner).unwrap(); + let first_payload = TestPayload::new("first"); + let first = match journal + .append_outcome( + &ingress(&journal, &owner), + &stale, + "session", + None, + first_payload.clone(), + ) + .unwrap() + { + AppendOutcome::Inserted(cursor) => cursor, + outcome => panic!("unexpected append outcome: {outcome:?}"), + }; + assert_eq!( + journal.classify_event( + &ingress(&journal, &owner), + &stale, + "session", + None, + &first_payload + ), + Ok(EventAdmission::Duplicate { + event_cursor: first.clone(), + head_cursor: first.clone(), + }) + ); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &stale, + "session", + None, + first_payload.clone(), + ), + Ok(AppendOutcome::Duplicate { + event_cursor: first.clone(), + head_cursor: first.clone(), + }) + ); + assert_eq!( + journal.classify_event( + &ingress(&journal, &owner), + &stale, + "session", + None, + &TestPayload::new("unrelated") + ), + Err(LiveEventJournalError::HeadChanged) + ); + assert_eq!( + journal.store_checkpoint(&owner, &stale, b"stale projection"), + Err(LiveEventJournalError::HeadChanged) + ); + assert_eq!( + journal.classify_event( + &ingress(&journal, &owner), + &first, + "session", + None, + &first_payload + ), + Ok(EventAdmission::Duplicate { + event_cursor: first.clone(), + head_cursor: first, + }) + ); + } + + #[test] + fn post_sync_error_reopens_as_an_exact_duplicate_not_a_poisoning_head_change() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let payload = TestPayload::new("durable-before-ack"); + let expected = { + let journal = LiveEventJournal::open(path.clone(), limits(10)).unwrap(); + let expected = journal.checkpoint(&owner).unwrap(); + journal.fail_next_append_after_sync(); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &expected, + "session", + None, + payload.clone() + ), + Err(LiveEventJournalError::StorageUnavailable) + ); + expected + }; + + let journal = LiveEventJournal::::open(path, limits(10)).unwrap(); + let outcome = journal + .append_outcome( + &ingress(&journal, &owner), + &expected, + "session", + None, + payload.clone(), + ) + .unwrap(); + let committed = match outcome { + AppendOutcome::Duplicate { + event_cursor, + head_cursor, + } => { + assert_eq!(event_cursor, head_cursor); + event_cursor + } + outcome => panic!("expected recovered duplicate, got {outcome:?}"), + }; + assert_eq!(committed.sequence(), 1); + let (entries, next, has_more) = events( + journal + .replay_after(&owner, &expected, journal.max_replay_entries()) + .unwrap(), + ); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].payload(), &payload); + assert_eq!(next, committed); + assert!(!has_more); + } + + #[test] + fn same_process_ambiguous_append_only_exact_ingress_retry_can_reconcile() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let owner = owner("same-process-ambiguous-append", 0); + let lease = journal.activate_account(&owner).unwrap(); + let ingress = journal.bind_ingress(&lease).unwrap(); + let start = journal.checkpoint(&lease).unwrap(); + let payload = TestPayload::new("durable before same-process error"); + + journal.fail_next_append_after_sync(); + assert_eq!( + journal.append_outcome(&ingress, &start, "session", Some("run"), payload.clone()), + Err(LiveEventJournalError::StorageUnavailable) + ); + assert_eq!( + journal.checkpoint(&lease), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + ); + assert_eq!( + journal.classify_event( + &ingress, + &start, + "session", + Some("run"), + &TestPayload::new("unrelated retry") + ), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + ); + + let admission = journal + .classify_event(&ingress, &start, "session", Some("run"), &payload) + .unwrap(); + let committed = match admission { + EventAdmission::Duplicate { + event_cursor, + head_cursor, + } => { + assert_eq!(event_cursor, head_cursor); + event_cursor + } + admission => panic!("expected exact recovered duplicate, got {admission:?}"), + }; + assert_eq!(committed.sequence(), 1); + assert_eq!(journal.checkpoint(&lease).unwrap(), committed); + assert!(matches!( + journal + .append_outcome(&ingress, &start, "session", Some("run"), payload) + .unwrap(), + AppendOutcome::Duplicate { .. } + )); + } + + #[test] + fn rollover_revokes_old_activation_and_ingress_before_tombstones_clear() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let owner = owner("rollover-ingress-fence", 0); + let old_lease = journal.activate_account(&owner).unwrap(); + let old_ingress = journal.bind_ingress(&old_lease).unwrap(); + let old_namespace = old_ingress.event_namespace_commitment(); + let start = journal.checkpoint(&old_lease).unwrap(); + let payload = TestPayload::new("same durable event ID across generations"); + let old_head = journal + .append_outcome( + &old_ingress, + &start, + "session", + Some("run"), + payload.clone(), + ) + .unwrap() + .cursor() + .clone(); + journal + .store_checkpoint( + &old_lease, + &old_head, + b"absolute rollover ingress projection", + ) + .unwrap(); + let obligation = journal + .prepare_rollover( + &old_lease, + &old_head, + b"absolute rollover ingress projection", + ) + .unwrap(); + + // In-flight commands cannot pass the FIFO seal while rollover is + // pending, even though they still carry the exact old capability. + assert_eq!( + journal.classify_event(&old_ingress, &old_head, "session", Some("run"), &payload), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + ); + + let activation = journal + .commit_rollover(&obligation, b"absolute rollover ingress projection") + .unwrap(); + let (fresh_lease, fresh) = activation.into_parts(); + let fresh_ingress = journal.bind_ingress(&fresh_lease).unwrap(); + assert_ne!(fresh_ingress.event_namespace_commitment(), old_namespace); + assert_eq!( + journal.checkpoint(&old_lease), + Err(LiveEventJournalError::JournalRetired) + ); + assert_eq!( + journal.bind_ingress(&old_lease), + Err(LiveEventJournalError::JournalRetired) + ); + assert_eq!( + journal.append_outcome( + &old_ingress, + &fresh, + "session", + Some("run"), + payload.clone() + ), + Err(LiveEventJournalError::JournalReplaced) + ); + + // Clearing the old generation's tombstone deliberately permits the + // same stable ID only when it arrives under the newly admitted ingress + // namespace. + let fresh_head = journal + .append_outcome( + &fresh_ingress, + &fresh, + "session", + Some("run"), + payload.clone(), + ) + .unwrap() + .cursor() + .clone(); + assert_eq!(fresh_head.sequence(), 1); + assert!(matches!( + journal + .append_outcome(&fresh_ingress, &fresh, "session", Some("run"), payload,) + .unwrap(), + AppendOutcome::Duplicate { .. } + )); + } + + #[test] + fn same_owner_rollover_preserves_absolute_projection_and_fences_old_actors() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let old_payload = TestPayload::new("old-generation-event"); + let old_head = { + let journal = LiveEventJournal::open(path.clone(), limits(10)).unwrap(); + let old_head = journal + .append(&owner, "session", None, old_payload.clone()) + .unwrap(); + { + let mut state = journal.lock_state().unwrap(); + let account = state.accounts.get_mut(&owner.account_key).unwrap(); + account.event_id_metadata_bytes = MAX_IDEMPOTENCY_METADATA_BYTES; + } + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &old_head, + "session", + None, + TestPayload::new("capacity-boundary") + ), + Err(LiveEventJournalError::IdempotencyCapacityExceeded) + ); + journal + .store_checkpoint(&owner, &old_head, b"absolute projection at rollover") + .unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + assert!(matches!( + journal.prepare_rollover(&lease, &old_head, b"different projection at rollover"), + Err(LiveEventJournalError::InvalidCheckpoint) + )); + let obligation = journal + .prepare_rollover(&lease, &old_head, b"absolute projection at rollover") + .unwrap(); + assert_eq!( + journal.checkpoint(&lease), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + ); + let activation = journal + .commit_rollover(&obligation, b"absolute projection at rollover") + .unwrap(); + let (fresh_lease, fresh) = activation.into_parts(); + let replayed = journal + .commit_rollover(&obligation, b"absolute projection at rollover") + .unwrap(); + assert_eq!(replayed.lease, fresh_lease); + assert_eq!(replayed.cursor, fresh); + assert_eq!(fresh.sequence(), 0); + assert_ne!(fresh.journal_id(), old_head.journal_id()); + old_head + }; + + let journal = LiveEventJournal::::open(path, limits(10)).unwrap(); + let fresh = journal.checkpoint(&owner).unwrap(); + assert_eq!(fresh.sequence(), 0); + assert_ne!(fresh.journal_id(), old_head.journal_id()); + let projection = journal.load_checkpoint(&owner).unwrap().unwrap(); + assert_eq!(projection.through_cursor, fresh); + assert_eq!(projection.bytes, b"absolute projection at rollover"); + assert_eq!( + journal.replay_after(&owner, &old_head, 10).unwrap(), + LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::JournalReplaced, + current_cursor: fresh.clone(), + }) + ); + assert_eq!( + journal.classify_event( + &ingress(&journal, &owner), + &old_head, + "session", + None, + &old_payload + ), + Err(LiveEventJournalError::JournalReplaced) + ); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &old_head, + "session", + None, + old_payload + ), + Err(LiveEventJournalError::JournalReplaced) + ); + assert_eq!( + journal + .append_outcome( + &ingress(&journal, &owner), + &fresh, + "session", + None, + TestPayload::new("new-generation-event") + ) + .unwrap() + .cursor() + .sequence(), + 1 + ); + } + + #[test] + fn rollover_crash_boundaries_reopen_as_exactly_old_or_new_generation() { + for (boundary, replacement_committed) in [ + (ReplaceFailureBoundary::BeforeFileSync, false), + (ReplaceFailureBoundary::AfterFileSync, false), + (ReplaceFailureBoundary::AfterPersist, true), + (ReplaceFailureBoundary::AfterDirectorySync, true), + ] { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let old_payload = TestPayload::new("old-generation-event"); + let old_head = { + let journal = LiveEventJournal::open(path.clone(), limits(10)).unwrap(); + let old_head = journal + .append(&owner, "session", None, old_payload.clone()) + .unwrap(); + journal + .store_checkpoint( + &owner, + &old_head, + b"absolute projection at ambiguous commit", + ) + .unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let obligation = journal + .prepare_rollover( + &lease, + &old_head, + b"absolute projection at ambiguous commit", + ) + .unwrap(); + journal.fail_next_replace_at(boundary); + assert!( + matches!( + journal.commit_rollover( + &obligation, + b"absolute projection at ambiguous commit" + ), + Err(LiveEventJournalError::StorageUnavailable) + ), + "boundary {boundary:?}" + ); + old_head + }; + + let journal = LiveEventJournal::::open(path, limits(10)).unwrap(); + let recovered = journal.checkpoint(&owner).unwrap(); + assert_eq!( + recovered.journal_id() != old_head.journal_id(), + replacement_committed, + "boundary {boundary:?}" + ); + assert_eq!( + journal.load_checkpoint(&owner).unwrap().unwrap().bytes, + b"absolute projection at ambiguous commit" + ); + if replacement_committed { + assert_eq!(recovered.sequence(), 0); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &old_head, + "session", + None, + old_payload + ), + Err(LiveEventJournalError::JournalReplaced), + "boundary {boundary:?}" + ); + } else { + assert_eq!(recovered, old_head); + let lease = journal.activate_account(&owner).unwrap(); + let obligation = journal + .prepare_rollover( + &lease, + &old_head, + b"absolute projection at ambiguous commit", + ) + .unwrap(); + let activation = journal + .commit_rollover(&obligation, b"absolute projection at ambiguous commit") + .unwrap(); + let (_, fresh) = activation.into_parts(); + assert_eq!(fresh.sequence(), 0); + assert_ne!(fresh.journal_id(), old_head.journal_id()); + } + } + } + + #[test] + fn rollover_requires_one_prepared_fifo_obligation_and_retries_ambiguity() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("rollover-obligation", 0); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let start = journal.checkpoint(&lease).unwrap(); + let head = journal + .append_outcome( + &journal.bind_ingress(&lease).unwrap(), + &start, + "session", + None, + TestPayload::new("before rollover"), + ) + .unwrap() + .cursor() + .clone(); + journal + .store_checkpoint(&lease, &head, b"absolute rollover projection") + .unwrap(); + + let forged = LiveEventJournalRolloverObligation { + owner: owner.clone(), + operation_token: lease.operation_token, + new_operation_token: new_process_token().unwrap(), + rollover_nonce: [0x44; PROCESS_TOKEN_BYTES], + journal_id: head.journal_id().to_string(), + head_sequence: head.sequence(), + checkpoint_commitment: Sha256::digest(b"absolute rollover projection").into(), + new_journal_id: new_journal_id().unwrap(), + }; + assert!(matches!( + journal.commit_rollover(&forged, b"absolute rollover projection"), + Err(LiveEventJournalError::JournalReplaced) + )); + + let obligation = journal + .prepare_rollover(&lease, &head, b"absolute rollover projection") + .unwrap(); + assert!(matches!( + journal.prepare_rollover(&lease, &head, b"absolute rollover projection"), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + )); + assert!(matches!( + journal.commit_rollover(&obligation, b"different projection"), + Err(LiveEventJournalError::InvalidCheckpoint) + )); + journal.fail_next_replace_at(ReplaceFailureBoundary::AfterPersist); + assert!(matches!( + journal.commit_rollover(&obligation, b"absolute rollover projection"), + Err(LiveEventJournalError::StorageUnavailable) + )); + assert_eq!( + journal.checkpoint(&lease), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + ); + let activation = journal + .commit_rollover(&obligation, b"absolute rollover projection") + .unwrap(); + let (fresh_lease, fresh) = activation.into_parts(); + assert_ne!(fresh.journal_id(), head.journal_id()); + assert_eq!(fresh.sequence(), 0); + assert_eq!( + journal.checkpoint(&lease), + Err(LiveEventJournalError::JournalRetired) + ); + assert_eq!(journal.checkpoint(&fresh_lease).unwrap(), fresh); + let path = journal.journal_path(&owner); + let bytes_before_replay = fs::read(&path).unwrap(); + let replayed_once = journal + .commit_rollover(&obligation, b"absolute rollover projection") + .unwrap(); + let replayed_twice = journal + .commit_rollover(&obligation, b"absolute rollover projection") + .unwrap(); + assert_eq!(replayed_once.lease, fresh_lease); + assert_eq!(replayed_once.cursor, fresh); + assert_eq!(replayed_twice.lease, fresh_lease); + assert_eq!(replayed_twice.cursor, fresh); + assert_eq!(fs::read(&path).unwrap(), bytes_before_replay); + assert_eq!( + journal.append_outcome( + &LiveEventJournalIngressLease { + owner: owner.clone(), + operation_token: lease.operation_token, + journal_id: decode_hex_array(head.journal_id()).unwrap(), + }, + &head, + "session", + None, + TestPayload::new("old actor retry"), + ), + Err(LiveEventJournalError::JournalReplaced) + ); + let next_obligation = journal + .prepare_rollover(&fresh_lease, &fresh, b"absolute rollover projection") + .unwrap(); + assert!(matches!( + journal.commit_rollover(&obligation, b"absolute rollover projection"), + Err(LiveEventJournalError::JournalReplaced) + )); + drop(next_obligation); + } + + #[test] + fn compaction_crash_boundaries_reopen_as_exactly_old_or_new_head() { + for (boundary, replacement_committed) in [ + (ReplaceFailureBoundary::BeforeFileSync, false), + (ReplaceFailureBoundary::AfterFileSync, false), + (ReplaceFailureBoundary::AfterPersist, true), + (ReplaceFailureBoundary::AfterDirectorySync, true), + ] { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner(&format!("compact-{boundary:?}"), 0); + let second_payload = TestPayload::new("second compacted event"); + let first = { + let journal = LiveEventJournal::open(path.clone(), limits(1)).unwrap(); + let first = journal + .append( + &owner, + "session", + None, + TestPayload::new("first compacted event"), + ) + .unwrap(); + journal + .store_checkpoint(&owner, &first, b"absolute compacted projection") + .unwrap(); + journal.fail_next_replace_at(boundary); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &first, + "session", + None, + second_payload.clone() + ), + Err(LiveEventJournalError::StorageUnavailable), + "boundary {boundary:?}" + ); + first + }; + + let journal = LiveEventJournal::::open(path, limits(1)).unwrap(); + let recovered = journal.checkpoint(&owner).unwrap(); + assert_eq!( + recovered.sequence(), + if replacement_committed { 2 } else { 1 }, + "boundary {boundary:?}" + ); + let retried = journal + .append_outcome( + &ingress(&journal, &owner), + &first, + "session", + None, + second_payload.clone(), + ) + .unwrap(); + if replacement_committed { + assert!(matches!(retried, AppendOutcome::Duplicate { .. })); + } else { + assert!(matches!(retried, AppendOutcome::Inserted(_))); + } + assert_eq!(journal.checkpoint(&owner).unwrap().sequence(), 2); + assert_eq!( + journal.load_checkpoint(&owner).unwrap().unwrap().bytes, + b"absolute compacted projection" + ); + } + } + + #[test] + fn checkpoint_survives_restart_and_preserves_recent_replay_suffix() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let (start, second) = { + let journal = LiveEventJournal::open(path.clone(), limits(4)).unwrap(); + let start = journal.checkpoint(&owner).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + let second = journal + .append(&owner, "session", None, TestPayload::new("two")) + .unwrap(); + assert_eq!( + journal + .store_checkpoint(&owner, &second, b"absolute projection at two") + .unwrap(), + second + ); + let saved = journal.load_checkpoint(&owner).unwrap().unwrap(); + assert_eq!(saved.through_cursor, second); + assert_eq!(saved.bytes, b"absolute projection at two"); + let (entries, _, _) = events(journal.replay_after(&owner, &start, 4).unwrap()); + assert_eq!(entries.len(), 2); + (start, second) + }; + + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let saved = journal.load_checkpoint(&owner).unwrap().unwrap(); + assert_eq!(saved.through_cursor, second); + assert_eq!(saved.bytes, b"absolute projection at two"); + let (retained, _, _) = events(journal.replay_after(&owner, &start, 4).unwrap()); + assert_eq!(retained.len(), 2); + let third = journal + .append(&owner, "session", None, TestPayload::new("three")) + .unwrap(); + let (delta, next, has_more) = events(journal.replay_after(&owner, &second, 4).unwrap()); + assert_eq!(delta.len(), 1); + assert_eq!(delta[0].payload().value, "three"); + assert_eq!(next, third); + assert!(!has_more); + } + + #[test] + fn compaction_requires_checkpoint_before_append_and_keeps_exact_tombstones() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let old_payload = TestPayload::new("one"); + let (old_cursor, head) = { + let journal = LiveEventJournal::open(path.clone(), limits(2)).unwrap(); + let old_cursor = journal + .append(&owner, "session", None, old_payload.clone()) + .unwrap(); + let head = journal + .append(&owner, "session", None, TestPayload::new("two")) + .unwrap(); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &head, + "session", + None, + TestPayload::new("three"), + ), + Err(LiveEventJournalError::CheckpointRequired) + ); + assert_eq!(journal.checkpoint(&owner).unwrap(), head); + journal + .store_checkpoint(&owner, &head, b"projection through two") + .unwrap(); + let third = journal + .append_outcome( + &ingress(&journal, &owner), + &head, + "session", + None, + TestPayload::new("three"), + ) + .unwrap(); + assert!(matches!(third, AppendOutcome::Inserted(_))); + (old_cursor, journal.checkpoint(&owner).unwrap()) + }; + + let journal = LiveEventJournal::::open(path, limits(2)).unwrap(); + assert_eq!( + journal + .classify_event( + &ingress(&journal, &owner), + &head, + "session", + None, + &old_payload + ) + .unwrap(), + EventAdmission::Duplicate { + event_cursor: old_cursor.clone(), + head_cursor: head.clone(), + } + ); + assert_eq!( + journal + .append_outcome( + &ingress(&journal, &owner), + &head, + "session", + None, + old_payload.clone() + ) + .unwrap(), + AppendOutcome::Duplicate { + event_cursor: old_cursor.clone(), + head_cursor: head.clone(), + } + ); + let mut conflict = TestPayload::new("conflict"); + conflict.event_id = old_payload.event_id; + assert_eq!( + journal.classify_event( + &ingress(&journal, &owner), + &head, + "session", + None, + &conflict + ), + Err(LiveEventJournalError::EventIdConflict) + ); + } + + #[test] + fn checkpoint_validation_is_bounded_and_schema_checked_on_reopen() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::::open(path.clone(), limits(2)).unwrap(); + let head = journal.checkpoint(&owner).unwrap(); + assert_eq!(journal.max_checkpoint_bytes(), MAX_CHECKPOINT_BYTES); + assert_eq!( + journal.store_checkpoint(&owner, &head, b""), + Err(LiveEventJournalError::InvalidCheckpoint) + ); + assert_eq!( + journal.store_checkpoint(&owner, &head, &vec![0; MAX_CHECKPOINT_BYTES + 1]), + Err(LiveEventJournalError::InvalidCheckpoint) + ); + journal + .store_checkpoint(&owner, &head, b"valid checkpoint") + .unwrap(); + journal.unload_account(&owner).unwrap(); + rewrite_header(&journal.journal_path(&owner), |header| { + header.checkpoint.as_mut().unwrap().schema = "unknown".to_string(); + }); + assert_eq!( + journal.load_checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn idempotency_capacity_rejects_only_unseen_events_without_advancing_head() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(4)).unwrap(); + let owner = owner("opaque-account", 0); + let payload = TestPayload::new("already committed"); + let head = journal + .append(&owner, "session", None, payload.clone()) + .unwrap(); + { + let mut state = journal.lock_state().unwrap(); + state + .accounts + .get_mut(&owner.account_key) + .unwrap() + .event_id_metadata_bytes = MAX_IDEMPOTENCY_METADATA_BYTES; + } + assert!(matches!( + journal + .append_outcome(&ingress(&journal, &owner), &head, "session", None, payload) + .unwrap(), + AppendOutcome::Duplicate { .. } + )); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &head, + "session", + None, + TestPayload::new("unseen"), + ), + Err(LiveEventJournalError::IdempotencyCapacityExceeded) + ); + assert_eq!(journal.checkpoint(&owner).unwrap(), head); + } + + #[test] + fn exact_tombstone_count_boundary_rolls_over_without_losing_retry_fences() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::::open(path.clone(), limits(1)).unwrap(); + journal.checkpoint(&owner).unwrap(); + let before_last = { + let mut state = journal.lock_state().unwrap(); + let account = state.accounts.get_mut(&owner.account_key).unwrap(); + account.event_ids.clear(); + account.event_id_metadata_bytes = 0; + for sequence in 1..u64::try_from(MAX_IDEMPOTENCY_EVENT_IDS).unwrap() { + let record = StoredEventId { + event_id: format!("seed-{sequence}"), + sequence, + commitment: "a".repeat(ACCOUNT_KEY_HEX_BYTES), + }; + account.event_id_metadata_bytes += encoded_event_id_bytes(&record).unwrap(); + account.event_ids.insert(record.event_id.clone(), record); + } + account.head_sequence = u64::try_from(MAX_IDEMPOTENCY_EVENT_IDS - 1).unwrap(); + let checkpoint_bytes = b"absolute projection before final tombstone".to_vec(); + account.checkpoint = Some(StoredCheckpoint { + schema: CHECKPOINT_SCHEMA.to_string(), + through_sequence: account.head_sequence, + commitment: bytes_commitment(&checkpoint_bytes), + bytes: checkpoint_bytes, + }); + journal.replace_account_file(&owner, account).unwrap(); + current_cursor(account) + }; + + let final_payload = TestPayload::new("final tombstone"); + let at_capacity = match journal + .append_outcome( + &ingress(&journal, &owner), + &before_last, + "session", + None, + final_payload.clone(), + ) + .unwrap() + { + AppendOutcome::Inserted(cursor) => cursor, + outcome => panic!("expected final insertion, got {outcome:?}"), + }; + assert_eq!( + at_capacity.sequence(), + u64::try_from(MAX_IDEMPOTENCY_EVENT_IDS).unwrap() + ); + assert!(matches!( + journal + .append_outcome( + &ingress(&journal, &owner), + &before_last, + "session", + None, + final_payload.clone(), + ) + .unwrap(), + AppendOutcome::Duplicate { .. } + )); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &at_capacity, + "session", + None, + TestPayload::new("one beyond capacity"), + ), + Err(LiveEventJournalError::IdempotencyCapacityExceeded) + ); + + journal + .store_checkpoint( + &owner, + &at_capacity, + b"absolute projection at exact capacity", + ) + .unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let obligation = journal + .prepare_rollover( + &lease, + &at_capacity, + b"absolute projection at exact capacity", + ) + .unwrap(); + let activation = journal + .commit_rollover(&obligation, b"absolute projection at exact capacity") + .unwrap(); + let (fresh_lease, fresh) = activation.into_parts(); + assert_eq!(fresh.sequence(), 0); + assert_ne!(fresh.journal_id(), at_capacity.journal_id()); + assert_eq!( + journal.append_outcome( + &ingress(&journal, &owner), + &at_capacity, + "session", + None, + final_payload, + ), + Err(LiveEventJournalError::JournalReplaced) + ); + let fresh_payload = TestPayload::new("first fresh event"); + let first_fresh = journal + .append_outcome( + &journal.bind_ingress(&fresh_lease).unwrap(), + &fresh, + "session", + None, + fresh_payload.clone(), + ) + .unwrap(); + assert_eq!(first_fresh.cursor().sequence(), 1); + assert!(matches!( + journal + .append_outcome( + &ingress(&journal, &owner), + &fresh, + "session", + None, + fresh_payload + ) + .unwrap(), + AppendOutcome::Duplicate { .. } + )); + drop(journal); + + let journal = LiveEventJournal::::open(path, limits(1)).unwrap(); + assert_eq!( + journal.checkpoint(&owner).unwrap(), + first_fresh.cursor().clone() + ); + assert_eq!( + journal.load_checkpoint(&owner).unwrap().unwrap().bytes, + b"absolute projection at exact capacity" + ); + } + + #[test] + fn maximum_checkpoint_and_tombstone_metadata_reopen_within_disk_bound() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::::open(path.clone(), limits(1)).unwrap(); + let mut account = journal.empty_account(&owner).unwrap(); + for index in 0..MAX_IDEMPOTENCY_EVENT_IDS { + let prefix = format!("{index:016x}"); + let event_id = format!( + "{prefix}{}", + "\"".repeat(MAX_EVENT_OWNER_ID_BYTES - prefix.len()) + ); + let record = StoredEventId { + event_id, + sequence: u64::try_from(index + 1).unwrap(), + commitment: "a".repeat(ACCOUNT_KEY_HEX_BYTES), + }; + let encoded_bytes = encoded_event_id_bytes(&record).unwrap(); + if account + .event_id_metadata_bytes + .checked_add(encoded_bytes) + .is_none_or(|total| total > MAX_IDEMPOTENCY_METADATA_BYTES) + { + break; + } + account.event_id_metadata_bytes += encoded_bytes; + account.event_ids.insert(record.event_id.clone(), record); + } + assert!(account.event_id_metadata_bytes > MAX_IDEMPOTENCY_METADATA_BYTES - 512); + account.head_sequence = u64::try_from(account.event_ids.len()).unwrap(); + let checkpoint_bytes = vec![0x5a; MAX_CHECKPOINT_BYTES]; + account.checkpoint = Some(StoredCheckpoint { + schema: CHECKPOINT_SCHEMA.to_string(), + through_sequence: account.head_sequence, + commitment: bytes_commitment(&checkpoint_bytes), + bytes: checkpoint_bytes, + }); + journal.replace_account_file(&owner, &mut account).unwrap(); + let file_len = fs::metadata(journal.journal_path(&owner)).unwrap().len(); + assert!(file_len <= journal.inner.limits.max_disk_bytes().unwrap()); + drop(journal); + + let journal = LiveEventJournal::::open(path, limits(1)).unwrap(); + let loaded = journal.load_checkpoint(&owner).unwrap().unwrap(); + assert_eq!(loaded.through_cursor.sequence(), account.head_sequence); + assert_eq!(loaded.bytes.len(), MAX_CHECKPOINT_BYTES); + } + + #[test] + fn cursor_below_head_with_no_retained_suffix_requires_snapshot() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(4)).unwrap(); + let owner = owner("opaque-account", 0); + let start = journal.checkpoint(&owner).unwrap(); + let head = journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + journal + .store_checkpoint(&owner, &head, b"absolute projection") + .unwrap(); + { + let mut state = journal.lock_state().unwrap(); + let account = state.accounts.get_mut(&owner.account_key).unwrap(); + account.entries.clear(); + account.total_payload_bytes = 0; + journal.replace_account_file(&owner, account).unwrap(); + } + assert_eq!( + journal.replay_after(&owner, &start, 4).unwrap(), + LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::RetentionGap, + current_cursor: head.clone(), + }) + ); + assert_eq!( + journal.replay_after(&owner, &head, 4).unwrap(), + LiveReplayRead::Events { + entries: Vec::new(), + next_cursor: head, + has_more: false, + } + ); + } + + #[test] + fn low_water_compaction_never_evicts_the_event_being_appended() { + let root = private_tempdir(); + let mut compact_limits = limits(2); + compact_limits.max_payload_bytes = 2_048; + compact_limits.max_total_payload_bytes = 2_500; + compact_limits.max_replay_payload_bytes = 2_500; + let journal = LiveEventJournal::open(root.path().join("journal"), compact_limits).unwrap(); + let owner = owner("opaque-account", 0); + journal + .append(&owner, "session", None, TestPayload::new("small")) + .unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("other")) + .unwrap(); + let projection_head = journal.checkpoint(&owner).unwrap(); + journal + .store_checkpoint(&owner, &projection_head, b"absolute projection") + .unwrap(); + let newest_payload = TestPayload::new("x".repeat(1_000)); + let newest_id = newest_payload.event_id.clone(); + let newest = journal + .append(&owner, "session", None, newest_payload) + .unwrap(); + let cursor_before_newest = LiveEventCursor::new( + newest.journal_id().to_string(), + newest.sequence().checked_sub(1).unwrap(), + ); + let (entries, next, _) = events( + journal + .replay_after(&owner, &cursor_before_newest, 2) + .unwrap(), + ); + assert_eq!(next, newest); + assert_eq!(entries.last().unwrap().payload().event_id, newest_id); + } + + #[test] + fn incomplete_rotation_blocks_new_owner_until_authorized_clear() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let previous = owner("opaque-account", 0); + journal + .append(&previous, "session", None, TestPayload::new("old")) + .unwrap(); + let current = owner("opaque-account", 1); + + { + let mut state = journal.lock_state().unwrap(); + let operation_token = authorize_rotation(&state.owners, &previous, ¤t).unwrap(); + state.owners.insert( + current.account_key.clone(), + JournalOwnerState::TransitionIncomplete { + generation: current.account_generation, + operation_token, + }, + ); + state.accounts.remove(¤t.account_key); + } + + assert_eq!( + journal.checkpoint(¤t), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + ); + assert_eq!( + journal.append(¤t, "session", None, TestPayload::new("new")), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + ); + let reset = journal.clear_account(¤t).unwrap(); + assert_eq!(reset.sequence(), 0); + assert_eq!(journal.checkpoint(¤t).unwrap(), reset); + } + + #[test] + fn owner_transition_and_append_share_one_serialization_boundary() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let previous = owner("opaque-account", 0); + journal.checkpoint(&previous).unwrap(); + let current = owner("opaque-account", 1); + + let state_guard = journal.lock_state().unwrap(); + let rotated = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let rotated_worker = Arc::clone(&rotated); + let worker_journal = journal.clone(); + let worker_previous = previous.clone(); + let worker_current = current.clone(); + let worker = thread::spawn(move || { + worker_journal + .rotate_account_generation(&worker_previous, &worker_current) + .unwrap(); + rotated_worker.store(true, std::sync::atomic::Ordering::Release); + }); + thread::sleep(std::time::Duration::from_millis(20)); + assert!(!rotated.load(std::sync::atomic::Ordering::Acquire)); + drop(state_guard); + worker.join().unwrap(); + assert!(rotated.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!( + journal.append(&previous, "session", None, TestPayload::new("late")), + Err(LiveEventJournalError::OwnerGenerationMismatch) + ); + } + + #[test] + fn indeterminate_recovery_resyncs_before_returning_a_deduped_cursor() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(10)).unwrap(); + let owner = owner("opaque-account", 0); + let payload = TestPayload::new("ambiguous"); + let cursor = journal + .append(&owner, "session", None, payload.clone()) + .unwrap(); + { + let mut state = journal.lock_state().unwrap(); + mark_owner_indeterminate(&mut state.owners, &owner).unwrap(); + state.accounts.remove(&owner.account_key); + } + + let retried = journal.append(&owner, "session", None, payload).unwrap(); + assert_eq!(retried, cursor); + let state = journal.lock_state().unwrap(); + assert!(matches!( + state.owners.get(&owner.account_key), + Some(JournalOwnerState::Active { + generation, + needs_resync: false, + .. + }) if *generation == owner.account_generation + )); + } + + #[test] + fn replay_is_bounded_and_reports_more_without_advancing_past_delivery() { + let root = private_tempdir(); + let journal = + LiveEventJournal::::open(root.path().join("journal"), limits(4)).unwrap(); + let owner = owner("opaque-account", 0); + let start = journal.checkpoint(&owner).unwrap(); + for value in ["one", "two", "three"] { + journal + .append(&owner, "session", None, TestPayload::new(value)) + .unwrap(); + } + let (first_page, next, has_more) = events(journal.replay_after(&owner, &start, 2).unwrap()); + assert_eq!(first_page.len(), 2); + assert!(has_more); + assert_eq!(next.sequence(), 2); + let (second_page, final_cursor, has_more) = + events(journal.replay_after(&owner, &next, 2).unwrap()); + assert_eq!(second_page.len(), 1); + assert!(!has_more); + assert_eq!(final_cursor.sequence(), 3); + } + + #[test] + fn replay_response_is_also_bounded_below_the_transport_frame() { + let root = private_tempdir(); + let mut replay_limits = limits(4); + replay_limits.max_replay_payload_bytes = 2_048; + let journal = LiveEventJournal::open(root.path().join("journal"), replay_limits).unwrap(); + let owner = owner("opaque-account", 0); + let start = journal.checkpoint(&owner).unwrap(); + for marker in ["a", "b", "c"] { + journal + .append( + &owner, + "session", + None, + TestPayload::new(format!("{marker}{}", "x".repeat(899))), + ) + .unwrap(); + } + + let (entries, next, has_more) = events(journal.replay_after(&owner, &start, 4).unwrap()); + assert_eq!(entries.len(), 2); + assert!(has_more); + assert_eq!(next.sequence(), 2); + } + + #[test] + fn cursor_ahead_requires_snapshot() { + let root = private_tempdir(); + let journal = + LiveEventJournal::::open(root.path().join("journal"), limits(4)).unwrap(); + let owner = owner("opaque-account", 0); + let current = journal.checkpoint(&owner).unwrap(); + let ahead = LiveEventCursor::new(current.journal_id().to_string(), 9); + assert_eq!( + journal.replay_after(&owner, &ahead, 4).unwrap(), + LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::CursorAhead, + current_cursor: current, + }) + ); + } + + #[test] + fn malformed_owners_payloads_cursors_and_limits_are_rejected() { + assert_eq!( + LiveEventAccountOwner::new("", 0), + Err(LiveEventJournalError::InvalidAccountOwner) + ); + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(4)).unwrap(); + let owner = owner("opaque-account", 0); + assert_eq!( + journal.append(&owner, "", None, TestPayload::new("value")), + Err(LiveEventJournalError::InvalidEventOwner) + ); + assert_eq!( + journal.append(&owner, "session", None, TestPayload::new("x".repeat(1_025))), + Err(LiveEventJournalError::PayloadTooLarge) + ); + assert_eq!( + journal.replay_after(&owner, &LiveEventCursor::new("bad".into(), 0), 4), + Err(LiveEventJournalError::InvalidCursor) + ); + assert_eq!( + journal.replay_after(&owner, &journal.checkpoint(&owner).unwrap(), 0), + Err(LiveEventJournalError::InvalidReplayLimit) + ); + assert!(matches!( + LiveEventJournal::::open( + root.path().join("invalid"), + LiveEventJournalLimits { + max_entries: 1, + max_payload_bytes: 2, + max_total_payload_bytes: 1, + max_replay_entries: 1, + max_replay_payload_bytes: 2, + } + ), + Err(LiveEventJournalError::InvalidLimits) + )); + } + + #[test] + fn platform_gate_rejects_unimplemented_durability_targets() { + assert!(durable_journal_platform_supported("macos")); + assert!(durable_journal_platform_supported("linux")); + assert!(!durable_journal_platform_supported("windows")); + assert!(!durable_journal_platform_supported("freebsd")); + if durable_journal_platform_supported(std::env::consts::OS) { + assert_eq!(ensure_supported_platform(), Ok(())); + } else { + assert_eq!( + ensure_supported_platform(), + Err(LiveEventJournalError::UnsupportedPlatform) + ); + } + } + + #[test] + fn cursor_parts_are_checked_without_exposing_the_constructor() { + let journal_id = "a".repeat(JOURNAL_ID_HEX_BYTES); + let cursor = LiveEventCursor::try_from_parts(journal_id.clone(), 42).unwrap(); + assert_eq!(cursor.journal_id(), journal_id); + assert_eq!(cursor.sequence(), 42); + assert_eq!(cursor.beginning().sequence(), 0); + assert_eq!( + LiveEventCursor::try_from_parts("not-a-journal".to_string(), 0), + Err(LiveEventJournalError::InvalidCursor) + ); + assert_eq!( + LiveEventCursor::try_from_parts( + "a".repeat(JOURNAL_ID_HEX_BYTES), + MAX_CURSOR_SEQUENCE + 1 + ), + Err(LiveEventJournalError::InvalidCursor) + ); + } + + #[cfg(unix)] + #[test] + fn dedicated_parent_helper_creates_and_normalizes_only_its_leaf() { + use std::os::unix::fs::PermissionsExt; + + let broad_parent = private_tempdir(); + fs::set_permissions(broad_parent.path(), fs::Permissions::from_mode(0o755)).unwrap(); + let dedicated = broad_parent.path().join("agent-live-events"); + prepare_live_event_journal_parent(&dedicated).unwrap(); + assert_eq!( + fs::metadata(&dedicated).unwrap().permissions().mode() & 0o777, + 0o700 + ); + + fs::set_permissions(&dedicated, fs::Permissions::from_mode(0o755)).unwrap(); + prepare_live_event_journal_parent(&dedicated).unwrap(); + assert_eq!( + fs::metadata(&dedicated).unwrap().permissions().mode() & 0o777, + 0o700 + ); + LiveEventJournal::::open(dedicated.join("journal"), limits(4)).unwrap(); + } + + #[cfg(unix)] + #[test] + fn dedicated_parent_helper_rejects_symlink_and_unsafe_ancestry() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let outer = private_tempdir(); + let target = outer.path().join("target"); + create_owner_only_directory(&target).unwrap(); + let linked = outer.path().join("linked"); + symlink(&target, &linked).unwrap(); + assert_eq!( + prepare_live_event_journal_parent(&linked), + Err(LiveEventJournalError::StorageUnavailable) + ); + + let unsafe_ancestor = outer.path().join("unsafe"); + create_owner_only_directory(&unsafe_ancestor).unwrap(); + fs::set_permissions(&unsafe_ancestor, fs::Permissions::from_mode(0o777)).unwrap(); + let private_child = unsafe_ancestor.join("private"); + create_owner_only_directory(&private_child).unwrap(); + assert!(matches!( + LiveEventJournal::::open(private_child.join("journal"), limits(4)), + Err(LiveEventJournalError::StorageUnavailable) + )); + assert_eq!( + prepare_live_event_journal_parent(&unsafe_ancestor.join("dedicated")), + Err(LiveEventJournalError::StorageUnavailable) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn dedicated_parent_helper_strips_acl_from_its_owned_leaf() { + use std::process::Command; + + let broad_parent = private_tempdir(); + let dedicated = broad_parent.path().join("agent-live-events"); + prepare_live_event_journal_parent(&dedicated).unwrap(); + assert!(Command::new("/bin/chmod") + .args(["+a", "everyone allow read"]) + .arg(&dedicated) + .status() + .unwrap() + .success()); + assert!( + macos_acl::has_extended_entries(&open_directory_no_follow(&dedicated).unwrap()) + .unwrap() + ); + prepare_live_event_journal_parent(&dedicated).unwrap(); + assert!( + !macos_acl::has_extended_entries(&open_directory_no_follow(&dedicated).unwrap()) + .unwrap() + ); + } + + #[test] + fn root_creation_must_commit_its_parent_link() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let mut observed_parent = None; + let result = ensure_private_directory_with_parent_sync(&path, |candidate| { + observed_parent = Some(candidate.to_path_buf()); + Err(LiveEventJournalError::StorageUnavailable) + }); + assert_eq!(result, Err(LiveEventJournalError::StorageUnavailable)); + assert_eq!(observed_parent.as_deref(), Some(parent.path())); + assert!(path.is_dir()); + + // A retry performs the parent sync again and can safely adopt the + // directory created before the indeterminate first sync. + ensure_private_directory(&path).unwrap(); + LiveEventJournal::::open(path, limits(4)).unwrap(); + } + + #[cfg(unix)] + #[test] + fn journal_requires_an_owner_private_parent_directory() { + use std::os::unix::fs::PermissionsExt; + + let parent = private_tempdir(); + fs::set_permissions(parent.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!( + LiveEventJournal::::open(parent.path().join("journal"), limits(4)), + Err(LiveEventJournalError::StorageUnavailable) + )); + + fs::set_permissions(parent.path(), fs::Permissions::from_mode(0o700)).unwrap(); + LiveEventJournal::::open(parent.path().join("journal"), limits(4)).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn journal_rejects_extended_acl_on_its_parent() { + use std::process::Command; + + let parent = private_tempdir(); + assert!(Command::new("/bin/chmod") + .args(["+a", "everyone allow add_file,delete_child"]) + .arg(parent.path()) + .status() + .unwrap() + .success()); + assert!(matches!( + LiveEventJournal::::open(parent.path().join("journal"), limits(4)), + Err(LiveEventJournalError::StorageUnavailable) + )); + + assert!(Command::new("/bin/chmod") + .arg("-N") + .arg(parent.path()) + .status() + .unwrap() + .success()); + LiveEventJournal::::open(parent.path().join("journal"), limits(4)).unwrap(); + } + + #[test] + fn metadata_errors_are_not_misclassified_as_missing_account_files() { + let nul_path = Path::new("\0"); + assert_eq!( + account_file_state(nul_path), + Err(LiveEventJournalError::StorageUnavailable) + ); + } + + #[test] + fn replacement_of_the_locked_root_fails_closed() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let moved = parent.path().join("journal-moved"); + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + let owner = owner("opaque-account", 0); + journal.checkpoint(&owner).unwrap(); + + fs::rename(&path, &moved).unwrap(); + create_owner_only_directory(&path).unwrap(); + assert_eq!( + journal.append(&owner, "session", None, TestPayload::new("late")), + Err(LiveEventJournalError::StorageUnavailable) + ); + } + + #[test] + fn replacement_of_the_lock_file_fails_closed() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + let owner = owner("opaque-account", 0); + journal.checkpoint(&owner).unwrap(); + + fs::rename(path.join("host.lock"), path.join("old-host.lock")).unwrap(); + File::create(path.join("host.lock")).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageUnavailable) + ); + } + + #[test] + fn v3_rejects_missing_or_aliased_tombstones_even_with_valid_snapshot_integrity() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::open(path, limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + let second = journal + .append(&owner, "session", None, TestPayload::new("two")) + .unwrap(); + journal + .store_checkpoint(&owner, &second, b"projection") + .unwrap(); + journal.unload_account(&owner).unwrap(); + rewrite_header(&journal.journal_path(&owner), |header| { + header.event_ids.remove(0); + }); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + + journal.clear_account(&owner).unwrap(); + let first = journal + .append(&owner, "session", None, TestPayload::new("three")) + .unwrap(); + let second = journal + .append(&owner, "session", None, TestPayload::new("four")) + .unwrap(); + journal + .store_checkpoint(&owner, &second, b"replacement projection") + .unwrap(); + journal.unload_account(&owner).unwrap(); + rewrite_header(&journal.journal_path(&owner), |header| { + header.event_ids[1].sequence = header.event_ids[0].sequence; + }); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(first.sequence(), 1); + } + + #[test] + fn v3_rejects_suffix_holes_and_payload_bit_flips() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + let checkpoint_head = journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + journal + .store_checkpoint(&owner, &checkpoint_head, b"projection through one") + .unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("two")) + .unwrap(); + let head = journal + .append(&owner, "session", None, TestPayload::new("three")) + .unwrap(); + journal.unload_account(&owner).unwrap(); + let (header, mut entries, _) = read_v3_parts(&journal.journal_path(&owner)); + entries.remove(1); + write_v3_parts_at_head( + &journal.journal_path(&owner), + &header, + &entries, + head.sequence(), + ); + } + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + journal.clear_account(&owner).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("original")) + .unwrap(); + journal.unload_account(&owner).unwrap(); + let mut bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let marker = b"original"; + let marker_offset = bytes + .windows(marker.len()) + .position(|window| window == marker) + .unwrap(); + bytes[marker_offset] ^= 1; + fs::write(journal.journal_path(&owner), bytes).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn v3_rejects_checkpoint_digest_mutation_even_with_recomputed_snapshot_integrity() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let head = journal.checkpoint(&owner).unwrap(); + journal + .store_checkpoint(&owner, &head, b"original projection") + .unwrap(); + journal.unload_account(&owner).unwrap(); + rewrite_header(&journal.journal_path(&owner), |header| { + header.checkpoint.as_mut().unwrap().bytes[0] ^= 1; + }); + assert_eq!( + journal.load_checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn over_record_journal_fails_closed_until_explicit_clear() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(3)).unwrap(); + for (sequence, value) in [(1, "one"), (2, "two"), (3, "three")] { + assert_eq!( + journal + .append(&owner, "session", None, TestPayload::new(value)) + .unwrap() + .sequence(), + sequence + ); + } + } + + let journal = LiveEventJournal::::open(path, limits(2)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(journal.clear_account(&owner).unwrap().sequence(), 0); + } + + #[test] + fn complete_but_corrupt_final_record_fails_closed_until_clear() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("old")) + .unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("corrupt-tail")) + .unwrap(); + let mut bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let marker = b"corrupt-tail"; + let marker_offset = bytes + .windows(marker.len()) + .position(|window| window == marker) + .unwrap(); + bytes[marker_offset] ^= 1; + fs::write(journal.journal_path(&owner), bytes).unwrap(); + } + + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + journal.clear_account(&owner).unwrap(); + } + + #[test] + fn malformed_persisted_journal_id_fails_closed_until_clear() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal.checkpoint(&owner).unwrap(); + let mut bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let anchor = select_v3_anchor(&bytes).unwrap(); + let journal_id = anchor.journal_id.as_bytes(); + let snapshot_start = usize::try_from(anchor.snapshot_offset).unwrap(); + let snapshot_end = usize::try_from(anchor.data_start).unwrap(); + let relative = bytes[snapshot_start..snapshot_end] + .windows(journal_id.len()) + .position(|window| window == journal_id) + .unwrap(); + bytes[snapshot_start + relative] = b'g'; + fs::write(journal.journal_path(&owner), bytes).unwrap(); + } + + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(journal.clear_account(&owner).unwrap().sequence(), 0); + } + + #[test] + fn v3_selected_anchor_rejects_terminal_frame_truncation_without_fallback() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("acknowledged")) + .unwrap(); + let bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let selected = select_v3_anchor(&bytes).unwrap(); + let older = decode_v3_anchor_slot(&bytes, 0, &selected.file_nonce) + .unwrap() + .unwrap(); + assert!(selected.revision > older.revision); + assert!(selected.committed_end > older.committed_end); + let file = open_read_write_no_follow(&journal.journal_path(&owner)).unwrap(); + // The higher anchor remains checksum-valid in the fixed prefix. + // Recovery must not silently fall back to the older anchor merely + // because the bytes committed by the higher one are now missing. + file.set_len(older.committed_end).unwrap(); + file.sync_all().unwrap(); + } + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn v3_selected_anchor_rejects_one_byte_terminal_truncation() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("acknowledged")) + .unwrap(); + let bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let selected = select_v3_anchor(&bytes).unwrap(); + let file = open_read_write_no_follow(&journal.journal_path(&owner)).unwrap(); + file.set_len(selected.committed_end - 1).unwrap(); + file.sync_all().unwrap(); + } + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn v3_rejects_deletion_of_every_post_checkpoint_frame() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + let checkpoint_head = journal + .append( + &owner, + "session", + None, + TestPayload::new("checkpoint event"), + ) + .unwrap(); + journal + .store_checkpoint(&owner, &checkpoint_head, b"absolute checkpoint") + .unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("tail one")) + .unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("tail two")) + .unwrap(); + let bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let anchor = select_v3_anchor(&bytes).unwrap(); + let base = v3_chain_base(&anchor).unwrap(); + let (_, checkpoint_frame_end, _) = decode_v3_frame::( + &bytes, + usize::try_from(anchor.data_start).unwrap(), + usize::try_from(anchor.committed_end).unwrap(), + &base, + MAX_CHECKPOINT_BYTES, + ) + .unwrap(); + let file = open_read_write_no_follow(&journal.journal_path(&owner)).unwrap(); + file.set_len(u64::try_from(checkpoint_frame_end).unwrap()) + .unwrap(); + file.sync_all().unwrap(); + } + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.load_checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn v3_complete_unanchored_frame_fails_closed_without_mutation() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let (journal_path, unanchored_len) = { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("committed")) + .unwrap(); + let journal_path = journal.journal_path(&owner); + let bytes = fs::read(&journal_path).unwrap(); + let anchor = select_v3_anchor(&bytes).unwrap(); + let payload = TestPayload::new("complete but unanchored"); + let entry = StoredEntry { + sequence: 2, + session_id: "session".to_string(), + run_id: None, + commitment: event_commitment("session", None, &payload).unwrap(), + payload, + }; + let (frame, _) = encode_v3_frame(&entry, &anchor.committed_chain_hash).unwrap(); + let file = open_read_write_no_follow(&journal_path).unwrap(); + write_all_at(&file, &frame, anchor.committed_end).unwrap(); + file.sync_all().unwrap(); + ( + journal_path, + anchor.committed_end + u64::try_from(frame.len()).unwrap(), + ) + }; + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(fs::metadata(&journal_path).unwrap().len(), unanchored_len); + let bytes = fs::read(journal_path).unwrap(); + assert!(bytes + .windows(b"complete but unanchored".len()) + .any(|window| window == b"complete but unanchored")); + } + + #[test] + fn v3_every_nonempty_partial_anchor_write_fails_closed() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let (journal_path, pristine, anchor, frame, encoded_anchor) = { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal.checkpoint(&owner).unwrap(); + let journal_path = journal.journal_path(&owner); + let bytes = fs::read(&journal_path).unwrap(); + let anchor = select_v3_anchor(&bytes).unwrap(); + let payload = TestPayload::new("not acknowledged"); + let entry = StoredEntry { + sequence: 1, + session_id: "session".to_string(), + run_id: None, + commitment: event_commitment("session", None, &payload).unwrap(), + payload, + }; + let (frame, frame_hash) = + encode_v3_frame(&entry, &anchor.committed_chain_hash).unwrap(); + let mut next = anchor.clone(); + next.revision = 1; + next.slot_index = 1; + next.committed_end += u64::try_from(frame.len()).unwrap(); + next.committed_head_sequence = 1; + next.committed_frame_count = 1; + next.committed_chain_hash = frame_hash; + let encoded_anchor = encode_v3_anchor(&next).unwrap(); + (journal_path, bytes, anchor, frame, encoded_anchor) + }; + let slot_offset = DISK_SUPERBLOCK_BYTES + DISK_ANCHOR_SLOT_BYTES; + for partial_len in 0..DISK_ANCHOR_SLOT_BYTES { + let mut torn = pristine.clone(); + torn.extend_from_slice(&frame); + torn[slot_offset..slot_offset + partial_len] + .copy_from_slice(&encoded_anchor[..partial_len]); + let slot = &torn[slot_offset..slot_offset + DISK_ANCHOR_SLOT_BYTES]; + if slot.iter().all(|byte| *byte == 0) { + assert_eq!(select_v3_anchor(&torn).unwrap(), anchor); + } else if slot == encoded_anchor { + assert_eq!(select_v3_anchor(&torn).unwrap().revision, 1); + } else { + assert_eq!( + select_v3_anchor(&torn), + Err(LiveEventJournalError::StorageCorrupt), + "partial anchor length {partial_len} must fail closed" + ); + } + } + + let mut complete = pristine.clone(); + complete.extend_from_slice(&frame); + complete[slot_offset..slot_offset + DISK_ANCHOR_SLOT_BYTES] + .copy_from_slice(&encoded_anchor); + assert_eq!(select_v3_anchor(&complete).unwrap().revision, 1); + + let mut absent = pristine.clone(); + absent.extend_from_slice(&frame); + fs::write(&journal_path, &absent).unwrap(); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(fs::read(&journal_path).unwrap(), absent); + } + + let mut torn = pristine; + torn.extend_from_slice(&frame); + torn[slot_offset..slot_offset + DISK_ANCHOR_SLOT_BYTES / 2] + .copy_from_slice(&encoded_anchor[..DISK_ANCHOR_SLOT_BYTES / 2]); + fs::write(&journal_path, &torn).unwrap(); + let torn_len = u64::try_from(torn.len()).unwrap(); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(fs::metadata(journal_path).unwrap().len(), torn_len); + } + + #[test] + fn v3_checksum_valid_nonadjacent_anchor_slots_fail_closed() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + let bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let selected = select_v3_anchor(&bytes).unwrap(); + let mut forged = decode_v3_anchor_slot(&bytes, 0, &selected.file_nonce) + .unwrap() + .unwrap(); + forged.revision = 8; + forged.slot_index = 0; + let encoded = encode_v3_anchor(&forged).unwrap(); + let file = open_read_write_no_follow(&journal.journal_path(&owner)).unwrap(); + write_all_at( + &file, + &encoded, + u64::try_from(DISK_SUPERBLOCK_BYTES).unwrap(), + ) + .unwrap(); + file.sync_all().unwrap(); + } + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn v3_checksum_corrupt_older_slot_fails_closed() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + let bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let file = open_read_write_no_follow(&journal.journal_path(&owner)).unwrap(); + let checksum_offset = + u64::try_from(DISK_SUPERBLOCK_BYTES + DISK_ANCHOR_HASHED_BYTES).unwrap(); + let corrupted = bytes[usize::try_from(checksum_offset).unwrap()] ^ 1; + write_all_at(&file, &[corrupted], checksum_offset).unwrap(); + file.sync_all().unwrap(); + } + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn v3_checksum_corrupt_newest_slot_fails_closed_without_truncating_its_frame() { + for relative_offset in [112usize, DISK_ANCHOR_HASHED_BYTES] { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let (journal_path, committed_len) = { + let journal = + LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("acknowledged")) + .unwrap(); + let journal_path = journal.journal_path(&owner); + let bytes = fs::read(&journal_path).unwrap(); + let newest_slot_offset = DISK_SUPERBLOCK_BYTES + DISK_ANCHOR_SLOT_BYTES; + let corrupt_offset = newest_slot_offset + relative_offset; + let file = open_read_write_no_follow(&journal_path).unwrap(); + write_all_at( + &file, + &[bytes[corrupt_offset] ^ 1], + u64::try_from(corrupt_offset).unwrap(), + ) + .unwrap(); + file.sync_all().unwrap(); + (journal_path, u64::try_from(bytes.len()).unwrap()) + }; + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt), + "newest slot corruption at relative offset {relative_offset} must fail closed" + ); + assert_eq!(fs::metadata(journal_path).unwrap().len(), committed_len); + } + } + + #[test] + fn v3_every_committed_prefix_bit_flip_fails_closed() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("prefix-bit-matrix", 0); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("acknowledged")) + .unwrap(); + let bytes = fs::read(journal.journal_path(&owner)).unwrap(); + assert_eq!(select_v3_anchor(&bytes).unwrap().revision, 1); + + for offset in 0..DISK_PREFIX_BYTES { + for bit in 0..u8::BITS { + let mut corrupted = bytes[..DISK_PREFIX_BYTES].to_vec(); + corrupted[offset] ^= 1u8 << bit; + assert_eq!( + select_v3_anchor(&corrupted), + Err(LiveEventJournalError::StorageCorrupt), + "prefix bit {bit} at byte {offset} must not select an older anchor" + ); + } + } + } + + #[test] + fn v3_zeroed_newest_slot_with_newer_frame_fails_closed_without_rollback() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let (journal_path, committed_bytes) = { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("acknowledged")) + .unwrap(); + let journal_path = journal.journal_path(&owner); + let mut bytes = fs::read(&journal_path).unwrap(); + let newest_slot_offset = DISK_SUPERBLOCK_BYTES + DISK_ANCHOR_SLOT_BYTES; + bytes[newest_slot_offset..newest_slot_offset + DISK_ANCHOR_SLOT_BYTES].fill(0); + fs::write(&journal_path, &bytes).unwrap(); + (journal_path, bytes) + }; + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(fs::read(journal_path).unwrap(), committed_bytes); + } + + #[test] + fn v3_payload_independent_identity_reads_the_selected_anchor() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let head = journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + let identity = read_v3_disk_identity( + &journal.journal_path(&owner), + journal.inner.limits.max_disk_bytes().unwrap(), + ) + .unwrap(); + assert_eq!(identity.account_key, owner.account_key); + assert_eq!(identity.journal_id.as_str(), head.journal_id()); + assert_eq!(identity.committed_head_sequence, head.sequence()); + assert_eq!(identity.committed_end, identity.anchor.committed_end); + } + + #[test] + fn v3_golden_prefix_layout_has_one_anchor_and_exact_eof() { + let owner = owner("golden-layout", 0); + let mut header = JournalHeader { + version: JOURNAL_FORMAT_VERSION, + journal_id: "00112233445566778899aabbccddeeff".to_string(), + account_key: owner.account_key, + head_sequence: 0, + checkpoint: None, + event_ids: Vec::new(), + integrity: String::new(), + }; + header.integrity = journal_header_integrity(&header).unwrap(); + let encoded = encode_v3_journal::( + &header, + &VecDeque::new(), + [0x5au8; PROCESS_TOKEN_BYTES], + ) + .unwrap(); + assert_eq!(DISK_SUPERBLOCK_BYTES, 80); + assert_eq!(DISK_ANCHOR_SLOT_BYTES, 256); + assert_eq!(DISK_PREFIX_BYTES, 592); + assert_eq!(&encoded.bytes[0..8], DISK_SUPERBLOCK_MAGIC); + assert_eq!(get_u32(&encoded.bytes, 8).unwrap(), 3); + assert_eq!(get_u32(&encoded.bytes, 12).unwrap(), 80); + assert_eq!(get_u32(&encoded.bytes, 16).unwrap(), 592); + assert_eq!(get_u32(&encoded.bytes, 20).unwrap(), 256); + assert_eq!(&encoded.bytes[24..40], &[0x5a; PROCESS_TOKEN_BYTES]); + assert_eq!( + &encoded.bytes[DISK_SUPERBLOCK_BYTES..DISK_SUPERBLOCK_BYTES + 8], + DISK_ANCHOR_MAGIC + ); + assert!( + encoded.bytes[DISK_SUPERBLOCK_BYTES + DISK_ANCHOR_SLOT_BYTES..DISK_PREFIX_BYTES] + .iter() + .all(|byte| *byte == 0) + ); + assert_eq!(encoded.anchor.slot_index, 0); + assert_eq!(encoded.anchor.revision, 0); + assert_eq!(encoded.anchor.snapshot_offset, 592); + assert_eq!(encoded.anchor.data_start, encoded.anchor.committed_end); + assert_eq!( + u64::try_from(encoded.bytes.len()).unwrap(), + encoded.anchor.committed_end + ); + assert_eq!(select_v3_anchor(&encoded.bytes).unwrap(), encoded.anchor); + } + + #[test] + fn v3_rejects_frame_length_complement_corruption() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("one")) + .unwrap(); + let mut bytes = fs::read(journal.journal_path(&owner)).unwrap(); + let anchor = select_v3_anchor(&bytes).unwrap(); + let complement_offset = usize::try_from(anchor.data_start).unwrap() + 12; + bytes[complement_offset] ^= 1; + fs::write(journal.journal_path(&owner), bytes).unwrap(); + } + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn v2_format_fails_closed_until_explicit_clear() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("old")) + .unwrap(); + journal.unload_account(&owner).unwrap(); + fs::write( + journal.journal_path(&owner), + br#"{"version":2,"journalId":"00000000000000000000000000000000","accountKey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +"#, + ) + .unwrap(); + + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(journal.clear_account(&owner).unwrap().sequence(), 0); + } + + #[test] + fn account_file_quota_bounds_root_and_process_authority_state() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + ensure_private_directory(&path).unwrap(); + for index in 0..MAX_ACCOUNT_JOURNAL_FILES { + File::create(path.join(format!("{index:064x}.events"))).unwrap(); + } + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let owner = owner("one-account-too-many", 0); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageUnavailable) + ); + let state = journal.lock_state().unwrap(); + assert!(state.owners.len() <= MAX_ACCOUNT_JOURNAL_FILES); + drop(state); + + let mut owners = HashMap::new(); + for index in 0..MAX_ACCOUNT_JOURNAL_FILES { + owners.insert( + format!("{index:064x}"), + JournalOwnerState::Active { + generation: 0, + operation_token: new_process_token().unwrap(), + needs_resync: false, + ambiguous_append: None, + }, + ); + } + assert_eq!( + authorize_active_owner(&mut owners, &owner), + Err(LiveEventJournalError::StorageUnavailable) + ); + } + + #[test] + fn account_quota_covers_public_clear_and_first_rotation_paths() { + let parent = private_tempdir(); + let clear_path = parent.path().join("clear-journal"); + let clear_journal = LiveEventJournal::::open(clear_path, limits(4)).unwrap(); + for index in 0..MAX_ACCOUNT_JOURNAL_FILES { + clear_journal + .clear_account(&owner(&format!("account-{index}"), 0)) + .unwrap(); + } + assert_eq!( + clear_journal.clear_account(&owner("clear-overflow", 0)), + Err(LiveEventJournalError::StorageUnavailable) + ); + assert_eq!( + clear_journal.lock_state().unwrap().owners.len(), + MAX_ACCOUNT_JOURNAL_FILES + ); + + let rotate_path = parent.path().join("rotate-journal"); + let rotate_journal = LiveEventJournal::::open(rotate_path, limits(4)).unwrap(); + for index in 0..MAX_ACCOUNT_JOURNAL_FILES { + let scope = format!("rotate-account-{index}"); + rotate_journal + .rotate_account_generation(&owner(&scope, 0), &owner(&scope, 1)) + .unwrap(); + } + assert_eq!( + rotate_journal.rotate_account_generation( + &owner("rotate-overflow", 0), + &owner("rotate-overflow", 1), + ), + Err(LiveEventJournalError::StorageUnavailable) + ); + assert_eq!( + rotate_journal.lock_state().unwrap().owners.len(), + MAX_ACCOUNT_JOURNAL_FILES + ); + } + + #[test] + fn startup_scavenges_only_owned_temporary_files() { + let parent = private_tempdir(); + let path = parent.path().join("journal"); + ensure_private_directory(&path).unwrap(); + let temporary = path.join(format!("{TEMP_FILE_PREFIX}abandoned")); + File::create(&temporary).unwrap(); + LiveEventJournal::::open(path, limits(4)).unwrap(); + assert!(!temporary.exists()); + } + + #[test] + fn unanchored_partial_tail_fails_closed_without_mutation() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let (journal_path, damaged_len) = { + let journal = LiveEventJournal::open(path.clone(), limits(10)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("first")) + .unwrap(); + let journal_path = journal.journal_path(&owner); + let mut file = OpenOptions::new().append(true).open(&journal_path).unwrap(); + file.write_all(b"uncommitted-partial-v3-frame").unwrap(); + file.sync_all().unwrap(); + (journal_path, file.metadata().unwrap().len()) + }; + + let journal = LiveEventJournal::::open(path, limits(10)).unwrap(); + assert_eq!( + journal.checkpoint(&owner), + Err(LiveEventJournalError::StorageCorrupt) + ); + assert_eq!(fs::metadata(&journal_path).unwrap().len(), damaged_len); + let on_disk = fs::read(journal_path).unwrap(); + assert!(on_disk + .windows(b"uncommitted-partial-v3-frame".len()) + .any(|window| window == b"uncommitted-partial-v3-frame")); + } + + #[test] + fn explicit_retirement_fences_stale_leases_and_token_replay() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::open(path, limits(10)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let start = journal.checkpoint(&lease).unwrap(); + let ingress = journal.bind_ingress(&lease).unwrap(); + let payload = TestPayload::new("before-retirement"); + let head = journal + .append_outcome(&ingress, &start, "session", None, payload) + .unwrap() + .cursor() + .clone(); + + assert_eq!( + journal.seal_for_retirement(&lease, &start), + Err(LiveEventJournalError::HeadChanged) + ); + let retirement = journal.seal_for_retirement(&lease, &head).unwrap(); + assert_eq!( + journal.checkpoint(&lease), + Err(LiveEventJournalError::JournalRetired) + ); + journal.retire_account(&retirement).unwrap(); + assert!(!journal.journal_path(&owner).exists()); + assert_eq!( + journal.checkpoint(&lease), + Err(LiveEventJournalError::JournalRetired) + ); + assert!(!journal.journal_path(&owner).exists()); + + let replacement_lease = journal.activate_account(&owner).unwrap(); + let replacement = journal.checkpoint(&replacement_lease).unwrap(); + assert_ne!(replacement.journal_id(), head.journal_id()); + assert_ne!(replacement_lease.operation_token, lease.operation_token); + assert_eq!( + journal.retire_account(&retirement), + Err(LiveEventJournalError::JournalRetired) + ); + assert!(journal.journal_path(&owner).exists()); + assert_eq!( + journal.replay_after(&replacement_lease, &head, 10).unwrap(), + LiveReplayRead::SnapshotRequired(SnapshotRequired { + reason: SnapshotRequiredReason::JournalReplaced, + current_cursor: replacement, + }) + ); + } + + #[test] + fn authoritative_reseed_binds_observation_owner_projection_and_seal() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("reseed-account", 0); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let stale_lease = journal.activate_account(&owner).unwrap(); + journal.unload_account(&stale_lease).unwrap(); + fs::write(journal.journal_path(&owner), b"legacy-or-corrupt-v2\n").unwrap(); + + let required = reseed_required(journal.activate_account(&owner)); + assert_eq!(required.owner(), &owner); + assert_eq!( + journal.checkpoint(&stale_lease), + Err(LiveEventJournalError::ReseedRequired) + ); + let wrong_owner = self::owner("wrong-reseed-account", 0); + assert!(matches!( + journal.prepare_reseed_parts( + required, + &wrong_owner, + b"absolute projection", + [1; 32], + [2; 32], + ), + Err(LiveEventJournalError::InvalidCheckpoint) + )); + + let changed_required = reseed_required(journal.activate_account(&owner)); + fs::write( + journal.journal_path(&owner), + b"changed-corrupt-generation\n", + ) + .unwrap(); + assert!(matches!( + journal.prepare_reseed_parts( + changed_required, + &owner, + b"absolute projection", + [1; 32], + [2; 32], + ), + Err(LiveEventJournalError::JournalReplaced) + )); + + let required = reseed_required(journal.activate_account(&owner)); + let duplicate_required = reseed_required(journal.activate_account(&owner)); + let mut obligation = journal + .prepare_reseed_parts(required, &owner, b"absolute projection", [1; 32], [2; 32]) + .unwrap(); + assert!(matches!( + journal.prepare_reseed_parts( + duplicate_required, + &owner, + b"absolute projection", + [1; 32], + [3; 32], + ), + Err(LiveEventJournalError::JournalReplaced) + )); + assert!(matches!( + journal.commit_reseed(&obligation), + Err(LiveEventJournalError::OwnerTransitionIncomplete) + )); + journal.mark_reseed_sealed(&mut obligation).unwrap(); + let expected_journal_id = obligation.new_journal_id.clone(); + let activation = journal.commit_reseed(&obligation).unwrap(); + let (lease, cursor) = activation.into_parts(); + assert_eq!(cursor.journal_id(), expected_journal_id); + assert_eq!(cursor.sequence(), 0); + assert_eq!( + journal.load_checkpoint(&lease).unwrap().unwrap(), + LiveProjectionCheckpoint { + through_cursor: cursor, + bytes: b"absolute projection".to_vec(), + } + ); + assert_eq!( + journal.checkpoint(&stale_lease), + Err(LiveEventJournalError::JournalRetired) + ); + assert!(matches!( + journal.commit_reseed(&obligation), + Err(LiveEventJournalError::JournalReplaced) + )); + } + + #[test] + fn malformed_persisted_event_id_requires_authoritative_reseed() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("malformed-persisted-event-id", 0); + { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let start = journal.checkpoint(&lease).unwrap(); + let ingress = journal.bind_ingress(&lease).unwrap(); + let head = journal + .append_outcome( + &ingress, + &start, + "session", + None, + TestPayload::new("persisted event"), + ) + .unwrap() + .cursor() + .clone(); + journal + .store_checkpoint(&lease, &head, b"projection before corruption") + .unwrap(); + journal.unload_account(&lease).unwrap(); + rewrite_header(&journal.journal_path(&owner), |header| { + header.event_ids[0].event_id.clear(); + }); + } + + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let required = reseed_required(journal.activate_account(&owner)); + let mut obligation = journal + .prepare_reseed_parts( + required, + &owner, + b"authoritative projection after corrupt event ID", + [7; 32], + [8; 32], + ) + .unwrap(); + journal.mark_reseed_sealed(&mut obligation).unwrap(); + let activation = journal.commit_reseed(&obligation).unwrap(); + let (lease, cursor) = activation.into_parts(); + assert_eq!(cursor.sequence(), 0); + assert_eq!( + journal.load_checkpoint(&lease).unwrap().unwrap(), + LiveProjectionCheckpoint { + through_cursor: cursor, + bytes: b"authoritative projection after corrupt event ID".to_vec(), + } + ); + } + + #[test] + fn reseed_replacement_boundaries_reopen_as_exactly_observed_or_fresh() { + for (boundary, replacement_committed) in [ + (ReplaceFailureBoundary::BeforeFileSync, false), + (ReplaceFailureBoundary::AfterFileSync, false), + (ReplaceFailureBoundary::AfterPersist, true), + (ReplaceFailureBoundary::AfterDirectorySync, true), + ] { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner(&format!("reseed-boundary-{boundary:?}"), 0); + let expected_journal_id = { + let journal = + LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + journal.unload_account(&lease).unwrap(); + fs::write(journal.journal_path(&owner), b"broken-v2\n").unwrap(); + let required = reseed_required(journal.activate_account(&owner)); + let mut obligation = journal + .prepare_reseed_parts( + required, + &owner, + b"authoritative absolute projection", + [3; 32], + [4; 32], + ) + .unwrap(); + journal.mark_reseed_sealed(&mut obligation).unwrap(); + let expected_journal_id = obligation.new_journal_id.clone(); + journal.fail_next_replace_at(boundary); + assert!(matches!( + journal.commit_reseed(&obligation), + Err(LiveEventJournalError::StorageUnavailable) + )); + expected_journal_id + }; + + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + if replacement_committed { + let lease = journal.activate_account(&owner).unwrap(); + let cursor = journal.checkpoint(&lease).unwrap(); + assert_eq!(cursor.journal_id(), expected_journal_id); + assert_eq!(cursor.sequence(), 0); + assert_eq!( + journal.load_checkpoint(&lease).unwrap().unwrap().bytes, + b"authoritative absolute projection" + ); + } else { + let required = reseed_required(journal.activate_account(&owner)); + assert_eq!(required.owner(), &owner); + } + } + } + + #[test] + fn reseed_postpersist_error_is_exactly_retryable_in_process() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("reseed-exact-retry", 0); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + journal.unload_account(&lease).unwrap(); + fs::write(journal.journal_path(&owner), b"broken-v2\n").unwrap(); + let required = reseed_required(journal.activate_account(&owner)); + let mut obligation = journal + .prepare_reseed_parts( + required, + &owner, + b"authoritative absolute projection", + [5; 32], + [6; 32], + ) + .unwrap(); + journal.mark_reseed_sealed(&mut obligation).unwrap(); + let expected_journal_id = obligation.new_journal_id.clone(); + journal.fail_next_replace_at(ReplaceFailureBoundary::AfterPersist); + assert!(matches!( + journal.commit_reseed(&obligation), + Err(LiveEventJournalError::StorageUnavailable) + )); + let activation = journal.commit_reseed(&obligation).unwrap(); + assert_eq!(activation.cursor.journal_id(), expected_journal_id); + assert_eq!(activation.cursor.sequence(), 0); + } + + #[test] + fn retirement_crash_boundaries_remain_fenced_and_retryable() { + for boundary in [ + RetirementFailureBoundary::BeforeRename, + RetirementFailureBoundary::AfterRename, + RetirementFailureBoundary::AfterRenameDirectorySync, + RetirementFailureBoundary::AfterUnlink, + RetirementFailureBoundary::AfterFinalDirectorySync, + ] { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner(&format!("retire-{boundary:?}"), 0); + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let head = journal.checkpoint(&lease).unwrap(); + let retirement = journal.seal_for_retirement(&lease, &head).unwrap(); + let pending = journal.retirement_path(&owner, &retirement.retirement_nonce); + journal.fail_next_retirement_at(boundary); + assert_eq!( + journal.retire_account(&retirement), + Err(LiveEventJournalError::StorageUnavailable), + "boundary {boundary:?}" + ); + assert_eq!( + journal.checkpoint(&lease), + Err(LiveEventJournalError::JournalRetired), + "boundary {boundary:?}" + ); + match boundary { + RetirementFailureBoundary::BeforeRename => { + assert!(journal.journal_path(&owner).exists()); + assert!(!pending.exists()); + } + RetirementFailureBoundary::AfterRename + | RetirementFailureBoundary::AfterRenameDirectorySync => { + assert!(!journal.journal_path(&owner).exists()); + assert!(pending.exists()); + } + RetirementFailureBoundary::AfterUnlink + | RetirementFailureBoundary::AfterFinalDirectorySync => { + assert!(!journal.journal_path(&owner).exists()); + assert!(!pending.exists()); + } + RetirementFailureBoundary::None => unreachable!(), + } + journal.retire_account(&retirement).unwrap(); + assert!(!journal.journal_path(&owner).exists()); + assert!(!pending.exists()); + assert!(!journal + .lock_state() + .unwrap() + .owners + .contains_key(&owner.account_key)); + } + } + + #[test] + fn retirement_reopen_resolves_precommit_source_or_committed_pending() { + for boundary in [ + RetirementFailureBoundary::BeforeRename, + RetirementFailureBoundary::AfterRename, + RetirementFailureBoundary::AfterRenameDirectorySync, + RetirementFailureBoundary::AfterUnlink, + RetirementFailureBoundary::AfterFinalDirectorySync, + ] { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner(&format!("reopen-{boundary:?}"), 0); + let old_head = { + let journal = + LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let old_head = journal.checkpoint(&lease).unwrap(); + let retirement = journal.seal_for_retirement(&lease, &old_head).unwrap(); + journal.fail_next_retirement_at(boundary); + assert_eq!( + journal.retire_account(&retirement), + Err(LiveEventJournalError::StorageUnavailable) + ); + old_head + }; + + let journal = LiveEventJournal::::open(path, limits(4)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let recovered = journal.checkpoint(&lease).unwrap(); + if boundary == RetirementFailureBoundary::BeforeRename { + assert_eq!(recovered, old_head); + } else { + assert_ne!(recovered.journal_id(), old_head.journal_id()); + assert_eq!(recovered.sequence(), 0); + } + } + } + + #[test] + fn authorized_retirement_frees_one_of_sixty_four_account_slots() { + let root = private_tempdir(); + let journal = + LiveEventJournal::::open(root.path().join("journal"), limits(4)).unwrap(); + let mut activated = Vec::new(); + for index in 0..MAX_ACCOUNT_JOURNAL_FILES { + let owner = owner(&format!("quota-owner-{index}"), 0); + let lease = journal.activate_account(&owner).unwrap(); + let head = journal.checkpoint(&lease).unwrap(); + activated.push((owner, lease, head)); + } + let overflow_owner = owner("quota-overflow", 0); + assert_eq!( + journal.activate_account(&overflow_owner), + Err(LiveEventJournalActivationError::Journal( + LiveEventJournalError::StorageUnavailable + )) + ); + + let (retired_owner, retired_lease, retired_head) = activated.remove(0); + let retirement = journal + .seal_for_retirement(&retired_lease, &retired_head) + .unwrap(); + journal.retire_account(&retirement).unwrap(); + let overflow_lease = journal.activate_account(&overflow_owner).unwrap(); + assert_eq!(journal.checkpoint(&overflow_lease).unwrap().sequence(), 0); + assert_eq!( + journal.checkpoint(&retired_lease), + Err(LiveEventJournalError::JournalRetired) + ); + assert!(!journal.journal_path(&retired_owner).exists()); + assert_eq!( + journal.lock_state().unwrap().owners.len(), + MAX_ACCOUNT_JOURNAL_FILES + ); + } + + #[test] + fn repeated_activate_retire_cycles_do_not_accumulate_owner_tombstones() { + let root = private_tempdir(); + let journal = + LiveEventJournal::::open(root.path().join("journal"), limits(4)).unwrap(); + let owner = owner("repeated-retirement", 0); + let mut prior_journal_id = None; + for _ in 0..256 { + let lease = journal.activate_account(&owner).unwrap(); + let head = journal.checkpoint(&lease).unwrap(); + if let Some(prior) = prior_journal_id.replace(head.journal_id().to_string()) { + assert_ne!(prior, head.journal_id()); + } + let retirement = journal.seal_for_retirement(&lease, &head).unwrap(); + journal.retire_account(&retirement).unwrap(); + assert!(journal.lock_state().unwrap().owners.is_empty()); + assert!(!journal.journal_path(&owner).exists()); + } + let residual_files = fs::read_dir(&journal.inner.root) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry.file_name().to_str().is_some_and(|name| { + is_account_journal_file_name(name) || parse_retiring_file_name(name).is_some() + }) + }) + .count(); + assert_eq!(residual_files, 0); + } + + #[test] + fn startup_scavenges_only_valid_pending_retirements_and_rejects_collisions() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("pending-account", 0); + let (source, pending) = { + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + let lease = journal.activate_account(&owner).unwrap(); + let head = journal.checkpoint(&lease).unwrap(); + let retirement = journal.seal_for_retirement(&lease, &head).unwrap(); + let source = journal.journal_path(&owner); + let pending = journal.retirement_path(&owner, &retirement.retirement_nonce); + (source, pending) + }; + fs::copy(&source, &pending).unwrap(); + assert_eq!( + LiveEventJournal::::open(path.clone(), limits(4)).err(), + Some(LiveEventJournalError::StorageCorrupt) + ); + assert!(source.exists()); + assert!(pending.exists()); + fs::remove_file(&source).unwrap(); + LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + assert!(!pending.exists()); + + let malformed_root = private_tempdir(); + let malformed_path = malformed_root.path().join("journal"); + ensure_private_directory(&malformed_path).unwrap(); + File::create(malformed_path.join(format!("{RETIRING_FILE_PREFIX}malformed"))).unwrap(); + assert_eq!( + LiveEventJournal::::open(malformed_path, limits(4)).err(), + Some(LiveEventJournalError::StorageCorrupt) + ); + } + + #[test] + fn authorized_clear_recovers_a_corrupt_journal_without_parsing_it() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let owner = owner("opaque-account", 0); + let journal = LiveEventJournal::open(path.clone(), limits(10)).unwrap(); + let old = journal + .append(&owner, "session", None, TestPayload::new("old")) + .unwrap(); + journal.unload_account(&owner).unwrap(); + fs::write(journal.journal_path(&owner), b"corrupt\n").unwrap(); + + let reset = journal.clear_account(&owner).unwrap(); + assert_ne!(reset.journal_id(), old.journal_id()); + assert_eq!(reset.sequence(), 0); + assert_eq!(journal.checkpoint(&owner).unwrap(), reset); + } + + #[test] + fn a_second_independent_host_cannot_open_the_same_root() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let first = LiveEventJournal::::open(path.clone(), limits(10)).unwrap(); + assert!(matches!( + LiveEventJournal::::open(path.clone(), limits(10)), + Err(LiveEventJournalError::AlreadyOpen) + )); + drop(first); + LiveEventJournal::::open(path, limits(10)).unwrap(); + } + + #[test] + fn cloned_journal_serializes_concurrent_appends() { + let root = private_tempdir(); + let journal = LiveEventJournal::open(root.path().join("journal"), limits(16)).unwrap(); + let owner = owner("opaque-account", 0); + let start = journal.checkpoint(&owner).unwrap(); + let barrier = Arc::new(Barrier::new(8)); + let mut workers = Vec::new(); + for index in 0..8 { + let journal = journal.clone(); + let owner = owner.clone(); + let barrier = Arc::clone(&barrier); + workers.push(thread::spawn(move || { + barrier.wait(); + journal + .append( + &owner, + "session", + Some("run"), + TestPayload::new(index.to_string()), + ) + .unwrap() + .sequence() + })); + } + let sequences = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + assert_eq!(sequences, (1..=8).collect()); + let (entries, _, _) = events(journal.replay_after(&owner, &start, 16).unwrap()); + assert_eq!(entries.len(), 8); + } + + #[cfg(unix)] + #[test] + fn journal_directory_and_file_are_owner_only() { + let root = private_tempdir(); + let path = root.path().join("journal"); + let journal = LiveEventJournal::open(path.clone(), limits(4)).unwrap(); + let owner = owner("opaque-account", 0); + journal + .append(&owner, "session", None, TestPayload::new("event")) + .unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(journal.journal_path(&owner)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn journal_strips_inherited_macos_acl_entries() { + use std::process::Command; + + let parent = private_tempdir(); + let path = parent.path().join("journal"); + let owner = owner("opaque-account", 0); + let account_path = { + let journal = LiveEventJournal::open(path.clone(), limits(4)).unwrap(); + journal + .append(&owner, "session", None, TestPayload::new("event")) + .unwrap(); + journal.journal_path(&owner) + }; + + for candidate in [&path, &account_path] { + assert!(Command::new("/bin/chmod") + .args(["+a", "everyone allow read"]) + .arg(candidate) + .status() + .unwrap() + .success()); + } + let journal = LiveEventJournal::::open(path.clone(), limits(4)).unwrap(); + journal.checkpoint(&owner).unwrap(); + + for candidate in [path, account_path] { + let output = Command::new("/bin/ls") + .args(["-lde"]) + .arg(&candidate) + .output() + .unwrap(); + assert!(output.status.success()); + let listing = String::from_utf8(output.stdout).unwrap(); + assert!( + !listing.lines().skip(1).any(|line| line.contains(" allow ")), + "extended ACL remained on {}: {listing}", + candidate.display() + ); + } + } +} diff --git a/frontend/src-tauri/src/agent_live_authority.rs b/frontend/src-tauri/src/agent_live_authority.rs new file mode 100644 index 000000000..7a084ce7b --- /dev/null +++ b/frontend/src-tauri/src/agent_live_authority.rs @@ -0,0 +1,287 @@ +//! Lower-layer, non-serializable durability capabilities shared by the Agent +//! host composition and event journal. +//! +//! These types deliberately contain no business logic and expose no general +//! constructor. The future exact Goose persistence adapter must live in the +//! private `mint` module below; until that reviewed adapter exists, authoritative +//! reseed and persisted-head acknowledgement remain impossible rather than +//! accepting renderer/provider scalars as proof. + +#![allow( + dead_code, + reason = "the exact Goose durable-persistence adapter is a later integration slice" +)] + +use crate::{ + agent::{AgentLiveEventCursor, AgentPagingError}, + agent_event_journal::LiveEventAccountOwner, + agent_live_binding::AgentLiveBindingLease, +}; + +const MAX_DURABLE_STABLE_OPERATION_ID_BYTES: usize = 128; +pub(crate) const AGENT_LIVE_PROJECTION_SCHEMA_VERSION: u16 = 1; + +/// Opaque identity of one native, durably recorded logical mutation. +/// +/// The wire journal event ID is derived from this value plus the journal and +/// route namespace, but this capability itself is never serialized or minted +/// from a renderer/remote scalar. Reconstructing it after restart belongs to +/// the future exact Goose persistence adapter in the private `mint` module. +/// It is deliberately non-Clone so creating a new logical mutation cannot be +/// confused with copying authority; exact retries borrow the same capability. +pub(crate) struct AgentDurableStableOperationId { + owner: AgentLiveDataOwnerKey, + session_id: String, + run_id: Option, + stable_id: String, + journal_namespace_commitment: [u8; 32], + projection_schema_version: u16, + payload_commitment: [u8; 32], +} + +impl std::fmt::Debug for AgentDurableStableOperationId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AgentDurableStableOperationId") + .field("owner", &"") + .field("session_id", &"") + .field("run_id", &"") + .field("stable_id", &"") + .field("journal_namespace_commitment", &"") + .field("projection_schema_version", &self.projection_schema_version) + .field("payload_commitment", &"") + .finish() + } +} + +impl AgentDurableStableOperationId { + pub(crate) fn owner(&self) -> &AgentLiveDataOwnerKey { + &self.owner + } + + pub(crate) fn session_id(&self) -> &str { + &self.session_id + } + + pub(crate) fn run_id(&self) -> Option<&str> { + self.run_id.as_deref() + } + + pub(crate) fn as_str(&self) -> &str { + &self.stable_id + } + + pub(crate) const fn journal_namespace_commitment(&self) -> &[u8; 32] { + &self.journal_namespace_commitment + } + + pub(crate) const fn projection_schema_version(&self) -> u16 { + self.projection_schema_version + } + + pub(crate) const fn payload_commitment(&self) -> &[u8; 32] { + &self.payload_commitment + } + + #[cfg(test)] + pub(crate) fn for_test( + owner: AgentLiveDataOwnerKey, + session_id: impl Into, + run_id: Option, + stable_id: impl Into, + journal_namespace_commitment: [u8; 32], + payload_commitment: [u8; 32], + ) -> Self { + let session_id = session_id.into(); + let stable_id = stable_id.into(); + assert!(!session_id.is_empty()); + assert!(run_id.as_deref().is_none_or(|run_id| !run_id.is_empty())); + assert!(is_valid_stable_operation_id(&stable_id)); + Self { + owner, + session_id, + run_id, + stable_id, + journal_namespace_commitment, + projection_schema_version: AGENT_LIVE_PROJECTION_SCHEMA_VERSION, + payload_commitment, + } + } +} + +fn is_valid_stable_operation_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_DURABLE_STABLE_OPERATION_ID_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'\\' | b'"')) +} + +/// Opaque identity of one account data owner. Peer reconnect and pairing +/// lineage are intentionally absent; target or data-generation changes create +/// a different identity. +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) struct AgentLiveDataOwnerKey { + account_scope: String, + account_generation: u64, + execution_target: String, + data_lineage_epoch: u64, +} + +impl std::fmt::Debug for AgentLiveDataOwnerKey { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AgentLiveDataOwnerKey") + .field("account_scope", &"") + .field("account_generation", &self.account_generation) + .field("execution_target", &"") + .field("data_lineage_epoch", &self.data_lineage_epoch) + .finish() + } +} + +impl AgentLiveDataOwnerKey { + pub(crate) fn from_binding_lease(lease: &AgentLiveBindingLease) -> Self { + Self { + account_scope: lease.account_scope().to_string(), + account_generation: lease.account_generation(), + execution_target: lease.execution_target().as_str().to_string(), + data_lineage_epoch: lease.lineage_epoch(), + } + } + + pub(crate) const fn account_generation(&self) -> u64 { + self.account_generation + } + + pub(crate) fn execution_target(&self) -> &str { + &self.execution_target + } + + #[cfg(test)] + pub(crate) fn for_test( + account_scope: impl Into, + account_generation: u64, + execution_target: impl Into, + data_lineage_epoch: u64, + ) -> Self { + Self { + account_scope: account_scope.into(), + account_generation, + execution_target: execution_target.into(), + data_lineage_epoch, + } + } +} + +/// Exact durable Goose-head receipt. Non-Clone and non-serializable; no sibling +/// module can mint it from a session/revision/cursor tuple. +pub(crate) struct AgentDurableHeadCommitReceipt { + stable_operation: AgentDurableStableOperationId, + history_revision: String, + through_event_cursor: AgentLiveEventCursor, +} + +impl std::fmt::Debug for AgentDurableHeadCommitReceipt { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AgentDurableHeadCommitReceipt") + .field("stable_operation", &self.stable_operation) + .field("history_revision", &"") + .field("through_event_cursor", &"") + .finish() + } +} + +impl AgentDurableHeadCommitReceipt { + pub(crate) fn stable_operation(&self) -> &AgentDurableStableOperationId { + &self.stable_operation + } + + pub(crate) fn history_revision(&self) -> &str { + &self.history_revision + } + + pub(crate) fn through_event_cursor(&self) -> &AgentLiveEventCursor { + &self.through_event_cursor + } + + #[cfg(test)] + pub(crate) fn for_test( + stable_operation: AgentDurableStableOperationId, + history_revision: impl Into, + through_event_cursor: AgentLiveEventCursor, + ) -> Self { + Self { + stable_operation, + history_revision: history_revision.into(), + through_event_cursor, + } + } +} + +/// One-use proof that an exact, currently bound data owner has an absolute +/// projection derived from a durably committed Goose head. +pub(crate) struct VerifiedJournalReseedAuthority { + owner: LiveEventAccountOwner, + binding: AgentLiveDataOwnerKey, + projection_bytes: Box<[u8]>, + durable_head_commitment: [u8; 32], + nonce: [u8; 32], +} + +impl std::fmt::Debug for VerifiedJournalReseedAuthority { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VerifiedJournalReseedAuthority") + .field("owner", &self.owner) + .field("binding", &self.binding) + .field( + "projection_bytes", + &format_args!("<{} bytes>", self.projection_bytes.len()), + ) + .field("durable_head_commitment", &"") + .field("nonce", &"") + .finish() + } +} + +impl VerifiedJournalReseedAuthority { + pub(crate) fn owner(&self) -> &LiveEventAccountOwner { + &self.owner + } + + pub(crate) fn binding_key(&self) -> &AgentLiveDataOwnerKey { + &self.binding + } + + pub(crate) fn projection_bytes(&self) -> &[u8] { + &self.projection_bytes + } + + pub(crate) const fn durable_head_commitment(&self) -> &[u8; 32] { + &self.durable_head_commitment + } + + pub(crate) const fn nonce(&self) -> &[u8; 32] { + &self.nonce + } +} + +/// Only the reviewed Goose persistence adapter belongs here. Keeping minting +/// private prevents other crate siblings from turning copied scalars into a +/// durability capability. The placeholder makes the intended ownership seam +/// explicit without exposing a constructor before that adapter is implemented. +mod mint { + use super::*; + + #[allow(unused_imports)] + use crate::agent::AgentRuntimeHandle; + + #[allow(dead_code)] + fn exact_goose_adapter_not_yet_integrated( + _: &AgentRuntimeHandle, + ) -> Result<(), AgentPagingError> { + Err(AgentPagingError::Unavailable) + } +} diff --git a/frontend/src-tauri/src/agent_live_binding.rs b/frontend/src-tauri/src/agent_live_binding.rs new file mode 100644 index 000000000..6a66ff11f --- /dev/null +++ b/frontend/src-tauri/src/agent_live_binding.rs @@ -0,0 +1,2212 @@ +//! Fail-closed binding of synchronized Agent state to one installed Maple +//! authorization context, product host registration, controller pairing, and +//! complete transport connection stamp. +//! +//! Persisted history paging deliberately remains usable without this state. +//! Live attach/resume must retain an exact [`AgentLiveBindingLease`] and +//! revalidate it after every asynchronous boundary. + +#![allow( + dead_code, + reason = "the verified pairing adapter is wired by the remote Agent slice" +)] + +use crate::{ + remote_protocol::ConnectionStamp, + remote_transport::{ + AuthorizationTransitionReceipt, InstalledAuthorizationContext, + InstalledAuthorizationDomain, PairingFence, VerifiedIncomingPeerAuthorization, + }, +}; +use std::{cmp::Ordering, collections::HashMap, fmt, sync::Arc}; +use tokio::sync::Mutex; + +const MAX_ACCOUNT_SCOPE_BYTES: usize = 256; +const MAX_HOST_REGISTRATION_ID_BYTES: usize = 128; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct AgentExecutionTargetId(Arc); + +impl AgentExecutionTargetId { + fn from_verified_registration(value: String) -> Result { + validate_bounded_id(&value, MAX_HOST_REGISTRATION_ID_BYTES) + .map_err(|_| AgentLiveBindingError::InvalidVerifiedBinding)?; + // The product target is the stable host-registration UUID, never an + // endpoint key, hostname, or friendly/local alias. + if !looks_like_non_nil_uuid(&value) { + return Err(AgentLiveBindingError::InvalidVerifiedBinding); + } + Ok(Self(Arc::from(value))) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +/// Installation-local authorization version. Independent installations never +/// compare or exchange these values; the pairing incarnation is the directed +/// shared wire fence. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct LocalAuthorizationContext { + account_epoch: u64, + snapshot_revision: u64, + snapshot_digest: [u8; 32], +} + +impl LocalAuthorizationContext { + fn from_installed( + installed: &InstalledAuthorizationContext, + ) -> Result { + if installed.account_epoch() == 0 || installed.snapshot_revision() == 0 { + return Err(AgentLiveBindingError::InvalidVerifiedBinding); + } + Ok(Self { + account_epoch: installed.account_epoch(), + snapshot_revision: installed.snapshot_revision(), + snapshot_digest: installed.snapshot_digest(), + }) + } + + pub(crate) const fn account_epoch(&self) -> u64 { + self.account_epoch + } + + pub(crate) const fn snapshot_revision(&self) -> u64 { + self.snapshot_revision + } + + #[cfg(test)] + pub(crate) const fn for_test( + account_epoch: u64, + snapshot_revision: u64, + snapshot_digest: [u8; 32], + ) -> Self { + Self { + account_epoch, + snapshot_revision, + snapshot_digest, + } + } +} + +/// Capability minted only after the endpoint revalidates an authenticated +/// controller against its currently installed authorization snapshot. +/// +/// This constructor never accepts pairing payload lifecycle revisions or +/// renderer-supplied target/stamp fields. +pub(crate) struct VerifiedAgentTargetBinding { + remote_authority: Option, + account_scope: String, + account_generation: u64, + execution_target: AgentExecutionTargetId, + controller_endpoint: iroh::EndpointId, + authorization: LocalAuthorizationContext, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, +} + +impl VerifiedAgentTargetBinding { + pub(crate) fn from_verified_remote_adapter( + account_scope: String, + account_generation: u64, + installed: VerifiedIncomingPeerAuthorization, + ) -> Result { + installed + .revalidate_current() + .map_err(|_| AgentLiveBindingError::StaleBinding)?; + validate_bounded_id(&account_scope, MAX_ACCOUNT_SCOPE_BYTES) + .map_err(|_| AgentLiveBindingError::InvalidVerifiedBinding)?; + let execution_target_id = installed.execution_target_id().to_string(); + let controller_endpoint = installed.controller_endpoint(); + let authorization = LocalAuthorizationContext::from_installed(installed.authorization())?; + let pairing_fence = installed.pairing_fence(); + let connection_stamp = installed.connection_stamp(); + Ok(Self { + remote_authority: Some(installed), + account_scope, + account_generation, + execution_target: AgentExecutionTargetId::from_verified_registration( + // Fields were copied above only after the opaque native + // capability revalidated its current admission record. + execution_target_id.to_string(), + )?, + controller_endpoint, + authorization, + pairing_fence, + connection_stamp, + }) + } + + fn revalidate_current(&self) -> Result<(), AgentLiveBindingError> { + match self.remote_authority.as_ref() { + Some(authority) => authority + .revalidate_current() + .map_err(|_| AgentLiveBindingError::StaleBinding), + #[cfg(test)] + None => Ok(()), + #[cfg(not(test))] + None => Err(AgentLiveBindingError::InvalidVerifiedBinding), + } + } +} + +/// Exact peer access lease. Data, pairing, and transport lineages are retained +/// independently so an ordinary reconnect never rotates persisted history. +#[derive(Debug, Clone)] +pub(crate) struct AgentLiveBindingLease { + account_scope: Arc, + account_generation: u64, + execution_target: AgentExecutionTargetId, + controller_endpoint: iroh::EndpointId, + authorization: LocalAuthorizationContext, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + data_lineage_epoch: u64, + peer_lineage_epoch: u64, + remote_authority: Option, +} + +impl PartialEq for AgentLiveBindingLease { + fn eq(&self, other: &Self) -> bool { + self.account_scope == other.account_scope + && self.account_generation == other.account_generation + && self.execution_target == other.execution_target + && self.controller_endpoint == other.controller_endpoint + && self.authorization == other.authorization + && self.pairing_fence == other.pairing_fence + && self.connection_stamp == other.connection_stamp + && self.data_lineage_epoch == other.data_lineage_epoch + && self.peer_lineage_epoch == other.peer_lineage_epoch + && same_remote_authority_instance( + self.remote_authority.as_ref(), + other.remote_authority.as_ref(), + ) + } +} + +impl Eq for AgentLiveBindingLease {} + +impl AgentLiveBindingLease { + pub(crate) fn account_scope(&self) -> &str { + &self.account_scope + } + + pub(crate) const fn account_generation(&self) -> u64 { + self.account_generation + } + + pub(crate) fn execution_target(&self) -> &AgentExecutionTargetId { + &self.execution_target + } + + pub(crate) const fn controller_endpoint(&self) -> iroh::EndpointId { + self.controller_endpoint + } + + pub(crate) fn authorization(&self) -> &LocalAuthorizationContext { + &self.authorization + } + + pub(crate) const fn pairing_fence(&self) -> PairingFence { + self.pairing_fence + } + + pub(crate) const fn connection_stamp(&self) -> ConnectionStamp { + self.connection_stamp + } + + /// Backward-compatible name used by the host context key. This is the data + /// lineage only; pairing and reconnect refreshes preserve it. + pub(crate) const fn lineage_epoch(&self) -> u64 { + self.data_lineage_epoch + } + + pub(crate) const fn peer_lineage_epoch(&self) -> u64 { + self.peer_lineage_epoch + } + + pub(crate) fn revalidate_current_authority(&self) -> Result<(), AgentLiveBindingError> { + match self.remote_authority.as_ref() { + Some(authority) => authority + .revalidate_current() + .map_err(|_| AgentLiveBindingError::StaleBinding), + #[cfg(test)] + None => Ok(()), + #[cfg(not(test))] + None => Err(AgentLiveBindingError::InvalidVerifiedBinding), + } + } + + pub(crate) fn remote_authority(&self) -> Option<&VerifiedIncomingPeerAuthorization> { + self.remote_authority.as_ref() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentLiveRotationObligation { + previous: AgentLiveBindingLease, + proposed: AgentLiveBindingLease, + transition_epoch: u64, +} + +impl AgentLiveRotationObligation { + pub(crate) fn previous(&self) -> &AgentLiveBindingLease { + &self.previous + } + + pub(crate) fn proposed(&self) -> &AgentLiveBindingLease { + &self.proposed + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AgentLiveBindOutcome { + Bound(AgentLiveBindingLease), + RotationRequired(AgentLiveRotationObligation), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentLiveBindingError { + InvalidVerifiedBinding, + Unbound, + WrongAccount, + StaleBinding, + AuthorizationConflict, + TransitionInProgress, + TransitionMismatch, + NonAdjacentGeneration, + EpochExhausted, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RevokedAgentLivePeer { + pub(crate) lease: AgentLiveBindingLease, +} + +#[derive(Debug)] +pub(crate) struct AppliedAgentAuthorizationTransition { + revoked_peers: Vec, + account_epoch_changed: bool, +} + +impl AppliedAgentAuthorizationTransition { + pub(crate) fn revoked_peers(&self) -> &[RevokedAgentLivePeer] { + &self.revoked_peers + } + + pub(crate) const fn account_epoch_changed(&self) -> bool { + self.account_epoch_changed + } +} + +impl fmt::Display for AgentLiveBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidVerifiedBinding => "verified Agent host binding is invalid", + Self::Unbound => "synchronized Agent history requires a verified host registration", + Self::WrongAccount => "Agent host registration belongs to another account", + Self::StaleBinding => "Agent host registration binding is stale", + Self::AuthorizationConflict => "Agent authorization state is conflicting", + Self::TransitionInProgress => "Agent host registration is rotating", + Self::TransitionMismatch => "Agent host registration rotation does not match", + Self::NonAdjacentGeneration => "Agent account data generation requires a full reset", + Self::EpochExhausted => "Agent host registration lineage is exhausted", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AgentLiveBindingError {} + +#[derive(Debug, Clone)] +struct PeerBinding { + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + peer_lineage_epoch: u64, + remote_authority: Option, +} + +#[derive(Debug, Clone)] +struct ActiveBinding { + account_scope: Arc, + account_generation: u64, + execution_target: AgentExecutionTargetId, + authorization: LocalAuthorizationContext, + data_lineage_epoch: u64, + peers: HashMap, +} + +impl ActiveBinding { + fn lease_for(&self, controller_endpoint: iroh::EndpointId) -> Option { + let peer = self.peers.get(&controller_endpoint)?; + Some(AgentLiveBindingLease { + account_scope: Arc::clone(&self.account_scope), + account_generation: self.account_generation, + execution_target: self.execution_target.clone(), + controller_endpoint, + authorization: self.authorization.clone(), + pairing_fence: peer.pairing_fence, + connection_stamp: peer.connection_stamp, + data_lineage_epoch: self.data_lineage_epoch, + peer_lineage_epoch: peer.peer_lineage_epoch, + remote_authority: peer.remote_authority.clone(), + }) + } +} + +#[derive(Clone)] +enum RegistryBindingState { + Unbound, + Active(ActiveBinding), + Transition { + previous: ActiveBinding, + proposed: ActiveBinding, + previous_lease: AgentLiveBindingLease, + proposed_lease: AgentLiveBindingLease, + transition_epoch: u64, + }, + /// A verified newer account epoch was observed before the durable data + /// generation advanced. The old owner is immediately unusable. + Fenced { + previous: ActiveBinding, + authorization_floor: LocalAuthorizationContext, + }, + /// Equal version with a different digest is impossible under one installed + /// authority. Stay blocked until a strictly newer account epoch arrives. + Poisoned { + previous: Option, + account_epoch: u64, + }, +} + +impl Default for RegistryBindingState { + fn default() -> Self { + Self::Unbound + } +} + +#[derive(Clone, Default)] +struct BindingRegistryState { + binding: RegistryBindingState, + authorization_domain: Option, + authorization_epoch_floor: u64, + account_revocation: Option, + peer_revocations: HashMap, + next_data_lineage_epoch: u64, + next_peer_lineage_epoch: u64, + next_transition_epoch: u64, +} + +#[derive(Clone, Default)] +pub(crate) struct AgentLiveBindingRegistry { + state: Arc>, +} + +impl AgentLiveBindingRegistry { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) async fn bind_or_refresh( + &self, + verified: VerifiedAgentTargetBinding, + ) -> Result { + let mut state = self.state.lock().await; + let authority = verified.remote_authority.clone(); + with_current_remote_authority(authority.as_ref(), || { + validate_authorization_domain( + &mut state, + authority + .as_ref() + .map(|authority| authority.authorization_domain()), + )?; + if let Err(error) = validate_authorization_floor(&mut state, &verified.authorization) { + return Err(error); + } + if verified.authorization.account_epoch < state.authorization_epoch_floor { + return Err(AgentLiveBindingError::StaleBinding); + } + validate_revocation_tombstones( + &mut state, + &verified.authorization, + verified.controller_endpoint, + )?; + let binding = std::mem::take(&mut state.binding); + let (next, result) = bind_state(binding, verified, &mut state); + state.binding = next; + result + })? + } + + pub(crate) async fn require_bound( + &self, + account_scope: &str, + account_generation: u64, + controller_endpoint: iroh::EndpointId, + ) -> Result { + let state = self.state.lock().await; + let current = required_lease( + &state.binding, + account_scope, + account_generation, + controller_endpoint, + )?; + let authority = current.remote_authority.clone(); + with_current_remote_authority(authority.as_ref(), || current) + } + + pub(crate) async fn revalidate( + &self, + account_scope: &str, + account_generation: u64, + lease: &AgentLiveBindingLease, + ) -> Result<(), AgentLiveBindingError> { + if account_scope != lease.account_scope.as_ref() { + return Err(AgentLiveBindingError::WrongAccount); + } + let state = self.state.lock().await; + let authority = lease.remote_authority.clone(); + with_current_remote_authority(authority.as_ref(), || { + let current = required_lease( + &state.binding, + account_scope, + account_generation, + lease.controller_endpoint, + )?; + if current == *lease { + Ok(()) + } else { + Err(AgentLiveBindingError::StaleBinding) + } + })? + } + + /// Consume the unforgeable receipt minted by the endpoint admission swap. + /// This is the production revocation path, including removal of the final + /// controller; it never relies on a cloneable snapshot context retained by + /// a peer which is no longer admitted. + pub(crate) async fn apply_authorization_transition( + &self, + receipt: AuthorizationTransitionReceipt, + ) -> Result { + let (authorization_domain, previous, current, removed_peers, account_epoch_changed) = + receipt.into_parts(); + let current = LocalAuthorizationContext::from_installed(¤t)?; + let previous = previous + .as_ref() + .map(LocalAuthorizationContext::from_installed) + .transpose()?; + let mut state = self.state.lock().await; + let mut candidate = state.clone(); + validate_authorization_domain(&mut candidate, Some(authorization_domain))?; + if let Err(error) = validate_authorization_floor(&mut candidate, ¤t) { + // Equal-version/different-digest authority is an equivocation, not + // a stale request. `validate_authorization_floor` has already + // poisoned the candidate, so publish that terminal fence before + // returning. Other validation failures leave the live registry + // byte-for-byte unchanged. + if error == AgentLiveBindingError::AuthorizationConflict { + *state = candidate; + } + return Err(error); + } + + if account_epoch_changed { + if previous + .as_ref() + .is_some_and(|previous| previous.account_epoch >= current.account_epoch) + { + return Err(AgentLiveBindingError::AuthorizationConflict); + } + let revoked_peers = committed_binding_leases(&candidate.binding) + .into_iter() + .map(|lease| RevokedAgentLivePeer { lease }) + .collect(); + if let Some(previous) = previous { + record_account_revocation(&mut candidate, previous)?; + } + candidate.authorization_epoch_floor = current.account_epoch; + candidate.peer_revocations.clear(); + let binding = std::mem::take(&mut candidate.binding); + candidate.binding = match binding { + RegistryBindingState::Active(previous) + | RegistryBindingState::Transition { previous, .. } + | RegistryBindingState::Fenced { previous, .. } => RegistryBindingState::Fenced { + previous, + authorization_floor: current, + }, + RegistryBindingState::Poisoned { + previous: Some(previous), + .. + } => RegistryBindingState::Fenced { + previous, + authorization_floor: current, + }, + RegistryBindingState::Poisoned { previous: None, .. } + | RegistryBindingState::Unbound => RegistryBindingState::Unbound, + }; + *state = candidate; + return Ok(AppliedAgentAuthorizationTransition { + revoked_peers, + account_epoch_changed: true, + }); + } + + // A retained controller's QUIC connection may remain open across an + // authorization revision, but every lease minted from the previous + // context is immediately stale. Return all such leases to the + // privileged transition cleanup path so idle subscriptions are woken + // and actor-acknowledged rather than waiting for another event. An + // exact idempotent snapshot replacement tears down nothing. + let authorization_changed = previous + .as_ref() + .is_some_and(|previous| previous != ¤t); + let mut revoked_peers = if authorization_changed { + committed_binding_leases(&candidate.binding) + .into_iter() + .map(|lease| RevokedAgentLivePeer { lease }) + .collect::>() + } else { + Vec::new() + }; + for controller_endpoint in removed_peers { + record_peer_revocation(&mut candidate, controller_endpoint, current.clone())?; + let binding = std::mem::take(&mut candidate.binding); + let (next, revoked) = revoke_peer_state( + binding, + controller_endpoint, + current.clone(), + &mut candidate, + )?; + candidate.binding = next; + if let Some(lease) = revoked { + if !revoked_peers.iter().any(|revoked| revoked.lease == lease) { + revoked_peers.push(RevokedAgentLivePeer { lease }); + } + } + } + candidate.authorization_epoch_floor = candidate + .authorization_epoch_floor + .max(current.account_epoch); + *state = candidate; + Ok(AppliedAgentAuthorizationTransition { + revoked_peers, + account_epoch_changed: false, + }) + } + + /// Remove one exact controller after the endpoint has installed an + /// authorization snapshot which no longer grants it. Other peer leases and + /// the account journal remain live. + #[cfg(test)] + pub(crate) async fn revoke_peer( + &self, + controller_endpoint: iroh::EndpointId, + installed: &InstalledAuthorizationContext, + ) -> Result, AgentLiveBindingError> { + let authorization = LocalAuthorizationContext::from_installed(installed)?; + let mut state = self.state.lock().await; + // A stale or unknown revocation capability must leave every lineage, + // tombstone, and active lease unchanged. Work on a candidate snapshot + // and publish it only after the whole state transition validates. + let mut candidate = state.clone(); + if let Err(error) = validate_authorization_floor(&mut candidate, &authorization) { + if error == AgentLiveBindingError::AuthorizationConflict { + *state = candidate; + } + return Err(error); + } + if !revocation_matches_known_peer(&candidate.binding, controller_endpoint) + && !candidate + .peer_revocations + .contains_key(&controller_endpoint) + && !matches!(&candidate.binding, RegistryBindingState::Unbound) + { + return Err(AgentLiveBindingError::StaleBinding); + } + if let Err(error) = + record_peer_revocation(&mut candidate, controller_endpoint, authorization.clone()) + { + if error == AgentLiveBindingError::AuthorizationConflict { + *state = candidate; + } + return Err(error); + } + let binding = std::mem::take(&mut candidate.binding); + let transition = + revoke_peer_state(binding, controller_endpoint, authorization, &mut candidate); + let (next, revoked) = match transition { + Ok(result) => result, + Err(error) => { + if error == AgentLiveBindingError::AuthorizationConflict { + *state = candidate; + } + return Err(error); + } + }; + candidate.binding = next; + *state = candidate; + Ok(revoked.map(|lease| RevokedAgentLivePeer { lease })) + } + + /// Fence every synchronized peer for the current account. This is called + /// from the native authorization lifecycle before logout/account switch; + /// no renderer value can invoke it as authority. + #[cfg(test)] + pub(crate) async fn revoke_account( + &self, + installed: &InstalledAuthorizationContext, + ) -> Result<(), AgentLiveBindingError> { + let authorization = LocalAuthorizationContext::from_installed(installed)?; + let mut state = self.state.lock().await; + validate_authorization_floor(&mut state, &authorization)?; + if authorization.account_epoch < state.authorization_epoch_floor { + return Err(AgentLiveBindingError::StaleBinding); + } + state.authorization_epoch_floor = authorization.account_epoch; + record_account_revocation(&mut state, authorization.clone())?; + let binding = std::mem::take(&mut state.binding); + state.binding = match binding { + RegistryBindingState::Active(previous) => RegistryBindingState::Fenced { + previous, + authorization_floor: authorization, + }, + RegistryBindingState::Transition { previous, .. } + | RegistryBindingState::Fenced { previous, .. } => RegistryBindingState::Fenced { + previous, + authorization_floor: authorization, + }, + RegistryBindingState::Poisoned { previous, .. } => RegistryBindingState::Poisoned { + previous, + account_epoch: authorization.account_epoch, + }, + RegistryBindingState::Unbound => RegistryBindingState::Unbound, + }; + Ok(()) + } + + pub(crate) async fn commit_rotation( + &self, + obligation: AgentLiveRotationObligation, + reverified: VerifiedAgentTargetBinding, + ) -> Result { + let mut state = self.state.lock().await; + let authority = reverified.remote_authority.clone(); + with_current_remote_authority(authority.as_ref(), || { + // Never consume the retryable Transition until the fresh native + // authority and every tombstone check have succeeded. A stale or + // mismatched commit attempt leaves the obligation byte-for-byte + // retryable; an authorization equivocation alone is published as + // the fail-closed poisoned candidate. + let mut candidate = state.clone(); + if let Err(error) = + validate_authorization_floor(&mut candidate, &reverified.authorization) + { + if error == AgentLiveBindingError::AuthorizationConflict { + *state = candidate; + } + return Err(error); + } + if let Err(error) = validate_revocation_tombstones( + &mut candidate, + &reverified.authorization, + reverified.controller_endpoint, + ) { + if error == AgentLiveBindingError::AuthorizationConflict { + *state = candidate; + } + return Err(error); + } + if !verified_matches_lease(&reverified, &obligation.proposed) { + return Err(AgentLiveBindingError::StaleBinding); + } + let binding = std::mem::take(&mut candidate.binding); + match binding { + RegistryBindingState::Transition { + previous: _, + mut proposed, + previous_lease, + proposed_lease, + transition_epoch, + } if previous_lease == obligation.previous + && proposed_lease == obligation.proposed + && transition_epoch == obligation.transition_epoch => + { + // The scalar proposal is only a durable-rotation identity. + // Install the freshly guarded native admission capability, + // never the clone captured before the asynchronous journal + // rotation. + replace_proposed_authority(&mut proposed, &reverified)?; + let committed = proposed + .lease_for(reverified.controller_endpoint) + .ok_or(AgentLiveBindingError::TransitionMismatch)?; + candidate.binding = RegistryBindingState::Active(proposed); + *state = candidate; + Ok(committed) + } + _ => Err(AgentLiveBindingError::TransitionMismatch), + } + })? + } + + /// Validate an exact retryable obligation while deliberately retaining the + /// fail-closed Transition state. + pub(crate) async fn abort_rotation( + &self, + obligation: &AgentLiveRotationObligation, + ) -> Result<(), AgentLiveBindingError> { + let state = self.state.lock().await; + match &state.binding { + RegistryBindingState::Transition { + previous_lease, + proposed_lease, + transition_epoch, + .. + } if previous_lease == &obligation.previous + && proposed_lease == &obligation.proposed + && *transition_epoch == obligation.transition_epoch => + { + Ok(()) + } + _ => Err(AgentLiveBindingError::TransitionMismatch), + } + } +} + +fn with_current_remote_authority( + authority: Option<&VerifiedIncomingPeerAuthorization>, + operation: impl FnOnce() -> R, +) -> Result { + match authority { + Some(authority) => authority + .with_current(operation) + .map_err(|_| AgentLiveBindingError::StaleBinding), + #[cfg(test)] + None => Ok(operation()), + #[cfg(not(test))] + None => Err(AgentLiveBindingError::InvalidVerifiedBinding), + } +} + +fn required_lease( + binding: &RegistryBindingState, + account_scope: &str, + account_generation: u64, + controller_endpoint: iroh::EndpointId, +) -> Result { + match binding { + RegistryBindingState::Active(active) + if active.account_scope.as_ref() == account_scope + && active.account_generation == account_generation => + { + active + .lease_for(controller_endpoint) + .ok_or(AgentLiveBindingError::Unbound) + } + RegistryBindingState::Active(active) if active.account_scope.as_ref() != account_scope => { + Err(AgentLiveBindingError::WrongAccount) + } + RegistryBindingState::Active(_) => Err(AgentLiveBindingError::StaleBinding), + RegistryBindingState::Transition { .. } | RegistryBindingState::Fenced { .. } => { + Err(AgentLiveBindingError::TransitionInProgress) + } + RegistryBindingState::Poisoned { .. } => Err(AgentLiveBindingError::AuthorizationConflict), + RegistryBindingState::Unbound => Err(AgentLiveBindingError::Unbound), + } +} + +fn validate_authorization_floor( + state: &mut BindingRegistryState, + authorization: &LocalAuthorizationContext, +) -> Result<(), AgentLiveBindingError> { + if authorization.account_epoch < state.authorization_epoch_floor { + return Err(AgentLiveBindingError::StaleBinding); + } + let mut conflicting = false; + match &state.binding { + RegistryBindingState::Active(active) => { + conflicting = matches!( + compare_authorization(authorization, &active.authorization), + Err(AgentLiveBindingError::AuthorizationConflict) + ); + } + RegistryBindingState::Transition { proposed, .. } => { + conflicting = matches!( + compare_authorization(authorization, &proposed.authorization), + Err(AgentLiveBindingError::AuthorizationConflict) + ); + } + RegistryBindingState::Fenced { + authorization_floor, + .. + } => { + conflicting = matches!( + compare_authorization(authorization, authorization_floor), + Err(AgentLiveBindingError::AuthorizationConflict) + ); + } + RegistryBindingState::Poisoned { .. } | RegistryBindingState::Unbound => {} + } + if conflicting { + poison_registry_for_conflicting_authority(state, authorization.account_epoch); + return Err(AgentLiveBindingError::AuthorizationConflict); + } + Ok(()) +} + +fn validate_authorization_domain( + state: &mut BindingRegistryState, + proposed: Option, +) -> Result<(), AgentLiveBindingError> { + match (state.authorization_domain.as_ref(), proposed) { + (Some(current), Some(proposed)) if current.same_instance(&proposed) => Ok(()), + (Some(_), Some(_)) => Err(AgentLiveBindingError::StaleBinding), + (None, Some(proposed)) => { + state.authorization_domain = Some(proposed); + Ok(()) + } + #[cfg(test)] + (_, None) => Ok(()), + #[cfg(not(test))] + (_, None) => Err(AgentLiveBindingError::InvalidVerifiedBinding), + } +} + +fn bind_state( + binding: RegistryBindingState, + verified: VerifiedAgentTargetBinding, + counters: &mut BindingRegistryState, +) -> ( + RegistryBindingState, + Result, +) { + match binding { + RegistryBindingState::Unbound => { + counters.authorization_epoch_floor = verified.authorization.account_epoch; + let active = match new_active(verified, counters) { + Ok(active) => active, + Err(error) => return (RegistryBindingState::Unbound, Err(error)), + }; + let endpoint = *active.peers.keys().next().expect("one peer is inserted"); + let lease = active + .lease_for(endpoint) + .expect("inserted peer has a lease"); + ( + RegistryBindingState::Active(active), + Ok(AgentLiveBindOutcome::Bound(lease)), + ) + } + RegistryBindingState::Active(mut active) => { + let authorization_order = + match compare_authorization(&verified.authorization, &active.authorization) { + Ok(order) => order, + Err(AgentLiveBindingError::AuthorizationConflict) => { + let epoch = active + .authorization + .account_epoch + .max(verified.authorization.account_epoch); + counters.authorization_epoch_floor = + counters.authorization_epoch_floor.max(epoch); + return ( + RegistryBindingState::Poisoned { + previous: Some(active), + account_epoch: epoch, + }, + Err(AgentLiveBindingError::AuthorizationConflict), + ); + } + Err(error) => return (RegistryBindingState::Active(active), Err(error)), + }; + + if authorization_order == AuthorizationOrder::NewerEpoch { + counters.authorization_epoch_floor = verified.authorization.account_epoch; + if Some(verified.account_generation) != active.account_generation.checked_add(1) { + let error = if verified.account_generation > active.account_generation { + AgentLiveBindingError::NonAdjacentGeneration + } else { + AgentLiveBindingError::TransitionInProgress + }; + let floor = verified.authorization; + return ( + RegistryBindingState::Fenced { + previous: active, + authorization_floor: floor, + }, + Err(error), + ); + } + return begin_rotation(active, verified, counters); + } + + if verified.account_scope != active.account_scope.as_ref() { + return ( + RegistryBindingState::Active(active), + Err(AgentLiveBindingError::WrongAccount), + ); + } + if verified.account_generation < active.account_generation { + return ( + RegistryBindingState::Active(active), + Err(AgentLiveBindingError::StaleBinding), + ); + } + let data_changed = verified.account_generation != active.account_generation + || verified.execution_target != active.execution_target; + if data_changed { + if Some(verified.account_generation) != active.account_generation.checked_add(1) { + return ( + RegistryBindingState::Active(active), + Err(AgentLiveBindingError::NonAdjacentGeneration), + ); + } + // The installed authorization digest does not include the + // product registration ID. Require a fresh installed snapshot + // before accepting a target switch. + if verified.execution_target != active.execution_target + && authorization_order != AuthorizationOrder::NewerRevision + { + return ( + RegistryBindingState::Active(active), + Err(AgentLiveBindingError::StaleBinding), + ); + } + return begin_rotation(active, verified, counters); + } + + let endpoint = verified.controller_endpoint; + let peer = active.peers.get(&endpoint).cloned(); + let peer_lineage_epoch = match peer { + None => match allocate_peer_epoch(counters) { + Ok(epoch) => epoch, + Err(error) => return (RegistryBindingState::Active(active), Err(error)), + }, + Some(current) if current.pairing_fence != verified.pairing_fence => { + if authorization_order != AuthorizationOrder::NewerRevision + || verified.connection_stamp <= current.connection_stamp + { + return ( + RegistryBindingState::Active(active), + Err(AgentLiveBindingError::StaleBinding), + ); + } + match allocate_peer_epoch(counters) { + Ok(epoch) => epoch, + Err(error) => return (RegistryBindingState::Active(active), Err(error)), + } + } + Some(current) => { + if verified.connection_stamp < current.connection_stamp { + return ( + RegistryBindingState::Active(active), + Err(AgentLiveBindingError::StaleBinding), + ); + } + current.peer_lineage_epoch + } + }; + active.authorization = verified.authorization; + counters.authorization_epoch_floor = active.authorization.account_epoch; + active.peers.insert( + endpoint, + PeerBinding { + pairing_fence: verified.pairing_fence, + connection_stamp: verified.connection_stamp, + peer_lineage_epoch, + remote_authority: verified.remote_authority.clone(), + }, + ); + let lease = active + .lease_for(endpoint) + .expect("refreshed peer has a lease"); + ( + RegistryBindingState::Active(active), + Ok(AgentLiveBindOutcome::Bound(lease)), + ) + } + RegistryBindingState::Transition { + previous, + proposed, + previous_lease, + proposed_lease, + transition_epoch, + } => { + if verified_matches_lease(&verified, &proposed_lease) { + let obligation = AgentLiveRotationObligation { + previous: previous_lease.clone(), + proposed: proposed_lease.clone(), + transition_epoch, + }; + ( + RegistryBindingState::Transition { + previous, + proposed, + previous_lease, + proposed_lease, + transition_epoch, + }, + Ok(AgentLiveBindOutcome::RotationRequired(obligation)), + ) + } else { + ( + RegistryBindingState::Transition { + previous, + proposed, + previous_lease, + proposed_lease, + transition_epoch, + }, + Err(AgentLiveBindingError::TransitionInProgress), + ) + } + } + RegistryBindingState::Fenced { + previous, + authorization_floor, + } => { + let order = match compare_authorization(&verified.authorization, &authorization_floor) { + Ok(order) => order, + Err(error) => { + return ( + RegistryBindingState::Fenced { + previous, + authorization_floor, + }, + Err(error), + ) + } + }; + if verified.authorization.account_epoch < authorization_floor.account_epoch + || verified.account_generation <= previous.account_generation + { + return ( + RegistryBindingState::Fenced { + previous, + authorization_floor, + }, + Err(AgentLiveBindingError::TransitionInProgress), + ); + } + if Some(verified.account_generation) != previous.account_generation.checked_add(1) { + return ( + RegistryBindingState::Fenced { + previous, + authorization_floor, + }, + Err(AgentLiveBindingError::NonAdjacentGeneration), + ); + } + if order == AuthorizationOrder::Same + && verified.authorization.snapshot_digest != authorization_floor.snapshot_digest + { + return ( + RegistryBindingState::Poisoned { + previous: Some(previous), + account_epoch: authorization_floor.account_epoch, + }, + Err(AgentLiveBindingError::AuthorizationConflict), + ); + } + begin_rotation(previous, verified, counters) + } + RegistryBindingState::Poisoned { + previous, + account_epoch, + } => { + if verified.authorization.account_epoch <= account_epoch { + return ( + RegistryBindingState::Poisoned { + previous, + account_epoch, + }, + Err(AgentLiveBindingError::AuthorizationConflict), + ); + } + counters.authorization_epoch_floor = verified.authorization.account_epoch; + let Some(previous) = previous else { + let active = match new_active(verified, counters) { + Ok(active) => active, + Err(error) => { + return ( + RegistryBindingState::Poisoned { + previous: None, + account_epoch, + }, + Err(error), + ) + } + }; + let endpoint = *active.peers.keys().next().expect("new peer"); + let lease = active.lease_for(endpoint).expect("new peer lease"); + return ( + RegistryBindingState::Active(active), + Ok(AgentLiveBindOutcome::Bound(lease)), + ); + }; + if Some(verified.account_generation) != previous.account_generation.checked_add(1) { + return ( + RegistryBindingState::Poisoned { + previous: Some(previous), + account_epoch, + }, + Err(AgentLiveBindingError::NonAdjacentGeneration), + ); + } + begin_rotation(previous, verified, counters) + } + } +} + +fn new_active( + verified: VerifiedAgentTargetBinding, + counters: &mut BindingRegistryState, +) -> Result { + let data_lineage_epoch = allocate_data_epoch(counters)?; + let peer_lineage_epoch = allocate_peer_epoch(counters)?; + let mut peers = HashMap::new(); + peers.insert( + verified.controller_endpoint, + PeerBinding { + pairing_fence: verified.pairing_fence, + connection_stamp: verified.connection_stamp, + peer_lineage_epoch, + remote_authority: verified.remote_authority.clone(), + }, + ); + Ok(ActiveBinding { + account_scope: Arc::from(verified.account_scope), + account_generation: verified.account_generation, + execution_target: verified.execution_target, + authorization: verified.authorization, + data_lineage_epoch, + peers, + }) +} + +fn begin_rotation( + previous: ActiveBinding, + verified: VerifiedAgentTargetBinding, + counters: &mut BindingRegistryState, +) -> ( + RegistryBindingState, + Result, +) { + let previous_lease = previous + .lease_for(verified.controller_endpoint) + .or_else(|| { + previous + .peers + .keys() + .next() + .and_then(|endpoint| previous.lease_for(*endpoint)) + }); + let Some(previous_lease) = previous_lease else { + return ( + RegistryBindingState::Active(previous), + Err(AgentLiveBindingError::Unbound), + ); + }; + let proposed = match new_active(verified, counters) { + Ok(active) => active, + Err(error) => return (RegistryBindingState::Active(previous), Err(error)), + }; + let proposed_endpoint = *proposed.peers.keys().next().expect("new peer"); + let proposed_lease = proposed + .lease_for(proposed_endpoint) + .expect("new peer lease"); + let transition_epoch = match allocate_transition_epoch(counters) { + Ok(epoch) => epoch, + Err(error) => return (RegistryBindingState::Active(previous), Err(error)), + }; + let obligation = AgentLiveRotationObligation { + previous: previous_lease.clone(), + proposed: proposed_lease.clone(), + transition_epoch, + }; + ( + RegistryBindingState::Transition { + previous, + proposed, + previous_lease, + proposed_lease, + transition_epoch, + }, + Ok(AgentLiveBindOutcome::RotationRequired(obligation)), + ) +} + +fn revoke_peer_state( + binding: RegistryBindingState, + controller_endpoint: iroh::EndpointId, + authorization: LocalAuthorizationContext, + counters: &mut BindingRegistryState, +) -> Result<(RegistryBindingState, Option), AgentLiveBindingError> { + let mut active = match binding { + RegistryBindingState::Active(active) => active, + RegistryBindingState::Transition { + previous, + proposed: _, + previous_lease, + proposed_lease, + .. + } => { + let revoked = [previous_lease, proposed_lease] + .into_iter() + .find(|lease| lease.controller_endpoint == controller_endpoint); + match compare_authorization(&authorization, &previous.authorization) { + Ok(_) => {} + Err(AgentLiveBindingError::AuthorizationConflict) => { + counters.authorization_epoch_floor = counters + .authorization_epoch_floor + .max(authorization.account_epoch); + return Ok(( + RegistryBindingState::Poisoned { + previous: Some(previous), + account_epoch: authorization.account_epoch, + }, + None, + )); + } + Err(error) => return Err(error), + } + // A proposal is not a committed data owner. Revocation invalidates + // the obligation and fences only the last committed owner, even if + // the revoked endpoint appeared solely in the proposed binding. + return Ok(( + RegistryBindingState::Fenced { + previous, + authorization_floor: authorization, + }, + revoked, + )); + } + RegistryBindingState::Fenced { + previous, + authorization_floor, + } => { + return Ok(( + RegistryBindingState::Fenced { + previous, + authorization_floor, + }, + None, + )); + } + other => return Ok((other, None)), + }; + match compare_authorization(&authorization, &active.authorization) { + Ok(_) => {} + Err(AgentLiveBindingError::AuthorizationConflict) => { + counters.authorization_epoch_floor = counters + .authorization_epoch_floor + .max(authorization.account_epoch); + return Ok(( + RegistryBindingState::Poisoned { + previous: Some(active), + account_epoch: authorization.account_epoch, + }, + None, + )); + } + Err(error) => return Err(error), + } + let revoked = active.lease_for(controller_endpoint); + active.authorization = authorization; + counters.authorization_epoch_floor = active.authorization.account_epoch; + active.peers.remove(&controller_endpoint); + Ok((RegistryBindingState::Active(active), revoked)) +} + +fn validate_revocation_tombstones( + state: &mut BindingRegistryState, + proposed: &LocalAuthorizationContext, + controller_endpoint: iroh::EndpointId, +) -> Result<(), AgentLiveBindingError> { + if let Some(tombstone) = state.account_revocation.as_ref() { + if proposed.account_epoch <= tombstone.account_epoch { + if proposed.account_epoch == tombstone.account_epoch + && proposed.snapshot_revision == tombstone.snapshot_revision + && proposed.snapshot_digest != tombstone.snapshot_digest + { + poison_registry_for_conflicting_authority(state, proposed.account_epoch); + return Err(AgentLiveBindingError::AuthorizationConflict); + } + // Logout/account reset is an account-epoch boundary. A same-epoch + // snapshot revision can never resurrect the revoked account. + return Err(AgentLiveBindingError::StaleBinding); + } + state.account_revocation = None; + state.peer_revocations.clear(); + } + + let peer_tombstone = state.peer_revocations.get(&controller_endpoint).cloned(); + if let Some(tombstone) = peer_tombstone.as_ref() { + match compare_authorization(proposed, tombstone) { + Ok(AuthorizationOrder::NewerEpoch | AuthorizationOrder::NewerRevision) => { + state.peer_revocations.remove(&controller_endpoint); + } + Ok(AuthorizationOrder::Same) | Err(AgentLiveBindingError::StaleBinding) => { + return Err(AgentLiveBindingError::StaleBinding); + } + Err(AgentLiveBindingError::AuthorizationConflict) => { + poison_registry_for_conflicting_authority(state, proposed.account_epoch); + return Err(AgentLiveBindingError::AuthorizationConflict); + } + Err(error) => return Err(error), + } + } + Ok(()) +} + +fn revocation_matches_known_peer( + binding: &RegistryBindingState, + controller_endpoint: iroh::EndpointId, +) -> bool { + match binding { + RegistryBindingState::Active(active) + | RegistryBindingState::Fenced { + previous: active, .. + } => active.peers.contains_key(&controller_endpoint), + RegistryBindingState::Transition { + previous, proposed, .. + } => { + previous.peers.contains_key(&controller_endpoint) + || proposed.peers.contains_key(&controller_endpoint) + } + RegistryBindingState::Poisoned { previous, .. } => previous + .as_ref() + .is_some_and(|active| active.peers.contains_key(&controller_endpoint)), + RegistryBindingState::Unbound => false, + } +} + +fn committed_binding_leases(binding: &RegistryBindingState) -> Vec { + let active = match binding { + RegistryBindingState::Active(active) + | RegistryBindingState::Transition { + previous: active, .. + } + | RegistryBindingState::Fenced { + previous: active, .. + } => Some(active), + RegistryBindingState::Poisoned { previous, .. } => previous.as_ref(), + RegistryBindingState::Unbound => None, + }; + active + .into_iter() + .flat_map(|active| { + active + .peers + .keys() + .filter_map(|endpoint| active.lease_for(*endpoint)) + }) + .collect() +} + +fn replace_proposed_authority( + proposed: &mut ActiveBinding, + reverified: &VerifiedAgentTargetBinding, +) -> Result<(), AgentLiveBindingError> { + if proposed.authorization != reverified.authorization { + return Err(AgentLiveBindingError::TransitionMismatch); + } + let peer = proposed + .peers + .get_mut(&reverified.controller_endpoint) + .ok_or(AgentLiveBindingError::TransitionMismatch)?; + if peer.pairing_fence != reverified.pairing_fence + || peer.connection_stamp != reverified.connection_stamp + { + return Err(AgentLiveBindingError::TransitionMismatch); + } + peer.remote_authority = reverified.remote_authority.clone(); + Ok(()) +} + +fn record_peer_revocation( + state: &mut BindingRegistryState, + endpoint: iroh::EndpointId, + authorization: LocalAuthorizationContext, +) -> Result<(), AgentLiveBindingError> { + if let Some(current) = state.peer_revocations.get(&endpoint) { + match compare_authorization(&authorization, current) { + Err(AgentLiveBindingError::AuthorizationConflict) => { + poison_registry_for_conflicting_authority(state, authorization.account_epoch); + return Err(AgentLiveBindingError::AuthorizationConflict); + } + Err(error) => return Err(error), + Ok(AuthorizationOrder::Same) => return Ok(()), + Ok(AuthorizationOrder::NewerEpoch | AuthorizationOrder::NewerRevision) => {} + } + } + state.authorization_epoch_floor = state + .authorization_epoch_floor + .max(authorization.account_epoch); + state.peer_revocations.insert(endpoint, authorization); + Ok(()) +} + +fn record_account_revocation( + state: &mut BindingRegistryState, + authorization: LocalAuthorizationContext, +) -> Result<(), AgentLiveBindingError> { + if let Some(current) = state.account_revocation.as_ref() { + match compare_authorization(&authorization, current) { + Err(AgentLiveBindingError::AuthorizationConflict) => { + poison_registry_for_conflicting_authority(state, authorization.account_epoch); + return Err(AgentLiveBindingError::AuthorizationConflict); + } + Err(error) => return Err(error), + Ok(AuthorizationOrder::Same) => return Ok(()), + Ok(AuthorizationOrder::NewerEpoch | AuthorizationOrder::NewerRevision) => {} + } + } + state.authorization_epoch_floor = state + .authorization_epoch_floor + .max(authorization.account_epoch); + state.account_revocation = Some(authorization); + Ok(()) +} + +fn poison_registry_for_conflicting_authority(state: &mut BindingRegistryState, epoch: u64) { + let previous = match std::mem::take(&mut state.binding) { + RegistryBindingState::Active(active) + | RegistryBindingState::Transition { + previous: active, .. + } + | RegistryBindingState::Fenced { + previous: active, .. + } => Some(active), + RegistryBindingState::Poisoned { previous, .. } => previous, + RegistryBindingState::Unbound => None, + }; + state.authorization_epoch_floor = state.authorization_epoch_floor.max(epoch); + state.binding = RegistryBindingState::Poisoned { + previous, + account_epoch: epoch, + }; +} + +fn verified_matches_lease( + verified: &VerifiedAgentTargetBinding, + lease: &AgentLiveBindingLease, +) -> bool { + verified.account_scope == lease.account_scope.as_ref() + && verified.account_generation == lease.account_generation + && verified.execution_target == lease.execution_target + && verified.controller_endpoint == lease.controller_endpoint + && verified.authorization == lease.authorization + && verified.pairing_fence == lease.pairing_fence + && verified.connection_stamp == lease.connection_stamp + && same_remote_authority_instance( + verified.remote_authority.as_ref(), + lease.remote_authority.as_ref(), + ) +} + +fn same_remote_authority_instance( + left: Option<&VerifiedIncomingPeerAuthorization>, + right: Option<&VerifiedIncomingPeerAuthorization>, +) -> bool { + match (left, right) { + (Some(left), Some(right)) => left.same_admission_instance(right), + #[cfg(test)] + (None, None) => true, + _ => false, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AuthorizationOrder { + Same, + NewerRevision, + NewerEpoch, +} + +fn compare_authorization( + proposed: &LocalAuthorizationContext, + current: &LocalAuthorizationContext, +) -> Result { + match proposed.account_epoch.cmp(¤t.account_epoch) { + Ordering::Less => return Err(AgentLiveBindingError::StaleBinding), + Ordering::Greater => return Ok(AuthorizationOrder::NewerEpoch), + Ordering::Equal => {} + } + match proposed.snapshot_revision.cmp(¤t.snapshot_revision) { + Ordering::Less => Err(AgentLiveBindingError::StaleBinding), + Ordering::Greater => Ok(AuthorizationOrder::NewerRevision), + Ordering::Equal if proposed.snapshot_digest == current.snapshot_digest => { + Ok(AuthorizationOrder::Same) + } + Ordering::Equal => Err(AgentLiveBindingError::AuthorizationConflict), + } +} + +fn allocate_data_epoch(state: &mut BindingRegistryState) -> Result { + state.next_data_lineage_epoch = state + .next_data_lineage_epoch + .checked_add(1) + .ok_or(AgentLiveBindingError::EpochExhausted)?; + Ok(state.next_data_lineage_epoch) +} + +fn allocate_peer_epoch(state: &mut BindingRegistryState) -> Result { + state.next_peer_lineage_epoch = state + .next_peer_lineage_epoch + .checked_add(1) + .ok_or(AgentLiveBindingError::EpochExhausted)?; + Ok(state.next_peer_lineage_epoch) +} + +fn allocate_transition_epoch( + state: &mut BindingRegistryState, +) -> Result { + state.next_transition_epoch = state + .next_transition_epoch + .checked_add(1) + .ok_or(AgentLiveBindingError::EpochExhausted)?; + Ok(state.next_transition_epoch) +} + +fn validate_bounded_id(value: &str, max_bytes: usize) -> Result<(), ()> { + if value.is_empty() + || value.len() > max_bytes + || value.chars().any(|character| { + character.is_control() + || matches!(character, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') + }) + { + Err(()) + } else { + Ok(()) + } +} + +fn looks_like_non_nil_uuid(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 || bytes.get(8) != Some(&b'-') { + return false; + } + for index in [13usize, 18, 23] { + if bytes.get(index) != Some(&b'-') { + return false; + } + } + let mut has_nonzero = false; + for (index, byte) in bytes.iter().copied().enumerate() { + if matches!(index, 8 | 13 | 18 | 23) { + continue; + } + if !byte.is_ascii_hexdigit() { + return false; + } + has_nonzero |= byte != b'0'; + } + has_nonzero +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::remote_transport::{InstalledAuthorizationDomain, PairingIncarnation}; + + const TARGET_A: &str = "11111111-1111-4111-8111-111111111111"; + const TARGET_B: &str = "22222222-2222-4222-8222-222222222222"; + + fn endpoint(seed: u8) -> iroh::EndpointId { + iroh::SecretKey::from_bytes(&[seed; 32]).public() + } + + #[allow(clippy::too_many_arguments)] + fn verified( + account: &str, + generation: u64, + target: &str, + controller: u8, + account_epoch: u64, + snapshot_revision: u64, + digest_byte: u8, + pairing_incarnation: u64, + host_epoch: u64, + connection_generation: u64, + ) -> VerifiedAgentTargetBinding { + VerifiedAgentTargetBinding { + remote_authority: None, + account_scope: account.to_string(), + account_generation: generation, + execution_target: AgentExecutionTargetId::from_verified_registration( + target.to_string(), + ) + .unwrap(), + controller_endpoint: endpoint(controller), + authorization: LocalAuthorizationContext::for_test( + account_epoch, + snapshot_revision, + [digest_byte; 32], + ), + pairing_fence: PairingFence::new(PairingIncarnation::new(pairing_incarnation).unwrap()) + .unwrap(), + connection_stamp: ConnectionStamp::new(host_epoch, connection_generation).unwrap(), + } + } + + fn initial(controller: u8) -> VerifiedAgentTargetBinding { + verified("account-a", 7, TARGET_A, controller, 17, 1, 1, 3, 41, 1) + } + + #[tokio::test] + async fn synchronized_access_is_unavailable_before_verified_binding() { + let registry = AgentLiveBindingRegistry::new(); + assert_eq!( + registry.require_bound("account-a", 7, endpoint(1)).await, + Err(AgentLiveBindingError::Unbound) + ); + assert!(AgentExecutionTargetId::from_verified_registration("local".into()).is_err()); + } + + #[tokio::test] + async fn host_restart_full_stamp_prevents_generation_aba() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(old) = registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first binding") + }; + let AgentLiveBindOutcome::Bound(restarted) = registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 1, 1, 3, 42, 1)) + .await + .unwrap() + else { + panic!("restart refresh") + }; + assert_eq!( + restarted.connection_stamp(), + ConnectionStamp::new(42, 1).unwrap() + ); + assert_eq!(restarted.lineage_epoch(), old.lineage_epoch()); + assert_eq!( + registry.revalidate("account-a", 7, &old).await, + Err(AgentLiveBindingError::StaleBinding) + ); + assert_eq!( + registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 1, 1, 3, 41, 99,)) + .await, + Err(AgentLiveBindingError::StaleBinding) + ); + } + + #[tokio::test] + async fn pairing_replacement_requires_new_auth_and_newer_stamp() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(old) = registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first binding") + }; + assert_eq!( + registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 1, 1, 4, 42, 1,)) + .await, + Err(AgentLiveBindingError::StaleBinding) + ); + assert_eq!( + registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 2, 2, 4, 41, 1,)) + .await, + Err(AgentLiveBindingError::StaleBinding) + ); + let AgentLiveBindOutcome::Bound(repaired) = registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 2, 2, 4, 42, 1)) + .await + .unwrap() + else { + panic!("pair repair") + }; + assert_eq!(old.lineage_epoch(), repaired.lineage_epoch()); + assert_ne!(old.peer_lineage_epoch(), repaired.peer_lineage_epoch()); + } + + #[tokio::test] + async fn two_controllers_with_equal_pairing_numbers_remain_independent() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(first) = + registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first") + }; + let AgentLiveBindOutcome::Bound(second) = + registry.bind_or_refresh(initial(2)).await.unwrap() + else { + panic!("second") + }; + assert_ne!(first.controller_endpoint(), second.controller_endpoint()); + registry.revalidate("account-a", 7, &first).await.unwrap(); + registry.revalidate("account-a", 7, &second).await.unwrap(); + } + + #[tokio::test] + async fn same_version_digest_conflict_poisons_existing_access() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(old) = registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first") + }; + assert_eq!( + registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 1, 9, 3, 41, 1,)) + .await, + Err(AgentLiveBindingError::AuthorizationConflict) + ); + assert_eq!( + registry.revalidate("account-a", 7, &old).await, + Err(AgentLiveBindingError::AuthorizationConflict) + ); + } + + #[tokio::test] + async fn transition_receipt_digest_conflict_poisons_existing_access() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(old) = registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first") + }; + let receipt = AuthorizationTransitionReceipt::for_test( + Some(InstalledAuthorizationContext::for_test(17, 1, [1; 32])), + InstalledAuthorizationContext::for_test(17, 1, [9; 32]), + Vec::new(), + false, + ); + + assert!(matches!( + registry.apply_authorization_transition(receipt).await, + Err(AgentLiveBindingError::AuthorizationConflict) + )); + assert_eq!( + registry.revalidate("account-a", 7, &old).await, + Err(AgentLiveBindingError::AuthorizationConflict) + ); + } + + #[tokio::test] + async fn newer_account_epoch_immediately_fences_old_before_data_advances() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(old) = registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first") + }; + assert_eq!( + registry + .bind_or_refresh(verified("account-b", 7, TARGET_A, 2, 18, 1, 2, 3, 42, 1,)) + .await, + Err(AgentLiveBindingError::TransitionInProgress) + ); + assert_eq!( + registry.revalidate("account-a", 7, &old).await, + Err(AgentLiveBindingError::TransitionInProgress) + ); + assert_eq!( + registry.bind_or_refresh(initial(1)).await, + Err(AgentLiveBindingError::StaleBinding) + ); + } + + #[tokio::test] + async fn account_epoch_floor_blocks_a_b_a_replay() { + let registry = AgentLiveBindingRegistry::new(); + registry.bind_or_refresh(initial(1)).await.unwrap(); + registry + .bind_or_refresh(verified("account-b", 7, TARGET_A, 2, 18, 1, 2, 3, 42, 1)) + .await + .unwrap_err(); + assert_eq!( + registry.bind_or_refresh(initial(1)).await, + Err(AgentLiveBindingError::StaleBinding) + ); + } + + #[tokio::test] + async fn target_a_b_a_uses_fresh_data_lineage_and_transition_retry_is_exact() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(first_a) = + registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first A") + }; + let to_b_request = verified("account-a", 8, TARGET_B, 1, 17, 2, 2, 3, 42, 1); + let AgentLiveBindOutcome::RotationRequired(to_b) = + registry.bind_or_refresh(to_b_request).await.unwrap() + else { + panic!("A to B") + }; + let AgentLiveBindOutcome::RotationRequired(retry) = registry + .bind_or_refresh(verified("account-a", 8, TARGET_B, 1, 17, 2, 2, 3, 42, 1)) + .await + .unwrap() + else { + panic!("exact retry") + }; + assert_eq!(to_b, retry); + let b = registry + .commit_rotation( + to_b, + verified("account-a", 8, TARGET_B, 1, 17, 2, 2, 3, 42, 1), + ) + .await + .unwrap(); + let AgentLiveBindOutcome::RotationRequired(to_a) = registry + .bind_or_refresh(verified("account-a", 9, TARGET_A, 1, 17, 3, 3, 3, 43, 1)) + .await + .unwrap() + else { + panic!("B to A") + }; + let second_a = registry + .commit_rotation( + to_a, + verified("account-a", 9, TARGET_A, 1, 17, 3, 3, 3, 43, 1), + ) + .await + .unwrap(); + assert_ne!(first_a.lineage_epoch(), b.lineage_epoch()); + assert_ne!(first_a.lineage_epoch(), second_a.lineage_epoch()); + assert_eq!(second_a.execution_target().as_str(), TARGET_A); + } + + #[tokio::test] + async fn peer_revocation_does_not_remove_other_controller() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(first) = + registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first") + }; + let AgentLiveBindOutcome::Bound(second) = + registry.bind_or_refresh(initial(2)).await.unwrap() + else { + panic!("second") + }; + let revoked = registry + .revoke_peer( + first.controller_endpoint(), + &InstalledAuthorizationContext::for_test(17, 2, [2; 32]), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + revoked.lease.controller_endpoint(), + first.controller_endpoint() + ); + assert_eq!( + registry.revalidate("account-a", 7, &first).await, + Err(AgentLiveBindingError::Unbound) + ); + // The authorization revision advanced, so recover the other peer's + // refreshed lease through a fresh verified capability. + let AgentLiveBindOutcome::Bound(second_refreshed) = registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 2, 17, 3, 3, 3, 41, 2)) + .await + .unwrap() + else { + panic!("refresh retained peer") + }; + assert_eq!( + second_refreshed.controller_endpoint(), + second.controller_endpoint() + ); + } + + #[tokio::test] + async fn authoritative_transition_receipt_revokes_last_peer_without_live_capability() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(old) = registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first binding") + }; + let transition = AuthorizationTransitionReceipt::for_test( + Some(InstalledAuthorizationContext::for_test(17, 1, [1; 32])), + InstalledAuthorizationContext::for_test(17, 2, [2; 32]), + vec![old.controller_endpoint()], + false, + ); + let applied = registry + .apply_authorization_transition(transition) + .await + .unwrap(); + assert!(!applied.account_epoch_changed()); + assert_eq!(applied.revoked_peers().len(), 1); + assert_eq!( + registry.revalidate("account-a", 7, &old).await, + Err(AgentLiveBindingError::Unbound) + ); + assert_eq!( + registry.bind_or_refresh(initial(1)).await, + Err(AgentLiveBindingError::StaleBinding) + ); + } + + #[tokio::test] + async fn authorization_refresh_wakes_all_old_context_leases_but_exact_retry_does_not() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(first) = + registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first binding") + }; + let AgentLiveBindOutcome::Bound(second) = + registry.bind_or_refresh(initial(2)).await.unwrap() + else { + panic!("second binding") + }; + let domain = InstalledAuthorizationDomain::for_test(); + let initial_context = InstalledAuthorizationContext::for_test(17, 1, [1; 32]); + + let exact_retry = AuthorizationTransitionReceipt::for_test_in_domain( + domain.clone(), + Some(initial_context.clone()), + initial_context.clone(), + Vec::new(), + false, + ); + let applied = registry + .apply_authorization_transition(exact_retry) + .await + .unwrap(); + assert!(applied.revoked_peers().is_empty()); + + let refreshed = AuthorizationTransitionReceipt::for_test_in_domain( + domain, + Some(initial_context), + InstalledAuthorizationContext::for_test(17, 2, [2; 32]), + Vec::new(), + false, + ); + let applied = registry + .apply_authorization_transition(refreshed) + .await + .unwrap(); + assert_eq!(applied.revoked_peers().len(), 2); + assert!(applied + .revoked_peers() + .iter() + .any(|revoked| revoked.lease == first)); + assert!(applied + .revoked_peers() + .iter() + .any(|revoked| revoked.lease == second)); + } + + #[tokio::test] + async fn authoritative_account_transition_fences_committed_owner_and_discards_proposal() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(old) = registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first binding") + }; + let AgentLiveBindOutcome::RotationRequired(_proposal) = registry + .bind_or_refresh(verified("account-a", 8, TARGET_B, 1, 17, 2, 2, 3, 42, 1)) + .await + .unwrap() + else { + panic!("rotation proposal") + }; + let transition = AuthorizationTransitionReceipt::for_test( + Some(InstalledAuthorizationContext::for_test(17, 2, [2; 32])), + InstalledAuthorizationContext::for_test(18, 1, [3; 32]), + vec![old.controller_endpoint()], + true, + ); + let applied = registry + .apply_authorization_transition(transition) + .await + .unwrap(); + assert!(applied.account_epoch_changed()); + assert_eq!(applied.revoked_peers().len(), 1); + let state = registry.state.lock().await; + let RegistryBindingState::Fenced { previous, .. } = &state.binding else { + panic!("account transition must fence the committed owner") + }; + assert_eq!(previous.account_generation, 7); + assert_eq!(previous.execution_target.as_str(), TARGET_A); + } + + #[tokio::test] + async fn stale_or_unknown_peer_revoke_preserves_active_binding_exactly() { + let registry = AgentLiveBindingRegistry::new(); + let AgentLiveBindOutcome::Bound(first) = registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 2, 2, 3, 41, 1)) + .await + .unwrap() + else { + panic!("first binding") + }; + + assert_eq!( + registry + .revoke_peer( + first.controller_endpoint(), + &InstalledAuthorizationContext::for_test(17, 1, [1; 32]), + ) + .await, + Err(AgentLiveBindingError::StaleBinding) + ); + registry.revalidate("account-a", 7, &first).await.unwrap(); + + assert_eq!( + registry + .revoke_peer( + endpoint(9), + &InstalledAuthorizationContext::for_test(17, 3, [3; 32]), + ) + .await, + Err(AgentLiveBindingError::StaleBinding) + ); + registry.revalidate("account-a", 7, &first).await.unwrap(); + } + + #[tokio::test] + async fn revoke_before_first_bind_tombstones_old_capability_until_newer_authority() { + let registry = AgentLiveBindingRegistry::new(); + registry + .revoke_peer( + endpoint(1), + &InstalledAuthorizationContext::for_test(17, 2, [2; 32]), + ) + .await + .unwrap(); + assert_eq!( + registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 2, 2, 3, 41, 1,)) + .await, + Err(AgentLiveBindingError::StaleBinding) + ); + let AgentLiveBindOutcome::Bound(fresh) = registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 3, 3, 3, 42, 1)) + .await + .unwrap() + else { + panic!("new installed authorization must clear the tombstone") + }; + assert_eq!(fresh.controller_endpoint(), endpoint(1)); + } + + #[tokio::test] + async fn revoke_between_rotation_proposal_and_commit_invalidates_obligation() { + let registry = AgentLiveBindingRegistry::new(); + registry.bind_or_refresh(initial(1)).await.unwrap(); + let AgentLiveBindOutcome::RotationRequired(obligation) = registry + .bind_or_refresh(verified("account-a", 8, TARGET_B, 1, 17, 2, 2, 4, 42, 1)) + .await + .unwrap() + else { + panic!("rotation proposal") + }; + registry + .revoke_peer( + endpoint(1), + &InstalledAuthorizationContext::for_test(17, 3, [3; 32]), + ) + .await + .unwrap(); + assert_eq!( + registry + .commit_rotation( + obligation, + verified("account-a", 8, TARGET_B, 1, 17, 3, 3, 4, 43, 1), + ) + .await, + Err(AgentLiveBindingError::StaleBinding) + ); + assert_eq!( + registry.require_bound("account-a", 8, endpoint(1)).await, + Err(AgentLiveBindingError::TransitionInProgress) + ); + let state = registry.state.lock().await; + let RegistryBindingState::Fenced { previous, .. } = &state.binding else { + panic!("revocation must discard the uncommitted proposal") + }; + assert_eq!(previous.account_generation, 7); + assert_eq!(previous.execution_target.as_str(), TARGET_A); + } + + #[tokio::test] + async fn account_revoke_equality_never_rebinds() { + let registry = AgentLiveBindingRegistry::new(); + registry + .revoke_account(&InstalledAuthorizationContext::for_test(17, 1, [1; 32])) + .await + .unwrap(); + assert_eq!( + registry.bind_or_refresh(initial(1)).await, + Err(AgentLiveBindingError::StaleBinding) + ); + assert_eq!( + registry + .bind_or_refresh(verified("account-a", 7, TARGET_A, 1, 17, 2, 2, 3, 42, 1,)) + .await, + Err(AgentLiveBindingError::StaleBinding) + ); + } + + #[tokio::test] + async fn authorization_receipt_from_another_admission_cannot_revoke_binding() { + let registry = AgentLiveBindingRegistry::new(); + let domain = InstalledAuthorizationDomain::for_test(); + { + let mut state = registry.state.lock().await; + state.authorization_domain = Some(domain.clone()); + } + let AgentLiveBindOutcome::Bound(old) = registry.bind_or_refresh(initial(1)).await.unwrap() + else { + panic!("first binding") + }; + let receipt = AuthorizationTransitionReceipt::for_test_in_domain( + InstalledAuthorizationDomain::for_test(), + Some(InstalledAuthorizationContext::for_test(17, 1, [1; 32])), + InstalledAuthorizationContext::for_test(17, 2, [2; 32]), + vec![old.controller_endpoint()], + false, + ); + assert!(matches!( + registry.apply_authorization_transition(receipt).await, + Err(AgentLiveBindingError::StaleBinding) + )); + registry.revalidate("account-a", 7, &old).await.unwrap(); + } + + #[test] + fn identical_scalars_from_another_admission_are_not_the_same_capability() { + let mut proposed = initial(1); + let authority = VerifiedIncomingPeerAuthorization::for_admission_identity_test( + InstalledAuthorizationContext::for_test(17, 1, [1; 32]), + endpoint(1), + Arc::from(TARGET_A), + proposed.pairing_fence, + proposed.connection_stamp, + ); + proposed.remote_authority = Some(authority.clone()); + let lease = AgentLiveBindingLease { + account_scope: Arc::from(proposed.account_scope.as_str()), + account_generation: proposed.account_generation, + execution_target: proposed.execution_target.clone(), + controller_endpoint: proposed.controller_endpoint, + authorization: proposed.authorization.clone(), + pairing_fence: proposed.pairing_fence, + connection_stamp: proposed.connection_stamp, + data_lineage_epoch: 1, + peer_lineage_epoch: 1, + remote_authority: Some(authority), + }; + assert!(verified_matches_lease(&proposed, &lease)); + + proposed.remote_authority = Some( + VerifiedIncomingPeerAuthorization::for_admission_identity_test( + InstalledAuthorizationContext::for_test(17, 1, [1; 32]), + endpoint(1), + Arc::from(TARGET_A), + proposed.pairing_fence, + proposed.connection_stamp, + ), + ); + assert!(!verified_matches_lease(&proposed, &lease)); + } +} diff --git a/frontend/src-tauri/src/agent_live_coordinator.rs b/frontend/src-tauri/src/agent_live_coordinator.rs new file mode 100644 index 000000000..ac2ed7fe8 --- /dev/null +++ b/frontend/src-tauri/src/agent_live_coordinator.rs @@ -0,0 +1,6355 @@ +//! Account-owned ordering for Maple-safe live Agent presentation events. +//! +//! Persisted Goose rows and live presentation events deliberately have +//! independent cursors. A head attach captures an absolute in-memory live +//! overlay at durable cursor C0, pauses a bounded subscriber while the caller +//! loads Goose's newest history page, then replays C0..C1 before making that +//! subscriber live. A cursor resume skips the history snapshot and replays +//! directly to its FIFO barrier. +//! +//! This module never accepts a raw Goose event, provider value, credential, or +//! arbitrary `serde_json::Value`. Callers must first project into the closed, +//! bounded [`MapleLiveEvent`] contract below. Runtime status is intentionally +//! absent: it is process/account state, not session replay state. + +#![allow( + dead_code, + reason = "the coordinator is wired by the remote Agent vertical slice" +)] + +use crate::agent_event_journal::{ + AppendOutcome, EventAdmission, LiveEventAccountOwner, LiveEventCursor, LiveEventJournal, + LiveEventJournalActivation, LiveEventJournalActivationError, LiveEventJournalError, + LiveEventJournalIngressLease, LiveEventJournalLease, LiveEventJournalReseedRequired, + LiveEventJournalRolloverObligation, LiveProjectionCheckpoint, LiveReplayEntry, + LiveReplayPayload, LiveReplayRead, SnapshotRequiredReason, +}; +use crate::agent_live_authority::{ + AgentDurableStableOperationId, AgentLiveDataOwnerKey, AGENT_LIVE_PROJECTION_SCHEMA_VERSION, +}; +use crate::remote_protocol::{ + SAFE_REMOTE_AGENT_ERROR, SAFE_REMOTE_PERMISSION_TITLE, SAFE_REMOTE_SETUP_WARNING, + SAFE_REMOTE_TOOL_CANCELLED, SAFE_REMOTE_TOOL_FAILED, SAFE_REMOTE_TOOL_TITLE, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + fmt, + sync::{mpsc as std_mpsc, Arc, Mutex as TerminalMutex}, + thread, +}; +use tokio::sync::{broadcast, mpsc, oneshot}; + +const DEFAULT_COMMAND_CAPACITY: usize = 128; +const MAX_COMMAND_CAPACITY: usize = 1_024; +const DEFAULT_SUBSCRIPTION_CAPACITY: usize = 128; +const MAX_SUBSCRIPTION_CAPACITY: usize = 512; +const MAX_BUFFERED_DELIVERY_BYTES: usize = MAX_TEXT_BYTES + 16 * 1024; +const MAX_ACCOUNT_SUBSCRIPTION_BUFFER_BYTES: usize = 64 * 1024 * 1024; +const REPLAY_PAGE_SIZE: usize = 50; +const MAX_ACCOUNT_SCOPE_BYTES: usize = 256; +const MAX_EXECUTION_TARGET_BYTES: usize = 128; +const MAX_OWNER_ID_BYTES: usize = 128; +const MAX_EVENT_ID_BYTES: usize = 128; +const MAX_ITEM_ID_BYTES: usize = 128; +const MAX_TITLE_BYTES: usize = 1_024; +const MAX_TEXT_BYTES: usize = 192 * 1_024; +const MAX_STATUS_BYTES: usize = 256; +const MAX_USER_FACING_ERROR_TITLE_BYTES: usize = 256; +const MAX_USER_FACING_ERROR_MESSAGE_BYTES: usize = 8 * 1024; +const MAX_PROJECT_ROOT_BYTES: usize = 4_096; +const MAX_MODEL_BYTES: usize = 256; +const MAX_MODE_BYTES: usize = 64; +const MAX_HISTORY_REVISION_BYTES: usize = 512; +const MAX_LIVE_ITEMS_PER_SESSION: usize = 200; +const MAX_LIVE_SESSIONS_PER_ACCOUNT: usize = 64; +const MAX_LIVE_ITEMS_PER_ACCOUNT: usize = 512; +const MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT: usize = + crate::remote_protocol::MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT; +const LIVE_CHECKPOINT_OUTER_OVERHEAD_BYTES: usize = 4 * 1024; +const LIVE_CHECKPOINT_SESSION_OVERHEAD_BYTES: usize = 256; +const LIVE_CHECKPOINT_ITEM_OVERHEAD_BYTES: usize = 256; +const MAX_SUBSCRIBERS_PER_ACCOUNT: usize = 64; +const MAX_INGRESS_ROUTES_PER_ACCOUNT: usize = 256; +const MAX_JAVASCRIPT_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const ACTOR_INGRESS_LINEAGE_BYTES: usize = 32; +const INGRESS_EVENT_ID_DOMAIN: &[u8] = b"maple-agent-live-ingress-event-v1\0"; +const LIVE_PAYLOAD_COMMITMENT_DOMAIN: &[u8] = b"maple-agent-live-payload-v1\0"; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MapleLiveItemType { + Message, + Thinking, + Tool, + Permission, + System, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MapleLiveRole { + User, + Assistant, + Thought, + System, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MapleLiveMerge { + Append, + Replace, +} + +/// Presentation-safe live timeline row. +/// +/// Tool arguments/results are intentionally not represented as arbitrary JSON. +/// Tool, error, and permission rows use fixed reviewed presentation strings; +/// only ordinary message/thinking/system rows may carry source presentation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct MapleLiveTimelineItem { + pub(crate) id: String, + pub(crate) item_type: MapleLiveItemType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) status: Option, + pub(crate) created_ms: u64, + pub(crate) merge: MapleLiveMerge, +} + +impl MapleLiveTimelineItem { + pub(crate) fn validate(&self) -> Result<(), AgentLiveProjectionError> { + validate_identifier(&self.id, MAX_ITEM_ID_BYTES)?; + validate_optional_text(self.title.as_deref(), MAX_TITLE_BYTES)?; + validate_optional_text(self.text.as_deref(), MAX_TEXT_BYTES)?; + validate_optional_text(self.status.as_deref(), MAX_STATUS_BYTES)?; + if self.item_type == MapleLiveItemType::Permission + && !matches!( + self.status.as_deref(), + Some("allow_once" | "deny_once" | "completed" | "cancelled") + ) + { + return Err(AgentLiveProjectionError::ActionablePermission); + } + if self.created_ms > MAX_JAVASCRIPT_SAFE_INTEGER { + return Err(AgentLiveProjectionError::InvalidTimestamp); + } + match self.item_type { + MapleLiveItemType::Tool => { + let expected_text = match self.status.as_deref() { + None | Some("pending" | "running" | "completed") => None, + Some("failed" | "error") => Some(SAFE_REMOTE_TOOL_FAILED), + Some("cancelled") => Some(SAFE_REMOTE_TOOL_CANCELLED), + Some(_) => return Err(AgentLiveProjectionError::UnsafePresentation), + }; + if self.role != Some(MapleLiveRole::Assistant) + || self.title.as_deref() != Some(SAFE_REMOTE_TOOL_TITLE) + || self.text.as_deref() != expected_text + { + return Err(AgentLiveProjectionError::UnsafePresentation); + } + } + MapleLiveItemType::Permission => { + if self.role != Some(MapleLiveRole::System) + || self.title.as_deref() != Some(SAFE_REMOTE_PERMISSION_TITLE) + || self.text.is_some() + { + return Err(AgentLiveProjectionError::UnsafePresentation); + } + } + MapleLiveItemType::Error => { + if self.role != Some(MapleLiveRole::System) + || self.title.as_deref() != Some("Agent error") + || self.text.as_deref() != Some(SAFE_REMOTE_AGENT_ERROR) + || self.status.as_deref() != Some("failed") + { + return Err(AgentLiveProjectionError::UnsafePresentation); + } + } + MapleLiveItemType::Message + | MapleLiveItemType::Thinking + | MapleLiveItemType::System => {} + } + Ok(()) + } + + fn as_absolute(mut self) -> Self { + self.merge = MapleLiveMerge::Replace; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct MapleLiveSessionSummary { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) project_root: String, + pub(crate) created_ms: i64, + pub(crate) updated_ms: i64, + /// Storage-derived sidebar order key. `updated_ms` remains the product's + /// semantic update time and must not be overloaded for keyset ordering. + pub(crate) page_sort_ms: i64, + pub(crate) message_count: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) model: Option, + pub(crate) mode: String, +} + +impl MapleLiveSessionSummary { + fn validate(&self) -> Result<(), AgentLiveProjectionError> { + validate_identifier(&self.id, MAX_OWNER_ID_BYTES)?; + validate_text(&self.title, MAX_TITLE_BYTES)?; + validate_text(&self.project_root, MAX_PROJECT_ROOT_BYTES)?; + validate_optional_text(self.model.as_deref(), MAX_MODEL_BYTES)?; + validate_text(&self.mode, MAX_MODE_BYTES)?; + for timestamp in [self.created_ms, self.updated_ms, self.page_sort_ms] { + if timestamp < 0 || (timestamp as u64) > MAX_JAVASCRIPT_SAFE_INTEGER { + return Err(AgentLiveProjectionError::InvalidTimestamp); + } + } + if u64::try_from(self.message_count) + .ok() + .is_none_or(|count| count > MAX_JAVASCRIPT_SAFE_INTEGER) + { + return Err(AgentLiveProjectionError::InvalidCount); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MapleLiveRunTerminal { + Completed, + Cancelled, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MapleLiveClearReason { + /// Explicitly discard the prior run's absolute overlay before a new run. + /// Publishing [`MapleLiveEvent::RunStarted`] alone never clears it. + RunStarted, + /// Explicitly discard the overlay after reconciling it against persisted + /// history. [`MapleLiveEvent::HistoryReplaced`] alone never clears it. + HistoryReplaced, + ExplicitReload, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MapleLiveUserFacingErrorKind { + Warning, + Error, +} + +/// Bounded, already-sanitized error presentation. Provider errors and raw +/// debug strings must be projected into this type before publication. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct MapleLiveUserFacingError { + pub(crate) id: String, + pub(crate) kind: MapleLiveUserFacingErrorKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) title: Option, + pub(crate) message: String, + pub(crate) created_ms: u64, +} + +impl MapleLiveUserFacingError { + fn validate(&self) -> Result<(), AgentLiveProjectionError> { + validate_identifier(&self.id, MAX_ITEM_ID_BYTES)?; + validate_optional_text(self.title.as_deref(), MAX_USER_FACING_ERROR_TITLE_BYTES)?; + if self.message.trim().is_empty() { + return Err(AgentLiveProjectionError::InvalidUserFacingError); + } + validate_text(&self.message, MAX_USER_FACING_ERROR_MESSAGE_BYTES)?; + if self.created_ms > MAX_JAVASCRIPT_SAFE_INTEGER { + return Err(AgentLiveProjectionError::InvalidTimestamp); + } + let (expected_title, expected_message) = match self.kind { + MapleLiveUserFacingErrorKind::Warning => ("Agent warning", SAFE_REMOTE_SETUP_WARNING), + MapleLiveUserFacingErrorKind::Error => ("Agent error", SAFE_REMOTE_AGENT_ERROR), + }; + if self.title.as_deref() != Some(expected_title) || self.message != expected_message { + return Err(AgentLiveProjectionError::UnsafePresentation); + } + Ok(()) + } + + pub(crate) fn to_timeline_item(&self) -> MapleLiveTimelineItem { + let (item_type, default_title, status) = match self.kind { + MapleLiveUserFacingErrorKind::Warning => { + (MapleLiveItemType::System, "Agent warning", "warning") + } + MapleLiveUserFacingErrorKind::Error => { + (MapleLiveItemType::Error, "Agent error", "failed") + } + }; + MapleLiveTimelineItem { + id: self.id.clone(), + item_type, + role: Some(MapleLiveRole::System), + title: Some( + self.title + .clone() + .unwrap_or_else(|| default_title.to_string()), + ), + text: Some(self.message.clone()), + status: Some(status.to_string()), + created_ms: self.created_ms, + merge: MapleLiveMerge::Replace, + } + } +} + +/// Closed durable event set admitted by the coordinator. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum MapleLiveEvent { + RunStarted { + event_id: String, + }, + TimelineUpsert { + event_id: String, + item: MapleLiveTimelineItem, + }, + TimelineCleared { + event_id: String, + reason: MapleLiveClearReason, + }, + /// Signals that persisted history changed. This never mutates the absolute + /// live overlay; publish `TimelineCleared` separately only after an + /// authoritative reconciliation says the overlay should be discarded. + HistoryReplaced { + event_id: String, + }, + /// Explicit acknowledgement that Goose history at `history_revision` + /// includes every live update through `through_event_cursor`. The actor + /// accepts this only when that cursor is its current FIFO head; newer live + /// output therefore cannot be cleared by a delayed commit. + HistoryHeadCommitted { + event_id: String, + history_revision: String, + through_event_cursor: LiveEventCursor, + }, + SessionUpdated { + event_id: String, + session: MapleLiveSessionSummary, + }, + RunFinished { + event_id: String, + terminal: MapleLiveRunTerminal, + }, + SessionDeleted { + event_id: String, + }, + UserFacingError { + event_id: String, + error: MapleLiveUserFacingError, + }, +} + +impl MapleLiveEvent { + pub(crate) fn event_id(&self) -> &str { + match self { + Self::RunStarted { event_id } + | Self::TimelineUpsert { event_id, .. } + | Self::TimelineCleared { event_id, .. } + | Self::HistoryReplaced { event_id } + | Self::HistoryHeadCommitted { event_id, .. } + | Self::SessionUpdated { event_id, .. } + | Self::RunFinished { event_id, .. } + | Self::SessionDeleted { event_id } + | Self::UserFacingError { event_id, .. } => event_id, + } + } + + fn validate(&self) -> Result<(), AgentLiveProjectionError> { + validate_identifier(self.event_id(), MAX_EVENT_ID_BYTES)?; + match self { + Self::TimelineUpsert { item, .. } => item.validate(), + Self::SessionUpdated { session, .. } => session.validate(), + Self::UserFacingError { error, .. } => error.validate(), + Self::HistoryHeadCommitted { + history_revision, + through_event_cursor, + .. + } => { + validate_identifier(history_revision, MAX_HISTORY_REVISION_BYTES)?; + through_event_cursor + .validate() + .map_err(|_| AgentLiveProjectionError::InvalidIdentifier) + } + Self::RunStarted { .. } + | Self::TimelineCleared { .. } + | Self::HistoryReplaced { .. } + | Self::RunFinished { .. } + | Self::SessionDeleted { .. } => Ok(()), + } + } +} + +/// Opaque producer capability for one exact actor, journal generation, and +/// session/run route. Cloning shares that same capability; it never creates a +/// new producer epoch. The type is deliberately not serializable. +#[derive(Clone)] +pub(crate) struct AgentLiveIngressLease { + journal_ingress: LiveEventJournalIngressLease, + data_owner: AgentLiveDataOwnerKey, + namespace: [u8; 32], + actor_lineage: [u8; ACTOR_INGRESS_LINEAGE_BYTES], + producer_epoch: u64, + session_id: Arc, + run_id: Option>, +} + +impl fmt::Debug for AgentLiveIngressLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AgentLiveIngressLease") + .field("session_id", &self.session_id) + .field("run_id", &self.run_id) + .field("producer_epoch", &"") + .field("namespace", &"") + .field("actor_lineage", &"") + .finish_non_exhaustive() + } +} + +impl AgentLiveIngressLease { + pub(crate) fn session_id(&self) -> &str { + &self.session_id + } + + pub(crate) fn run_id(&self) -> Option<&str> { + self.run_id.as_deref() + } + + /// Bind one native, durable stable operation ID to this exact producer. + /// The input must come from the trusted Goose/runtime adapter, never a + /// renderer or remote scalar. Reusing it in the same journal deterministically + /// reconstructs an exact retry; creating it through a fresh post-rollover + /// ingress is an explicit new-operation contract. + pub(crate) fn event_id( + &self, + durable_stable_operation_id: &AgentDurableStableOperationId, + ) -> Result { + if durable_stable_operation_id.owner() != &self.data_owner + || durable_stable_operation_id.session_id() != self.session_id.as_ref() + || durable_stable_operation_id.run_id() != self.run_id.as_deref() + { + return Err(AgentLiveCoordinatorError::StableOperationMismatch); + } + if durable_stable_operation_id.projection_schema_version() + != AGENT_LIVE_PROJECTION_SCHEMA_VERSION + { + return Err(AgentLiveCoordinatorError::ProjectionSchemaMismatch); + } + if durable_stable_operation_id.journal_namespace_commitment() != &self.namespace { + return Err(AgentLiveCoordinatorError::IngressRebindRequired); + } + let wire = ingress_event_wire_id( + &self.namespace, + &self.session_id, + self.run_id.as_deref(), + durable_stable_operation_id.as_str(), + ); + Ok(IngressEventId { + wire, + namespace: self.namespace, + actor_lineage: self.actor_lineage, + producer_epoch: self.producer_epoch, + session_id: Arc::clone(&self.session_id), + run_id: self.run_id.as_ref().map(Arc::clone), + payload_commitment: *durable_stable_operation_id.payload_commitment(), + }) + } +} + +/// Typed event identity. Its wire string is durable, while the hidden producer +/// fields prevent pairing an old in-memory event with a newly admitted lease. +/// It is intentionally not deserializable; journal replay reconstructs only the +/// closed [`MapleLiveEvent`] DTO and can never re-enter producer publication. +#[derive(Clone)] +pub(crate) struct IngressEventId { + wire: String, + namespace: [u8; 32], + actor_lineage: [u8; ACTOR_INGRESS_LINEAGE_BYTES], + producer_epoch: u64, + session_id: Arc, + run_id: Option>, + payload_commitment: [u8; 32], +} + +impl fmt::Debug for IngressEventId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IngressEventId") + .field("wire", &"") + .field("producer_epoch", &"") + .finish_non_exhaustive() + } +} + +impl IngressEventId { + pub(crate) fn presentation_id(&self) -> &str { + &self.wire + } +} + +/// Non-serializable producer envelope. Every constructor copies the typed ID +/// into the durable DTO, and publication rechecks both copies plus the exact +/// ingress capability before the journal sees the event. +#[derive(Debug, Clone)] +pub(crate) struct AgentLivePublishEvent { + id: IngressEventId, + event: MapleLiveEvent, +} + +impl AgentLivePublishEvent { + fn new(id: IngressEventId, event: MapleLiveEvent) -> Self { + debug_assert_eq!(id.wire, event.event_id()); + Self { id, event } + } + + pub(crate) fn run_started(id: IngressEventId) -> Self { + Self::new(id.clone(), MapleLiveEvent::RunStarted { event_id: id.wire }) + } + + pub(crate) fn timeline_upsert(id: IngressEventId, item: MapleLiveTimelineItem) -> Self { + Self::new( + id.clone(), + MapleLiveEvent::TimelineUpsert { + event_id: id.wire, + item, + }, + ) + } + + pub(crate) fn timeline_cleared(id: IngressEventId, reason: MapleLiveClearReason) -> Self { + Self::new( + id.clone(), + MapleLiveEvent::TimelineCleared { + event_id: id.wire, + reason, + }, + ) + } + + pub(crate) fn history_replaced(id: IngressEventId) -> Self { + Self::new( + id.clone(), + MapleLiveEvent::HistoryReplaced { event_id: id.wire }, + ) + } + + pub(crate) fn history_head_committed( + id: IngressEventId, + history_revision: String, + through_event_cursor: LiveEventCursor, + ) -> Self { + Self::new( + id.clone(), + MapleLiveEvent::HistoryHeadCommitted { + event_id: id.wire, + history_revision, + through_event_cursor, + }, + ) + } + + pub(crate) fn session_updated(id: IngressEventId, session: MapleLiveSessionSummary) -> Self { + Self::new( + id.clone(), + MapleLiveEvent::SessionUpdated { + event_id: id.wire, + session, + }, + ) + } + + pub(crate) fn run_finished(id: IngressEventId, terminal: MapleLiveRunTerminal) -> Self { + Self::new( + id.clone(), + MapleLiveEvent::RunFinished { + event_id: id.wire, + terminal, + }, + ) + } + + pub(crate) fn session_deleted(id: IngressEventId) -> Self { + Self::new( + id.clone(), + MapleLiveEvent::SessionDeleted { event_id: id.wire }, + ) + } + + pub(crate) fn user_facing_error(id: IngressEventId, error: MapleLiveUserFacingError) -> Self { + Self::new( + id.clone(), + MapleLiveEvent::UserFacingError { + event_id: id.wire, + error, + }, + ) + } + + #[cfg(test)] + fn from_test_durable(id: IngressEventId, event: MapleLiveEvent) -> Self { + match event { + MapleLiveEvent::RunStarted { .. } => Self::run_started(id), + MapleLiveEvent::TimelineUpsert { item, .. } => Self::timeline_upsert(id, item), + MapleLiveEvent::TimelineCleared { reason, .. } => Self::timeline_cleared(id, reason), + MapleLiveEvent::HistoryReplaced { .. } => Self::history_replaced(id), + MapleLiveEvent::HistoryHeadCommitted { + history_revision, + through_event_cursor, + .. + } => Self::history_head_committed(id, history_revision, through_event_cursor), + MapleLiveEvent::SessionUpdated { session, .. } => Self::session_updated(id, session), + MapleLiveEvent::RunFinished { terminal, .. } => Self::run_finished(id, terminal), + MapleLiveEvent::SessionDeleted { .. } => Self::session_deleted(id), + MapleLiveEvent::UserFacingError { error, .. } => Self::user_facing_error(id, error), + } + } +} + +impl LiveReplayPayload for MapleLiveEvent { + fn live_replay_event_id(&self) -> &str { + self.event_id() + } + + fn validate_live_replay_payload(&self) -> Result<(), LiveEventJournalError> { + self.validate().map_err(|error| match error { + AgentLiveProjectionError::TextTooLarge + | AgentLiveProjectionError::TooManyTimelineItems + | AgentLiveProjectionError::MergedItemTooLarge + | AgentLiveProjectionError::AccountProjectionCapacityExceeded => { + LiveEventJournalError::PayloadTooLarge + } + AgentLiveProjectionError::InvalidIdentifier + | AgentLiveProjectionError::ConflictingItemIdentity + | AgentLiveProjectionError::ActionablePermission + | AgentLiveProjectionError::InvalidTimestamp + | AgentLiveProjectionError::InvalidCount + | AgentLiveProjectionError::InvalidUserFacingError + | AgentLiveProjectionError::UnsafePresentation => { + LiveEventJournalError::InvalidEventOwner + } + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentLiveProjectionError { + InvalidIdentifier, + TextTooLarge, + TooManyTimelineItems, + ConflictingItemIdentity, + ActionablePermission, + InvalidTimestamp, + InvalidCount, + InvalidUserFacingError, + UnsafePresentation, + MergedItemTooLarge, + AccountProjectionCapacityExceeded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HeadReloadReason { + PausedSubscriberOverflow, + SlowSubscriber, + JournalReplaced, + RetentionGap, + CursorAhead, + OwnerChanged, + OrderingLost, + JournalUnavailable, + ReseedRequired, +} + +/// Terminal lifecycle reason for a coordinator instance. Sealing is distinct +/// from a recoverable head reload: callers must construct a newly owned +/// coordinator before any further publication. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentLiveSealReason { + OwnerChanged, + AccountSignedOut, + ExecutionTargetStopped, + HostShutdown, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum AgentLiveCoordinatorError { + InvalidAccountScope, + InvalidExecutionTarget, + InvalidSession, + InvalidRun, + DataOwnerMismatch, + StableOperationMismatch, + ProjectionSchemaMismatch, + InvalidSubscriptionCapacity, + InvalidCommandCapacity, + SubscriberCapacityExceeded, + IngressRouteCapacityExceeded, + IngressEpochExhausted, + IngressRebindRequired, + StaleHistoryCommit, + Projection(AgentLiveProjectionError), + Journal(LiveEventJournalError), + ReseedRequired(LiveEventJournalReseedRequired), + HeadReloadRequired(HeadReloadReason), + Sealed(AgentLiveSealReason), + WorkerUnavailable, + CoordinatorClosed, +} + +impl fmt::Display for AgentLiveCoordinatorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidAccountScope => "live Agent account scope is invalid", + Self::InvalidExecutionTarget => "live Agent execution target is invalid", + Self::InvalidSession => "live Agent session owner is invalid", + Self::InvalidRun => "live Agent run owner is invalid", + Self::DataOwnerMismatch => { + "live Agent data owner does not match the journal activation" + } + Self::StableOperationMismatch => { + "durable operation authority does not match the live Agent owner or route" + } + Self::ProjectionSchemaMismatch => { + "durable operation authority uses a different live projection schema" + } + Self::InvalidSubscriptionCapacity => "live Agent subscription capacity is invalid", + Self::InvalidCommandCapacity => "live Agent command capacity is invalid", + Self::SubscriberCapacityExceeded => "live Agent account has too many subscribers", + Self::IngressRouteCapacityExceeded => { + "live Agent account has too many admitted producer routes" + } + Self::IngressEpochExhausted => "live Agent producer epoch is exhausted", + Self::IngressRebindRequired => { + "live Agent producer must explicitly bind to the current journal generation" + } + Self::StaleHistoryCommit => { + "Agent history advanced after the persisted-head acknowledgement" + } + Self::Projection(_) => "live Agent projection is invalid", + Self::Journal(_) => "live Agent journal operation failed", + Self::ReseedRequired(_) => { + "the live Agent journal requires a verified authoritative reseed" + } + Self::HeadReloadRequired(_) => "the Agent history head must be reloaded", + Self::Sealed(_) => "the live Agent coordinator is sealed", + Self::WorkerUnavailable => "live Agent journal worker is unavailable", + Self::CoordinatorClosed => "live Agent coordinator is closed", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AgentLiveCoordinatorError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentLiveDelivery { + pub(crate) cursor: LiveEventCursor, + pub(crate) session_id: String, + pub(crate) run_id: Option, + pub(crate) event: MapleLiveEvent, +} + +impl AgentLiveDelivery { + pub(crate) fn validate(&self) -> Result<(), AgentLiveCoordinatorError> { + validate_owner_id(&self.session_id).map_err(AgentLiveCoordinatorError::Projection)?; + if let Some(run_id) = self.run_id.as_deref() { + validate_owner_id(run_id).map_err(AgentLiveCoordinatorError::Projection)?; + } + self.event + .validate() + .map_err(AgentLiveCoordinatorError::Projection)?; + validate_event_route(&self.session_id, self.run_id.as_deref(), &self.event) + } +} + +#[derive(Clone)] +pub(crate) struct AgentLiveCoordinator { + data_owner: AgentLiveDataOwnerKey, + execution_target: Arc, + commands: mpsc::Sender, +} + +impl AgentLiveCoordinator { + /// Start from the exact opaque lease activated by the host while holding + /// its account/target lifecycle lock. The host must derive the owner with + /// [`target_bound_owner`] before activation; this coordinator never + /// reconstructs authority from raw account-shaped values. + pub(crate) async fn start_activated( + journal: LiveEventJournal, + lease: LiveEventJournalLease, + data_owner: AgentLiveDataOwnerKey, + execution_target: impl Into, + ) -> Result { + let execution_target = execution_target.into(); + validate_identifier(&execution_target, MAX_EXECUTION_TARGET_BYTES) + .map_err(|_| AgentLiveCoordinatorError::InvalidExecutionTarget)?; + if lease.account_generation() != data_owner.account_generation() { + return Err(AgentLiveCoordinatorError::DataOwnerMismatch); + } + if execution_target != data_owner.execution_target() { + return Err(AgentLiveCoordinatorError::InvalidExecutionTarget); + } + Self::start_with_backend( + Arc::new(journal), + lease, + data_owner, + execution_target, + DEFAULT_COMMAND_CAPACITY, + ) + .await + } + + #[cfg(test)] + async fn start( + journal: LiveEventJournal, + opaque_account_scope: &str, + account_generation: u64, + execution_target: impl Into, + ) -> Result { + let execution_target = execution_target.into(); + validate_identifier(&execution_target, MAX_EXECUTION_TARGET_BYTES) + .map_err(|_| AgentLiveCoordinatorError::InvalidExecutionTarget)?; + let owner = + target_bound_owner(opaque_account_scope, account_generation, &execution_target)?; + let lease = journal + .activate_account(&owner) + .map_err(map_journal_activation)?; + let data_owner = AgentLiveDataOwnerKey::for_test( + opaque_account_scope, + account_generation, + execution_target.clone(), + 0, + ); + Self::start_activated(journal, lease, data_owner, execution_target).await + } + + async fn start_with_backend( + journal: Arc, + lease: LiveEventJournalLease, + data_owner: AgentLiveDataOwnerKey, + execution_target: String, + command_capacity: usize, + ) -> Result { + validate_identifier(&execution_target, MAX_EXECUTION_TARGET_BYTES) + .map_err(|_| AgentLiveCoordinatorError::InvalidExecutionTarget)?; + if command_capacity == 0 || command_capacity > MAX_COMMAND_CAPACITY { + return Err(AgentLiveCoordinatorError::InvalidCommandCapacity); + } + + // The exact opaque account/target lease is captured once here and + // carried by every blocking operation. No publish/attach call can + // substitute owner-shaped data or revive a retired journal. + let disk = BlockingJournalWorker::spawn(journal, lease)?; + let durable_cursor = disk.checkpoint().await.map_err(map_journal_for_attach)?; + let projection_checkpoint = disk + .load_projection_checkpoint() + .await + .map_err(map_journal_for_attach)?; + let (commands, receiver) = mpsc::channel(command_capacity); + let actor = CoordinatorActor::load( + disk, + data_owner.clone(), + durable_cursor, + projection_checkpoint, + ) + .await?; + tokio::spawn(actor.run(receiver)); + Ok(Self { + data_owner, + execution_target: Arc::from(execution_target), + commands, + }) + } + + pub(crate) fn execution_target(&self) -> &str { + &self.execution_target + } + + /// Explicitly admit one producer route at the actor's FIFO. This never runs + /// inside `publish`: after a rollover or producer supersession, the native + /// runtime must deliberately bind again before constructing any new event. + pub(crate) async fn begin_ingress( + &self, + session_id: impl Into, + run_id: Option, + ) -> Result { + let session_id = session_id.into(); + validate_owner_id(&session_id).map_err(|_| AgentLiveCoordinatorError::InvalidSession)?; + if let Some(run_id) = run_id.as_deref() { + validate_owner_id(run_id).map_err(|_| AgentLiveCoordinatorError::InvalidRun)?; + } + let (reply, response) = oneshot::channel(); + self.commands + .send(CoordinatorCommand::BeginIngress { + session_id, + run_id, + reply, + }) + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)?; + response + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)? + } + + /// Durably append before updating the absolute overlay or notifying any + /// subscriber. The route and event ID come exclusively from the exact + /// opaque producer lease; publication never looks up or swaps to a current + /// ingress capability on the caller's behalf. + pub(crate) async fn publish( + &self, + ingress: &AgentLiveIngressLease, + event: AgentLivePublishEvent, + ) -> Result { + event + .event + .validate() + .map_err(AgentLiveCoordinatorError::Projection)?; + validate_event_route(ingress.session_id(), ingress.run_id(), &event.event)?; + let (reply, response) = oneshot::channel(); + self.commands + .send(CoordinatorCommand::Publish { + ingress: ingress.clone(), + event, + reply, + }) + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)?; + response + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)? + } + + /// FIFO barrier C0 plus an authoritative, order-stable absolute overlay. + /// The returned subscriber remains paused until its token is finalized. + pub(crate) async fn begin_account_head_attach( + &self, + capacity: Option, + ) -> Result { + let capacity = validate_subscription_capacity(capacity)?; + let (sender, receiver) = broadcast::channel(capacity); + let terminal_reason = Arc::new(TerminalMutex::new(None)); + let (reply, response) = oneshot::channel(); + self.commands + .send(CoordinatorCommand::BeginHeadAttach { + capacity, + sender, + terminal_reason: Arc::clone(&terminal_reason), + cancellation_commands: self.commands.clone(), + reply, + }) + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)?; + let begun = response + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)??; + let subscriber_id = begun.subscriber.transfer(); + Ok(AgentHeadAttach { + through_cursor: begun.through_cursor, + live_sessions_complete: true, + live_sessions: begun.live_sessions, + token: AgentHeadAttachToken { + subscriber_id: Some(subscriber_id), + commands: self.commands.clone(), + receiver: Some(receiver), + terminal_reason, + }, + }) + } + + /// Cursor-first replay for an already attached client. The FIFO barrier is + /// held until the bounded replay is enqueued and the subscriber is live. + pub(crate) async fn begin_resume( + &self, + cursor: LiveEventCursor, + capacity: Option, + ) -> Result { + cursor + .validate() + .map_err(AgentLiveCoordinatorError::Journal)?; + let capacity = validate_subscription_capacity(capacity)?; + let (sender, receiver) = broadcast::channel(capacity); + let terminal_reason = Arc::new(TerminalMutex::new(None)); + let (reply, response) = oneshot::channel(); + self.commands + .send(CoordinatorCommand::BeginResume { + cursor, + capacity, + sender, + terminal_reason: Arc::clone(&terminal_reason), + cancellation_commands: self.commands.clone(), + reply, + }) + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)?; + let begun = response + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)??; + let subscriber_id = begun.subscriber.transfer(); + Ok(AgentLiveResume { + through_cursor: begun.through_cursor, + subscription: AgentLiveSubscription { + subscriber_id: Some(subscriber_id), + commands: self.commands.clone(), + receiver, + terminal_reason, + }, + }) + } + + /// FIFO lifecycle barrier. Every command accepted before this command is + /// handled first; once it returns, pending attaches and active subscribers + /// are closed and every later mutation is rejected with the seal reason. + pub(crate) async fn seal( + &self, + reason: AgentLiveSealReason, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .send(CoordinatorCommand::Seal { reason, reply }) + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)?; + response + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)? + } + + /// Retire one session's absolute live suffix only after the caller has + /// durably replaced that session's Goose head through the supplied account + /// cursor. `history_revision` must cover every projected run/item for the + /// session; the coordinator can fence ordering but cannot inspect Goose. + pub(crate) async fn acknowledge_persisted_head( + &self, + ingress: &AgentLiveIngressLease, + event_id: IngressEventId, + history_revision: impl Into, + through_event_cursor: LiveEventCursor, + ) -> Result { + self.publish( + ingress, + AgentLivePublishEvent::history_head_committed( + event_id, + history_revision.into(), + through_event_cursor, + ), + ) + .await + } + + #[cfg(test)] + async fn publish_for_test( + &self, + session_id: impl Into, + run_id: Option, + event: MapleLiveEvent, + ) -> Result { + let session_id = session_id.into(); + let ingress = self + .begin_ingress(session_id.clone(), run_id.clone()) + .await?; + let event = self.publish_event_for_test(&ingress, event)?; + self.publish(&ingress, event).await + } + + #[cfg(test)] + fn publish_event_for_test( + &self, + ingress: &AgentLiveIngressLease, + event: MapleLiveEvent, + ) -> Result { + let payload_commitment = + live_event_payload_commitment(ingress.session_id(), ingress.run_id(), &event)?; + let stable_operation = AgentDurableStableOperationId::for_test( + self.data_owner.clone(), + ingress.session_id(), + ingress.run_id().map(str::to_string), + event.event_id(), + ingress.namespace, + payload_commitment, + ); + let event_id = ingress.event_id(&stable_operation)?; + Ok(AgentLivePublishEvent::from_test_durable(event_id, event)) + } + + #[cfg(test)] + async fn acknowledge_persisted_head_for_test( + &self, + session_id: impl Into, + stable_operation_id: impl Into, + history_revision: impl Into, + through_event_cursor: LiveEventCursor, + ) -> Result { + self.publish_for_test( + session_id, + None, + MapleLiveEvent::HistoryHeadCommitted { + event_id: stable_operation_id.into(), + history_revision: history_revision.into(), + through_event_cursor, + }, + ) + .await + } +} + +/// FIFO proof that this coordinator stopped accepting mutations at one exact +/// durable head. The host may pass these fields directly to +/// `LiveEventJournal::seal_for_retirement` before retiring the account file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentLiveSeal { + pub(crate) journal_lease: LiveEventJournalLease, + pub(crate) through_cursor: LiveEventCursor, + pub(crate) reason: AgentLiveSealReason, +} + +pub(crate) struct AgentHeadAttach { + pub(crate) through_cursor: LiveEventCursor, + /// Always true for this v1 coordinator. Consumers must clear cached live + /// overlays for sessions absent from `live_sessions` at this same C0. + pub(crate) live_sessions_complete: bool, + pub(crate) live_sessions: Vec, + pub(crate) token: AgentHeadAttachToken, +} + +impl AgentHeadAttach { + pub(crate) fn live_items_for_session(&self, session_id: &str) -> &[MapleLiveTimelineItem] { + self.live_sessions + .iter() + .find(|session| session.session_id == session_id) + .map(|session| session.live_items.as_slice()) + .unwrap_or_default() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AgentLiveSessionProjection { + pub(crate) session_id: String, + /// Empty is authoritative, not "snapshot omitted". + pub(crate) live_items: Vec, +} + +const LIVE_PROJECTION_CHECKPOINT_VERSION: u8 = 1; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CoordinatorProjectionCheckpoint { + format_version: u8, + live_sessions: Vec, +} + +fn decode_projection_checkpoint( + bytes: &[u8], +) -> Result, AgentLiveCoordinatorError> { + if bytes.is_empty() || bytes.len() > MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT { + return Err(invalid_projection_checkpoint()); + } + let checkpoint: CoordinatorProjectionCheckpoint = + serde_json::from_slice(bytes).map_err(|_| invalid_projection_checkpoint())?; + if checkpoint.format_version != LIVE_PROJECTION_CHECKPOINT_VERSION + || checkpoint.live_sessions.len() > MAX_LIVE_SESSIONS_PER_ACCOUNT + { + return Err(invalid_projection_checkpoint()); + } + + let mut sessions = HashMap::with_capacity(checkpoint.live_sessions.len()); + let mut previous_session_id: Option = None; + for live_session in checkpoint.live_sessions { + if live_session.live_items.is_empty() + || live_session.live_items.len() > MAX_LIVE_ITEMS_PER_SESSION + || validate_owner_id(&live_session.session_id).is_err() + || previous_session_id + .as_deref() + .is_some_and(|previous| previous >= live_session.session_id.as_str()) + { + return Err(invalid_projection_checkpoint()); + } + let session_id = live_session.session_id; + previous_session_id = Some(session_id.clone()); + let mut projection = SessionLiveProjection::default(); + for item in live_session.live_items { + if item.merge != MapleLiveMerge::Replace + || projection.items.iter().any(|known| known.id == item.id) + { + return Err(invalid_projection_checkpoint()); + } + projection + .upsert(item) + .map_err(|_| invalid_projection_checkpoint())?; + } + if sessions.insert(session_id, projection).is_some() { + return Err(invalid_projection_checkpoint()); + } + } + + let mut item_count = 0usize; + let mut projected_bytes = LIVE_CHECKPOINT_OUTER_OVERHEAD_BYTES; + for (session_id, projection) in &sessions { + accumulate_projection_bounds( + session_id, + projection, + &mut item_count, + &mut projected_bytes, + ) + .map_err(|_| invalid_projection_checkpoint())?; + } + if item_count > MAX_LIVE_ITEMS_PER_ACCOUNT + || projected_bytes > MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT + { + return Err(invalid_projection_checkpoint()); + } + Ok(sessions) +} + +fn invalid_projection_checkpoint() -> AgentLiveCoordinatorError { + AgentLiveCoordinatorError::Journal(LiveEventJournalError::InvalidCheckpoint) +} + +fn projection_checkpoint_matches( + checkpoint: Option<&LiveProjectionCheckpoint>, + through: &LiveEventCursor, + bytes: &[u8], +) -> bool { + checkpoint.is_some_and(|checkpoint| { + checkpoint.through_cursor == *through && checkpoint.bytes == bytes + }) +} + +fn set_terminal_reason( + target: &Arc>>, + reason: HeadReloadReason, +) { + if let Ok(mut terminal) = target.lock() { + *terminal = Some(reason); + } +} + +pub(crate) struct AgentHeadAttachToken { + subscriber_id: Option, + commands: mpsc::Sender, + receiver: Option>, + terminal_reason: Arc>>, +} + +impl AgentHeadAttachToken { + /// Replay C0..C1, then resume the same subscriber. Events published while + /// Goose history was loading are delivered exactly once through `recv`. + pub(crate) async fn finalize(mut self) -> Result { + let subscriber_id = self + .subscriber_id + .take() + .ok_or(AgentLiveCoordinatorError::CoordinatorClosed)?; + let mut cancellation_guard = + SubscriberCancellationGuard::new(subscriber_id, self.commands.clone()); + let (reply, response) = oneshot::channel(); + self.commands + .send(CoordinatorCommand::FinalizeHeadAttach { + subscriber_id, + reply, + }) + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)?; + let through_cursor = match response.await { + Ok(Ok(cursor)) => cursor, + Ok(Err(AgentLiveCoordinatorError::CoordinatorClosed)) => { + return Err(terminal_coordinator_error(&self.terminal_reason) + .unwrap_or(AgentLiveCoordinatorError::CoordinatorClosed)); + } + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err(terminal_coordinator_error(&self.terminal_reason) + .unwrap_or(AgentLiveCoordinatorError::CoordinatorClosed)); + } + }; + let receiver = self + .receiver + .take() + .ok_or(AgentLiveCoordinatorError::CoordinatorClosed)?; + let resume = AgentLiveResume { + through_cursor, + subscription: AgentLiveSubscription { + subscriber_id: Some(subscriber_id), + commands: self.commands.clone(), + receiver, + terminal_reason: Arc::clone(&self.terminal_reason), + }, + }; + cancellation_guard.disarm(); + Ok(resume) + } + + /// Cancel a paused attach and wait until the actor has released its bounded + /// subscriber reservation. Dropping the token is only best-effort; callers + /// that need a lifecycle barrier should use this acknowledged API. + pub(crate) async fn cancel(mut self) -> Result<(), AgentLiveCoordinatorError> { + let subscriber_id = self + .subscriber_id + .take() + .ok_or(AgentLiveCoordinatorError::CoordinatorClosed)?; + let mut cancellation_guard = + SubscriberCancellationGuard::new(subscriber_id, self.commands.clone()); + unsubscribe_with_ack(&self.commands, subscriber_id).await?; + cancellation_guard.disarm(); + Ok(()) + } +} + +impl Drop for AgentHeadAttachToken { + fn drop(&mut self) { + if let Some(subscriber_id) = self.subscriber_id.take() { + try_unsubscribe(&self.commands, subscriber_id); + } + } +} + +pub(crate) struct AgentLiveResume { + pub(crate) through_cursor: LiveEventCursor, + pub(crate) subscription: AgentLiveSubscription, +} + +pub(crate) struct AgentLiveSubscription { + subscriber_id: Option, + commands: mpsc::Sender, + receiver: broadcast::Receiver, + terminal_reason: Arc>>, +} + +impl AgentLiveSubscription { + pub(crate) async fn recv(&mut self) -> Result { + self.receiver.recv().await.map_err(|error| match error { + broadcast::error::RecvError::Lagged(_) => { + AgentLiveReceiveError::HeadReloadRequired(HeadReloadReason::SlowSubscriber) + } + broadcast::error::RecvError::Closed => self + .terminal_reason + .lock() + .ok() + .and_then(|reason| *reason) + .map(AgentLiveReceiveError::HeadReloadRequired) + .unwrap_or(AgentLiveReceiveError::Closed), + }) + } + + /// Unregister this active subscription and wait for the actor to reclaim + /// its aggregate buffer budget. This is safe after a seal or reload fence. + pub(crate) async fn unsubscribe(mut self) -> Result<(), AgentLiveCoordinatorError> { + let subscriber_id = self + .subscriber_id + .take() + .ok_or(AgentLiveCoordinatorError::CoordinatorClosed)?; + let mut cancellation_guard = + SubscriberCancellationGuard::new(subscriber_id, self.commands.clone()); + unsubscribe_with_ack(&self.commands, subscriber_id).await?; + cancellation_guard.disarm(); + Ok(()) + } +} + +impl Drop for AgentLiveSubscription { + fn drop(&mut self) { + if let Some(subscriber_id) = self.subscriber_id.take() { + try_unsubscribe(&self.commands, subscriber_id); + } + } +} + +/// Cancellation safety for async subscriber lifecycle operations. Once an ID +/// leaves its owning token/subscription, this guard retains the cleanup duty +/// across every await until the actor acknowledges removal or ownership is +/// transferred into a successfully constructed live subscription. +struct SubscriberCancellationGuard { + subscriber_id: Option, + commands: mpsc::Sender, +} + +impl SubscriberCancellationGuard { + fn new(subscriber_id: u64, commands: mpsc::Sender) -> Self { + Self { + subscriber_id: Some(subscriber_id), + commands, + } + } + + fn disarm(&mut self) { + self.subscriber_id = None; + } +} + +impl Drop for SubscriberCancellationGuard { + fn drop(&mut self) { + if let Some(subscriber_id) = self.subscriber_id.take() { + try_unsubscribe(&self.commands, subscriber_id); + } + } +} + +async fn unsubscribe_with_ack( + commands: &mpsc::Sender, + subscriber_id: u64, +) -> Result<(), AgentLiveCoordinatorError> { + let (reply, response) = oneshot::channel(); + commands + .send(CoordinatorCommand::Unsubscribe { + subscriber_id, + reply: Some(reply), + }) + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)?; + response + .await + .map_err(|_| AgentLiveCoordinatorError::CoordinatorClosed)? +} + +fn try_unsubscribe(commands: &mpsc::Sender, subscriber_id: u64) { + let command = CoordinatorCommand::Unsubscribe { + subscriber_id, + reply: None, + }; + if let Err(mpsc::error::TrySendError::Full(command)) = commands.try_send(command) { + // A Drop can run while the bounded actor queue is full (including when + // cancellation interrupts the original send). Preserve bounded + // backpressure by waiting in one runtime task rather than losing the + // unregister operation or introducing an unbounded side channel. + let commands = commands.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = commands.send(command).await; + }); + } + } +} + +fn terminal_coordinator_error( + reason: &Arc>>, +) -> Option { + reason + .lock() + .ok() + .and_then(|reason| *reason) + .map(AgentLiveCoordinatorError::HeadReloadRequired) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentLiveReceiveError { + HeadReloadRequired(HeadReloadReason), + Closed, +} + +struct PendingSubscriberOwnership { + subscriber_id: Option, + commands: mpsc::Sender, +} + +impl PendingSubscriberOwnership { + fn new(subscriber_id: u64, commands: mpsc::Sender) -> Self { + Self { + subscriber_id: Some(subscriber_id), + commands, + } + } + + fn transfer(mut self) -> u64 { + self.subscriber_id + .take() + .expect("pending subscriber ownership transfers once") + } +} + +impl Drop for PendingSubscriberOwnership { + fn drop(&mut self) { + if let Some(subscriber_id) = self.subscriber_id.take() { + try_unsubscribe(&self.commands, subscriber_id); + } + } +} + +struct BeginHeadAttachResult { + subscriber: PendingSubscriberOwnership, + through_cursor: LiveEventCursor, + live_sessions: Vec, +} + +struct BeginResumeResult { + subscriber: PendingSubscriberOwnership, + through_cursor: LiveEventCursor, +} + +enum CoordinatorCommand { + BeginIngress { + session_id: String, + run_id: Option, + reply: oneshot::Sender>, + }, + Publish { + ingress: AgentLiveIngressLease, + event: AgentLivePublishEvent, + reply: oneshot::Sender>, + }, + BeginHeadAttach { + capacity: usize, + sender: broadcast::Sender, + terminal_reason: Arc>>, + cancellation_commands: mpsc::Sender, + reply: oneshot::Sender>, + }, + FinalizeHeadAttach { + subscriber_id: u64, + reply: oneshot::Sender>, + }, + BeginResume { + cursor: LiveEventCursor, + capacity: usize, + sender: broadcast::Sender, + terminal_reason: Arc>>, + cancellation_commands: mpsc::Sender, + reply: oneshot::Sender>, + }, + Unsubscribe { + subscriber_id: u64, + reply: Option>>, + }, + Seal { + reason: AgentLiveSealReason, + reply: oneshot::Sender>, + }, +} + +struct CoordinatorActor { + disk: BlockingJournalWorker, + data_owner: AgentLiveDataOwnerKey, + durable_cursor: LiveEventCursor, + pending_rollover: Option, + actor_lineage: [u8; ACTOR_INGRESS_LINEAGE_BYTES], + next_producer_epoch: u64, + ingress_epochs: HashMap, + sessions: HashMap, + subscribers: HashMap, + next_subscriber_id: u64, + poison: Option, + sealed: Option, + seal_result: Option, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +struct IngressRoute { + session_id: String, + run_id: Option, +} + +/// The underlying capability is move-only. The actor retains the sole owning +/// allocation while its blocking worker borrows it through a short-lived +/// `Arc`; an ambiguous commit acknowledgement can therefore never discard or +/// substitute the exact prepared rollover obligation. +struct PendingRollover { + obligation: Arc, + previous_cursor: LiveEventCursor, + checkpoint_bytes: Vec, +} + +impl CoordinatorActor { + async fn load( + disk: BlockingJournalWorker, + data_owner: AgentLiveDataOwnerKey, + durable_cursor: LiveEventCursor, + projection_checkpoint: Option, + ) -> Result { + let mut actor_lineage = [0u8; ACTOR_INGRESS_LINEAGE_BYTES]; + getrandom::fill(&mut actor_lineage) + .map_err(|_| AgentLiveCoordinatorError::WorkerUnavailable)?; + let (sessions, replay_cursor) = match projection_checkpoint { + Some(checkpoint) => { + if checkpoint.through_cursor.journal_id() != durable_cursor.journal_id() { + return Err(invalid_projection_checkpoint()); + } + if checkpoint.through_cursor.sequence() > durable_cursor.sequence() { + return Err(invalid_projection_checkpoint()); + } + ( + decode_projection_checkpoint(&checkpoint.bytes)?, + checkpoint.through_cursor, + ) + } + None => (HashMap::new(), durable_cursor.beginning()), + }; + let mut actor = Self { + disk, + data_owner, + durable_cursor: durable_cursor.clone(), + pending_rollover: None, + actor_lineage, + next_producer_epoch: 1, + ingress_epochs: HashMap::new(), + sessions, + subscribers: HashMap::new(), + next_subscriber_id: 1, + poison: None, + sealed: None, + seal_result: None, + }; + if replay_cursor != durable_cursor { + let entries = actor + .replay_until(replay_cursor.clone(), &durable_cursor) + .await?; + let mut applied_cursor = replay_cursor; + for delivery in entries { + if delivery.cursor.journal_id() != applied_cursor.journal_id() + || delivery.cursor.sequence() + != applied_cursor.sequence().checked_add(1).ok_or( + AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost, + ), + )? + { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost, + )); + } + validate_event_route( + &delivery.session_id, + delivery.run_id.as_deref(), + &delivery.event, + )?; + if let MapleLiveEvent::HistoryHeadCommitted { + through_event_cursor, + .. + } = &delivery.event + { + if through_event_cursor != &applied_cursor { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost, + )); + } + } + let mutation = actor.prepare_mutation(&delivery.session_id, &delivery.event)?; + actor.apply_mutation(&delivery.session_id, mutation); + applied_cursor = delivery.cursor; + } + if applied_cursor != durable_cursor { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost, + )); + } + } + let verified = actor + .disk + .checkpoint() + .await + .map_err(map_journal_for_attach)?; + if verified != durable_cursor { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost, + )); + } + Ok(actor) + } + + async fn run(mut self, mut commands: mpsc::Receiver) { + while let Some(command) = commands.recv().await { + match command { + CoordinatorCommand::BeginIngress { + session_id, + run_id, + reply, + } => { + let result = self.begin_ingress(session_id, run_id).await; + let _ = reply.send(result); + } + CoordinatorCommand::Publish { + ingress, + event, + reply, + } => { + let result = self.publish(ingress, event).await; + let _ = reply.send(result); + } + CoordinatorCommand::BeginHeadAttach { + capacity, + sender, + terminal_reason, + cancellation_commands, + reply, + } => { + let result = self + .begin_head_attach(capacity, sender, terminal_reason, cancellation_commands) + .await; + let _ = reply.send(result); + } + CoordinatorCommand::FinalizeHeadAttach { + subscriber_id, + reply, + } => { + let result = self.finalize_head_attach(subscriber_id).await; + let _ = reply.send(result); + } + CoordinatorCommand::BeginResume { + cursor, + capacity, + sender, + terminal_reason, + cancellation_commands, + reply, + } => { + let result = self + .begin_resume( + cursor, + capacity, + sender, + terminal_reason, + cancellation_commands, + ) + .await; + let _ = reply.send(result); + } + CoordinatorCommand::Unsubscribe { + subscriber_id, + reply, + } => { + self.subscribers.remove(&subscriber_id); + if let Some(reply) = reply { + let _ = reply.send(Ok(())); + } + } + CoordinatorCommand::Seal { reason, reply } => { + let result = self.seal(reason).await; + let _ = reply.send(result); + } + } + } + } + + async fn begin_ingress( + &mut self, + session_id: String, + run_id: Option, + ) -> Result { + self.ensure_ready().await?; + let route = IngressRoute { + session_id: session_id.clone(), + run_id: run_id.clone(), + }; + if !self.ingress_epochs.contains_key(&route) + && self.ingress_epochs.len() >= MAX_INGRESS_ROUTES_PER_ACCOUNT + { + return Err(AgentLiveCoordinatorError::IngressRouteCapacityExceeded); + } + let producer_epoch = self.next_producer_epoch; + self.next_producer_epoch = self + .next_producer_epoch + .checked_add(1) + .ok_or(AgentLiveCoordinatorError::IngressEpochExhausted)?; + let journal_ingress = self + .disk + .bind_ingress() + .await + .map_err(|error| self.map_mutation_journal_error(error))?; + let namespace = journal_ingress.event_namespace_commitment(); + self.ingress_epochs.insert(route, producer_epoch); + Ok(AgentLiveIngressLease { + journal_ingress, + data_owner: self.data_owner.clone(), + namespace, + actor_lineage: self.actor_lineage, + producer_epoch, + session_id: Arc::from(session_id), + run_id: run_id.map(Arc::from), + }) + } + + async fn publish( + &mut self, + ingress: AgentLiveIngressLease, + event: AgentLivePublishEvent, + ) -> Result { + // A command arriving after an ambiguous rollover acknowledgement may + // drive only the exact prepared obligation to completion. No classify, + // append, projection mutation, or subscriber registration can cross + // the generation fence while that obligation remains pending. + self.ensure_ready().await?; + self.validate_ingress_event(&ingress, &event)?; + let session_id = ingress.session_id.to_string(); + let run_id = ingress.run_id.as_deref().map(str::to_string); + let event = event.event; + match self + .classify_durable( + ingress.journal_ingress.clone(), + &session_id, + run_id.as_deref(), + event.clone(), + ) + .await? + { + EventAdmission::New => {} + EventAdmission::Duplicate { + event_cursor, + head_cursor, + } => { + return self.recover_or_return_duplicate( + session_id, + run_id, + event, + event_cursor, + head_cursor, + ); + } + } + if let MapleLiveEvent::HistoryHeadCommitted { + through_event_cursor, + .. + } = &event + { + if through_event_cursor != &self.durable_cursor { + return Err(AgentLiveCoordinatorError::StaleHistoryCommit); + } + } + let mutation = self.prepare_mutation(&session_id, &event)?; + let expected_head = self.durable_cursor.clone(); + let mut outcome = self + .append_durable( + ingress.journal_ingress.clone(), + expected_head.clone(), + &session_id, + run_id.as_deref(), + event.clone(), + ) + .await; + if matches!( + &outcome, + Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::CheckpointRequired + )) + ) { + self.store_current_projection_checkpoint().await?; + outcome = self + .append_durable( + ingress.journal_ingress.clone(), + expected_head, + &session_id, + run_id.as_deref(), + event.clone(), + ) + .await; + } + if matches!( + &outcome, + Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::IdempotencyCapacityExceeded + )) + ) { + self.rollover_current_projection().await?; + // The exact producer capability was revoked at the FIFO rollover + // barrier. Never silently remint or replay this rejected event. + return Err(AgentLiveCoordinatorError::IngressRebindRequired); + } + if matches!( + &outcome, + Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::StorageUnavailable + )) + ) { + match self + .disk + .classify_event( + ingress.journal_ingress, + self.durable_cursor.clone(), + session_id.clone(), + run_id.clone(), + event.clone(), + ) + .await + { + Ok(EventAdmission::Duplicate { + event_cursor, + head_cursor, + }) => { + return self.recover_or_return_duplicate( + session_id, + run_id, + event, + event_cursor, + head_cursor, + ); + } + Ok(EventAdmission::New) => { + return Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::StorageUnavailable, + )); + } + Err(LiveEventJournalError::StorageUnavailable) => { + return Err(self.poison(HeadReloadReason::JournalUnavailable)); + } + Err(error) => return Err(self.map_mutation_journal_error(error)), + } + } + let cursor = match outcome? { + AppendOutcome::Inserted(cursor) => cursor, + AppendOutcome::Duplicate { + event_cursor, + head_cursor, + } => { + return self.recover_or_return_duplicate( + session_id, + run_id, + event, + event_cursor, + head_cursor, + ); + } + }; + + if cursor.journal_id() != self.durable_cursor.journal_id() { + return Err(self.poison(HeadReloadReason::JournalReplaced)); + } + let expected = self + .durable_cursor + .sequence() + .checked_add(1) + .ok_or_else(|| self.poison(HeadReloadReason::OrderingLost))?; + if cursor.sequence() < expected { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + if cursor.sequence() != expected { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + + self.apply_mutation(&session_id, mutation); + self.durable_cursor = cursor.clone(); + self.fan_out(AgentLiveDelivery { + cursor: cursor.clone(), + session_id, + run_id, + event, + }); + Ok(cursor) + } + + fn validate_ingress_event( + &mut self, + ingress: &AgentLiveIngressLease, + event: &AgentLivePublishEvent, + ) -> Result<(), AgentLiveCoordinatorError> { + let route = IngressRoute { + session_id: ingress.session_id.to_string(), + run_id: ingress.run_id.as_deref().map(str::to_string), + }; + if ingress.actor_lineage != self.actor_lineage + || ingress.namespace != ingress.journal_ingress.event_namespace_commitment() + || self.ingress_epochs.get(&route) != Some(&ingress.producer_epoch) + || event.id.namespace != ingress.namespace + || event.id.actor_lineage != ingress.actor_lineage + || event.id.producer_epoch != ingress.producer_epoch + || event.id.session_id.as_ref() != ingress.session_id.as_ref() + || event.id.run_id.as_deref() != ingress.run_id.as_deref() + || event.id.wire != event.event.event_id() + { + return Err(AgentLiveCoordinatorError::IngressRebindRequired); + } + let commitment = + live_event_payload_commitment(ingress.session_id(), ingress.run_id(), &event.event)?; + if commitment != event.id.payload_commitment { + self.poison(HeadReloadReason::OrderingLost); + return Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::EventIdConflict, + )); + } + Ok(()) + } + + fn recover_or_return_duplicate( + &mut self, + session_id: String, + run_id: Option, + event: MapleLiveEvent, + event_cursor: LiveEventCursor, + head_cursor: LiveEventCursor, + ) -> Result { + if event_cursor.journal_id() != self.durable_cursor.journal_id() + || head_cursor.journal_id() != self.durable_cursor.journal_id() + { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + if head_cursor == self.durable_cursor { + if event_cursor.sequence() > head_cursor.sequence() { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + // A durable tombstone may outlive the retained payload and many + // later events. That delayed retry is already reflected in this + // actor's absolute projection, so it returns its original cursor + // without mutation or fanout. + return Ok(event_cursor); + } + let expected = self + .durable_cursor + .sequence() + .checked_add(1) + .ok_or_else(|| self.poison(HeadReloadReason::OrderingLost))?; + if head_cursor.sequence() != expected || event_cursor != head_cursor { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + if let MapleLiveEvent::HistoryHeadCommitted { + through_event_cursor, + .. + } = &event + { + if through_event_cursor != &self.durable_cursor { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + } + let mutation = self.prepare_mutation(&session_id, &event)?; + self.apply_mutation(&session_id, mutation); + self.durable_cursor = head_cursor.clone(); + self.fan_out(AgentLiveDelivery { + cursor: head_cursor.clone(), + session_id, + run_id, + event, + }); + Ok(head_cursor) + } + + async fn classify_durable( + &mut self, + ingress: LiveEventJournalIngressLease, + session_id: &str, + run_id: Option<&str>, + event: MapleLiveEvent, + ) -> Result { + match self + .disk + .classify_event( + ingress, + self.durable_cursor.clone(), + session_id.to_string(), + run_id.map(str::to_string), + event, + ) + .await + { + Ok(admission) => Ok(admission), + Err(error) => Err(self.map_mutation_journal_error(error)), + } + } + + async fn append_durable( + &mut self, + ingress: LiveEventJournalIngressLease, + expected_head: LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: MapleLiveEvent, + ) -> Result { + match self + .disk + .append_outcome( + ingress, + expected_head, + session_id.to_string(), + run_id.map(str::to_string), + event, + ) + .await + { + Ok(outcome) => Ok(outcome), + Err(error) => Err(self.map_mutation_journal_error(error)), + } + } + + async fn store_current_projection_checkpoint( + &mut self, + ) -> Result<(), AgentLiveCoordinatorError> { + let bytes = self.encode_projection_checkpoint()?; + let stored = match self + .disk + .store_projection_checkpoint(self.durable_cursor.clone(), bytes) + .await + { + Ok(cursor) => cursor, + Err(error) => return Err(self.map_mutation_journal_error(error)), + }; + if stored != self.durable_cursor { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + Ok(()) + } + + async fn rollover_current_projection(&mut self) -> Result<(), AgentLiveCoordinatorError> { + if self.pending_rollover.is_some() { + return self.finish_pending_rollover().await; + } + let bytes = self.encode_projection_checkpoint()?; + let previous = self.durable_cursor.clone(); + + match self + .disk + .store_projection_checkpoint(previous.clone(), bytes.clone()) + .await + { + Ok(stored) if stored == previous => {} + Ok(_) => return Err(self.poison(HeadReloadReason::OrderingLost)), + Err(LiveEventJournalError::StorageUnavailable) => { + let current = self + .disk + .checkpoint() + .await + .map_err(|error| self.map_mutation_journal_error(error))?; + let checkpoint = self + .disk + .load_projection_checkpoint() + .await + .map_err(|error| self.map_mutation_journal_error(error))?; + if current != previous + || !projection_checkpoint_matches(checkpoint.as_ref(), &previous, &bytes) + { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + } + Err(error) => return Err(self.map_mutation_journal_error(error)), + } + + // This FIFO point is the generation barrier. Both active and paused + // subscribers are bound to the old journal ID, so close them before + // preparing the move-only capability; none may observe a sequence + // from both generations. + self.ingress_epochs.clear(); + self.invalidate_subscribers(HeadReloadReason::JournalReplaced); + let obligation = match self + .disk + .prepare_rollover(previous.clone(), bytes.clone()) + .await + { + Ok(obligation) => obligation, + Err(error) => return Err(self.map_mutation_journal_error(error)), + }; + self.pending_rollover = Some(PendingRollover { + obligation: Arc::new(obligation), + previous_cursor: previous, + checkpoint_bytes: bytes, + }); + self.finish_pending_rollover().await + } + + async fn finish_pending_rollover(&mut self) -> Result<(), AgentLiveCoordinatorError> { + let Some(pending) = self.pending_rollover.as_ref() else { + return Ok(()); + }; + let previous = pending.previous_cursor.clone(); + let obligation = Arc::clone(&pending.obligation); + let checkpoint_bytes = pending.checkpoint_bytes.clone(); + let activation = match self + .disk + .commit_rollover(obligation, checkpoint_bytes) + .await + { + Ok(activation) => activation, + Err(LiveEventJournalError::StorageUnavailable) => { + // The atomic replace may already be durable. Retain the exact + // non-clone obligation and checkpoint bytes; the next FIFO + // command retries this commit before doing anything else. + return Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::StorageUnavailable, + )); + } + Err(error) => return Err(self.map_mutation_journal_error(error)), + }; + let (_fresh_lease, replacement) = activation.into_parts(); + if replacement.journal_id() == previous.journal_id() || replacement.sequence() != 0 { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + self.durable_cursor = replacement; + self.pending_rollover = None; + Ok(()) + } + + fn map_mutation_journal_error( + &mut self, + error: LiveEventJournalError, + ) -> AgentLiveCoordinatorError { + match error { + LiveEventJournalError::OwnerGenerationMismatch + | LiveEventJournalError::OwnerTransitionIncomplete => { + self.poison(HeadReloadReason::OwnerChanged) + } + LiveEventJournalError::JournalReplaced | LiveEventJournalError::JournalRetired => { + self.poison(HeadReloadReason::JournalReplaced) + } + LiveEventJournalError::ReseedRequired => self.poison(HeadReloadReason::ReseedRequired), + LiveEventJournalError::HeadChanged => self.poison(HeadReloadReason::OrderingLost), + LiveEventJournalError::StorageCorrupt => { + self.poison(HeadReloadReason::JournalUnavailable) + } + other => AgentLiveCoordinatorError::Journal(other), + } + } + + async fn begin_head_attach( + &mut self, + capacity: usize, + sender: broadcast::Sender, + terminal_reason: Arc>>, + cancellation_commands: mpsc::Sender, + ) -> Result { + self.verify_checkpoint().await?; + self.ensure_subscriber_capacity(capacity)?; + let subscriber_id = self.allocate_subscriber_id()?; + let sessions = self.absolute_live_sessions(); + self.subscribers.insert( + subscriber_id, + SubscriberState { + sender, + terminal_reason, + reserved_buffer_bytes: reserved_subscriber_bytes(capacity)?, + mode: SubscriberMode::Paused { + from: self.durable_cursor.clone(), + capacity, + observed_events: 0, + overflowed: false, + }, + }, + ); + Ok(BeginHeadAttachResult { + subscriber: PendingSubscriberOwnership::new(subscriber_id, cancellation_commands), + through_cursor: self.durable_cursor.clone(), + live_sessions: sessions, + }) + } + + async fn finalize_head_attach( + &mut self, + subscriber_id: u64, + ) -> Result { + self.ensure_ready().await?; + let (from, capacity, observed_events, overflowed) = { + let subscriber = self + .subscribers + .get(&subscriber_id) + .ok_or(AgentLiveCoordinatorError::CoordinatorClosed)?; + let SubscriberMode::Paused { + from, + capacity, + observed_events, + overflowed, + } = &subscriber.mode + else { + return Err(AgentLiveCoordinatorError::CoordinatorClosed); + }; + (from.clone(), *capacity, *observed_events, *overflowed) + }; + if overflowed { + if let Some(subscriber) = self.subscribers.remove(&subscriber_id) { + set_terminal_reason( + &subscriber.terminal_reason, + HeadReloadReason::PausedSubscriberOverflow, + ); + } + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::PausedSubscriberOverflow, + )); + } + + let through = match self.verified_checkpoint().await { + Ok(cursor) => cursor, + Err(error) => { + if let Some(subscriber) = self.subscribers.remove(&subscriber_id) { + if let AgentLiveCoordinatorError::HeadReloadRequired(reason) = &error { + set_terminal_reason(&subscriber.terminal_reason, *reason); + } + } + return Err(error); + } + }; + let deliveries = match self.replay_until(from, &through).await { + Ok(deliveries) => deliveries, + Err(error) => { + if let Some(subscriber) = self.subscribers.remove(&subscriber_id) { + if let AgentLiveCoordinatorError::HeadReloadRequired(reason) = &error { + set_terminal_reason(&subscriber.terminal_reason, *reason); + } + } + return Err(error); + } + }; + if deliveries.len() != observed_events || deliveries.len() > capacity { + if let Some(subscriber) = self.subscribers.remove(&subscriber_id) { + set_terminal_reason(&subscriber.terminal_reason, HeadReloadReason::OrderingLost); + } + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost, + )); + } + + let subscriber = self + .subscribers + .get_mut(&subscriber_id) + .ok_or(AgentLiveCoordinatorError::CoordinatorClosed)?; + for delivery in deliveries { + if subscriber.sender.send(delivery).is_err() { + if let Some(subscriber) = self.subscribers.remove(&subscriber_id) { + set_terminal_reason( + &subscriber.terminal_reason, + HeadReloadReason::OrderingLost, + ); + } + return Err(AgentLiveCoordinatorError::CoordinatorClosed); + } + } + subscriber.mode = SubscriberMode::Active; + Ok(through) + } + + async fn begin_resume( + &mut self, + cursor: LiveEventCursor, + capacity: usize, + sender: broadcast::Sender, + terminal_reason: Arc>>, + cancellation_commands: mpsc::Sender, + ) -> Result { + let through = self.verified_checkpoint().await?; + self.ensure_subscriber_capacity(capacity)?; + let deliveries = self.replay_until(cursor, &through).await?; + if deliveries.len() > capacity { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::PausedSubscriberOverflow, + )); + } + for delivery in deliveries { + sender.send(delivery).map_err(|_| { + AgentLiveCoordinatorError::HeadReloadRequired(HeadReloadReason::OrderingLost) + })?; + } + let subscriber_id = self.allocate_subscriber_id()?; + self.subscribers.insert( + subscriber_id, + SubscriberState { + sender, + terminal_reason, + reserved_buffer_bytes: reserved_subscriber_bytes(capacity)?, + mode: SubscriberMode::Active, + }, + ); + Ok(BeginResumeResult { + subscriber: PendingSubscriberOwnership::new(subscriber_id, cancellation_commands), + through_cursor: through, + }) + } + + async fn replay_until( + &mut self, + mut cursor: LiveEventCursor, + through: &LiveEventCursor, + ) -> Result, AgentLiveCoordinatorError> { + if cursor.journal_id() != through.journal_id() { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::JournalReplaced, + )); + } + if cursor.sequence() > through.sequence() { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::CursorAhead, + )); + } + + let mut deliveries = Vec::new(); + while cursor.sequence() < through.sequence() { + let replay = match self.disk.replay_after(cursor.clone()).await { + Ok(replay) => replay, + Err(error) => return Err(self.map_mutation_journal_error(error)), + }; + match replay { + LiveReplayRead::SnapshotRequired(required) => { + let reason = map_snapshot_reason(required.reason); + if matches!( + reason, + HeadReloadReason::JournalReplaced | HeadReloadReason::ReseedRequired + ) { + return Err(self.poison(reason)); + } + return Err(AgentLiveCoordinatorError::HeadReloadRequired(reason)); + } + LiveReplayRead::Events { + entries, + next_cursor, + has_more, + } => { + let previous_sequence = cursor.sequence(); + for entry in entries { + if entry.cursor().sequence() > through.sequence() { + break; + } + deliveries.push(delivery_from_entry(entry)); + } + if next_cursor.journal_id() != through.journal_id() + || next_cursor.sequence() <= previous_sequence + || next_cursor.sequence() > through.sequence() + { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost, + )); + } + cursor = next_cursor; + if !has_more && cursor.sequence() < through.sequence() { + return Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost, + )); + } + } + } + } + Ok(deliveries) + } + + async fn verify_checkpoint(&mut self) -> Result<(), AgentLiveCoordinatorError> { + self.verified_checkpoint().await.map(|_| ()) + } + + async fn verified_checkpoint(&mut self) -> Result { + self.ensure_ready().await?; + let current = match self.disk.checkpoint().await { + Ok(cursor) => cursor, + Err( + LiveEventJournalError::OwnerGenerationMismatch + | LiveEventJournalError::OwnerTransitionIncomplete, + ) => return Err(self.poison(HeadReloadReason::OwnerChanged)), + Err(LiveEventJournalError::JournalReplaced | LiveEventJournalError::JournalRetired) => { + return Err(self.poison(HeadReloadReason::JournalReplaced)); + } + Err(LiveEventJournalError::ReseedRequired) => { + return Err(self.poison(HeadReloadReason::ReseedRequired)); + } + Err(LiveEventJournalError::StorageCorrupt) => { + return Err(self.poison(HeadReloadReason::JournalUnavailable)); + } + Err(error) => return Err(map_journal_for_attach(error)), + }; + if current.journal_id() != self.durable_cursor.journal_id() { + return Err(self.poison(HeadReloadReason::JournalReplaced)); + } + if current.sequence() != self.durable_cursor.sequence() { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + Ok(current) + } + + fn prepare_mutation( + &self, + session_id: &str, + event: &MapleLiveEvent, + ) -> Result { + let current = self.sessions.get(session_id).cloned().unwrap_or_default(); + let mutation = current + .prepare(event) + .map_err(AgentLiveCoordinatorError::Projection)?; + self.validate_projection_bounds(session_id, &mutation)?; + Ok(mutation) + } + + fn apply_mutation(&mut self, session_id: &str, mutation: ProjectionMutation) { + match mutation { + ProjectionMutation::Noop => {} + ProjectionMutation::Remove => { + self.sessions.remove(session_id); + } + ProjectionMutation::Set(projection) => { + self.sessions.insert(session_id.to_string(), projection); + } + } + } + + fn absolute_live_sessions(&self) -> Vec { + let mut live_sessions = self + .sessions + .iter() + .map(|(session_id, projection)| AgentLiveSessionProjection { + session_id: session_id.clone(), + live_items: projection.absolute_items(), + }) + .collect::>(); + live_sessions.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + live_sessions + } + + fn encode_projection_checkpoint(&self) -> Result, AgentLiveCoordinatorError> { + let bytes = serde_json::to_vec(&CoordinatorProjectionCheckpoint { + format_version: LIVE_PROJECTION_CHECKPOINT_VERSION, + live_sessions: self.absolute_live_sessions(), + }) + .map_err(|_| { + AgentLiveCoordinatorError::Journal(LiveEventJournalError::InvalidCheckpoint) + })?; + if bytes.len() > MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT { + return Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::AccountProjectionCapacityExceeded, + )); + } + Ok(bytes) + } + + fn fan_out(&mut self, delivery: AgentLiveDelivery) { + self.subscribers.retain(|_, subscriber| { + if subscriber.sender.receiver_count() == 0 { + return false; + } + match &mut subscriber.mode { + SubscriberMode::Paused { + capacity, + observed_events, + overflowed, + .. + } => { + *observed_events = observed_events.saturating_add(1); + if *observed_events > *capacity { + *overflowed = true; + } + true + } + SubscriberMode::Active => subscriber.sender.send(delivery.clone()).is_ok(), + } + }); + } + + fn validate_projection_bounds( + &self, + session_id: &str, + mutation: &ProjectionMutation, + ) -> Result<(), AgentLiveCoordinatorError> { + let replacement = match mutation { + ProjectionMutation::Set(projection) => Some(projection), + ProjectionMutation::Noop => return Ok(()), + ProjectionMutation::Remove => None, + }; + let replacing_existing = self.sessions.contains_key(session_id); + let projected_session_count = self + .sessions + .len() + .saturating_sub(usize::from(replacing_existing)) + .saturating_add(usize::from(replacement.is_some())); + if projected_session_count > MAX_LIVE_SESSIONS_PER_ACCOUNT { + return Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::AccountProjectionCapacityExceeded, + )); + } + + let mut item_count = 0usize; + let mut projected_bytes = LIVE_CHECKPOINT_OUTER_OVERHEAD_BYTES; + for (existing_session_id, projection) in &self.sessions { + if existing_session_id == session_id { + continue; + } + accumulate_projection_bounds( + existing_session_id, + projection, + &mut item_count, + &mut projected_bytes, + )?; + } + if let Some(projection) = replacement { + accumulate_projection_bounds( + session_id, + projection, + &mut item_count, + &mut projected_bytes, + )?; + } + if item_count > MAX_LIVE_ITEMS_PER_ACCOUNT + || projected_bytes > MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT + { + return Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::AccountProjectionCapacityExceeded, + )); + } + Ok(()) + } + + fn ensure_subscriber_capacity( + &mut self, + requested_capacity: usize, + ) -> Result<(), AgentLiveCoordinatorError> { + self.subscribers + .retain(|_, subscriber| subscriber.sender.receiver_count() != 0); + if self.subscribers.len() >= MAX_SUBSCRIBERS_PER_ACCOUNT { + return Err(AgentLiveCoordinatorError::SubscriberCapacityExceeded); + } + let new_buffer_bytes = reserved_subscriber_bytes(requested_capacity)?; + let existing_buffer_bytes = self + .subscribers + .values() + .try_fold(0usize, |total, subscriber| { + total.checked_add(subscriber.reserved_buffer_bytes) + }) + .ok_or(AgentLiveCoordinatorError::SubscriberCapacityExceeded)?; + if existing_buffer_bytes + .checked_add(new_buffer_bytes) + .is_none_or(|total| total > MAX_ACCOUNT_SUBSCRIPTION_BUFFER_BYTES) + { + return Err(AgentLiveCoordinatorError::SubscriberCapacityExceeded); + } + Ok(()) + } + + fn allocate_subscriber_id(&mut self) -> Result { + let id = self.next_subscriber_id; + self.next_subscriber_id = self + .next_subscriber_id + .checked_add(1) + .ok_or_else(|| self.poison(HeadReloadReason::OrderingLost))?; + Ok(id) + } + + fn ensure_healthy(&self) -> Result<(), AgentLiveCoordinatorError> { + if let Some(reason) = self.sealed { + return Err(AgentLiveCoordinatorError::Sealed(reason)); + } + if let Some(reason) = self.poison { + return Err(AgentLiveCoordinatorError::HeadReloadRequired(reason)); + } + Ok(()) + } + + async fn ensure_ready(&mut self) -> Result<(), AgentLiveCoordinatorError> { + self.ensure_healthy()?; + self.finish_pending_rollover().await?; + self.ensure_healthy() + } + + async fn seal( + &mut self, + reason: AgentLiveSealReason, + ) -> Result { + if let Some(existing) = self.sealed { + if existing != reason { + return Err(AgentLiveCoordinatorError::Sealed(existing)); + } + if let Some(sealed) = self.seal_result.clone() { + return Ok(sealed); + } + // A prior transient verification failure left this actor terminal + // but without retirement authority. Rechecking the same immutable + // actor head is safe and lets the host resume its fail-closed + // Sealing state; publication is never reopened. + } else { + // The FIFO lifecycle fence is terminal even if disk verification + // fails. Never allow a failed attempt to reopen publication while + // the host decides whether it can retire or must reseed this + // journal. + self.sealed = Some(reason); + self.invalidate_subscribers(HeadReloadReason::OwnerChanged); + } + if let Some(poison) = self.poison { + return Err(AgentLiveCoordinatorError::HeadReloadRequired(poison)); + } + // Sealing is allowed to resume, but never bypass, an ambiguous + // rollover. The same-reason seal retry will continue holding the + // terminal actor fence while the exact obligation is retried. + self.finish_pending_rollover().await?; + let through_cursor = match self.disk.checkpoint().await { + Ok(cursor) => cursor, + Err(error) => return Err(self.map_mutation_journal_error(error)), + }; + if through_cursor.journal_id() != self.durable_cursor.journal_id() { + return Err(self.poison(HeadReloadReason::JournalReplaced)); + } + if through_cursor.sequence() != self.durable_cursor.sequence() { + return Err(self.poison(HeadReloadReason::OrderingLost)); + } + let sealed = AgentLiveSeal { + journal_lease: self + .disk + .lease() + .map_err(|error| self.map_mutation_journal_error(error))?, + through_cursor, + reason, + }; + self.seal_result = Some(sealed.clone()); + Ok(sealed) + } + + fn poison(&mut self, reason: HeadReloadReason) -> AgentLiveCoordinatorError { + self.poison = Some(reason); + self.invalidate_subscribers(reason); + AgentLiveCoordinatorError::HeadReloadRequired(reason) + } + + fn invalidate_subscribers(&mut self, reason: HeadReloadReason) { + for subscriber in self.subscribers.values() { + if let Ok(mut terminal) = subscriber.terminal_reason.lock() { + *terminal = Some(reason); + } + } + self.subscribers.clear(); + } +} + +#[derive(Debug, Clone, Default)] +struct SessionLiveProjection { + items: Vec, + checkpoint_wire_bytes: usize, +} + +impl SessionLiveProjection { + fn prepare( + mut self, + event: &MapleLiveEvent, + ) -> Result { + match event { + MapleLiveEvent::TimelineUpsert { item, .. } => { + self.upsert(item.clone())?; + Ok(ProjectionMutation::Set(self)) + } + MapleLiveEvent::UserFacingError { error, .. } => { + self.upsert(error.to_timeline_item())?; + Ok(ProjectionMutation::Set(self)) + } + MapleLiveEvent::TimelineCleared { .. } => Ok(ProjectionMutation::Remove), + MapleLiveEvent::HistoryHeadCommitted { .. } => Ok(ProjectionMutation::Remove), + MapleLiveEvent::SessionDeleted { .. } => Ok(ProjectionMutation::Remove), + MapleLiveEvent::RunStarted { .. } + | MapleLiveEvent::HistoryReplaced { .. } + | MapleLiveEvent::SessionUpdated { .. } + | MapleLiveEvent::RunFinished { .. } => Ok(ProjectionMutation::Noop), + } + } + + fn upsert(&mut self, incoming: MapleLiveTimelineItem) -> Result<(), AgentLiveProjectionError> { + incoming.validate()?; + if let Some(existing) = self.items.iter_mut().find(|item| item.id == incoming.id) { + let previous_bytes = live_item_checkpoint_wire_bytes(existing)?; + let merged = merge_live_item(existing, incoming)?; + let merged_bytes = live_item_checkpoint_wire_bytes(&merged)?; + self.checkpoint_wire_bytes = self + .checkpoint_wire_bytes + .checked_sub(previous_bytes) + .and_then(|bytes| bytes.checked_add(merged_bytes)) + .ok_or(AgentLiveProjectionError::AccountProjectionCapacityExceeded)?; + *existing = merged; + return Ok(()); + } + if self.items.len() >= MAX_LIVE_ITEMS_PER_SESSION { + return Err(AgentLiveProjectionError::TooManyTimelineItems); + } + let incoming = incoming.as_absolute(); + self.checkpoint_wire_bytes = self + .checkpoint_wire_bytes + .checked_add(live_item_checkpoint_wire_bytes(&incoming)?) + .ok_or(AgentLiveProjectionError::AccountProjectionCapacityExceeded)?; + self.items.push(incoming); + Ok(()) + } + + fn absolute_items(&self) -> Vec { + self.items + .iter() + .cloned() + .map(MapleLiveTimelineItem::as_absolute) + .collect() + } +} + +enum ProjectionMutation { + Noop, + Remove, + Set(SessionLiveProjection), +} + +fn accumulate_projection_bounds( + session_id: &str, + projection: &SessionLiveProjection, + item_count: &mut usize, + projected_bytes: &mut usize, +) -> Result<(), AgentLiveCoordinatorError> { + *item_count = item_count.checked_add(projection.items.len()).ok_or( + AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::AccountProjectionCapacityExceeded, + ), + )?; + let session_bytes = LIVE_CHECKPOINT_SESSION_OVERHEAD_BYTES + .checked_add( + json_string_checkpoint_wire_bytes(session_id) + .map_err(AgentLiveCoordinatorError::Projection)?, + ) + .and_then(|bytes| bytes.checked_add(projection.checkpoint_wire_bytes)) + .ok_or(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::AccountProjectionCapacityExceeded, + ))?; + *projected_bytes = + projected_bytes + .checked_add(session_bytes) + .ok_or(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::AccountProjectionCapacityExceeded, + ))?; + Ok(()) +} + +fn live_item_checkpoint_wire_bytes( + item: &MapleLiveTimelineItem, +) -> Result { + [ + Some(item.id.as_str()), + item.title.as_deref(), + item.text.as_deref(), + item.status.as_deref(), + ] + .into_iter() + .flatten() + .try_fold(LIVE_CHECKPOINT_ITEM_OVERHEAD_BYTES, |bytes, value| { + bytes + .checked_add(json_string_checkpoint_wire_bytes(value)?) + .ok_or(AgentLiveProjectionError::AccountProjectionCapacityExceeded) + }) +} + +/// Upper-bound a JSON string without allocating. ASCII controls may use the +/// six-byte `\u00XX` form; quotes and backslashes use two bytes; every other +/// scalar is emitted as its UTF-8 bytes. The two quote bytes are included. +fn json_string_checkpoint_wire_bytes(value: &str) -> Result { + value + .chars() + .try_fold(2usize, |bytes, character| { + let encoded = if character.is_ascii_control() { + 6 + } else if matches!(character, '"' | '\\') { + 2 + } else { + character.len_utf8() + }; + bytes.checked_add(encoded) + }) + .ok_or(AgentLiveProjectionError::AccountProjectionCapacityExceeded) +} + +fn merge_live_item( + existing: &MapleLiveTimelineItem, + incoming: MapleLiveTimelineItem, +) -> Result { + if incoming.merge == MapleLiveMerge::Replace { + return Ok(incoming.as_absolute()); + } + if existing.item_type != incoming.item_type || existing.role != incoming.role { + return Err(AgentLiveProjectionError::ConflictingItemIdentity); + } + let mut merged = existing.clone(); + if let Some(text) = incoming.text { + let target = merged.text.get_or_insert_with(String::new); + if target + .len() + .checked_add(text.len()) + .is_none_or(|length| length > MAX_TEXT_BYTES) + { + return Err(AgentLiveProjectionError::MergedItemTooLarge); + } + target.push_str(&text); + } + if incoming.title.is_some() { + merged.title = incoming.title; + } + if incoming.status.is_some() { + merged.status = incoming.status; + } + merged.created_ms = incoming.created_ms; + merged.merge = MapleLiveMerge::Replace; + merged.validate()?; + Ok(merged) +} + +struct SubscriberState { + sender: broadcast::Sender, + terminal_reason: Arc>>, + reserved_buffer_bytes: usize, + mode: SubscriberMode, +} + +enum SubscriberMode { + Paused { + from: LiveEventCursor, + capacity: usize, + observed_events: usize, + overflowed: bool, + }, + Active, +} + +trait CoordinatorJournal: Send + Sync + 'static { + fn max_replay_entries(&self) -> usize; + + fn checkpoint( + &self, + lease: &LiveEventJournalLease, + ) -> Result; + fn load_projection_checkpoint( + &self, + lease: &LiveEventJournalLease, + ) -> Result, LiveEventJournalError>; + fn store_projection_checkpoint( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result; + fn bind_ingress( + &self, + lease: &LiveEventJournalLease, + ) -> Result; + fn prepare_rollover( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result; + fn commit_rollover( + &self, + obligation: &LiveEventJournalRolloverObligation, + bytes: &[u8], + ) -> Result; + fn classify_event( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: &MapleLiveEvent, + ) -> Result; + fn append_outcome( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: MapleLiveEvent, + ) -> Result; + fn replay_after( + &self, + lease: &LiveEventJournalLease, + cursor: &LiveEventCursor, + limit: usize, + ) -> Result, LiveEventJournalError>; +} + +impl CoordinatorJournal for LiveEventJournal { + fn max_replay_entries(&self) -> usize { + self.max_replay_entries() + } + + fn checkpoint( + &self, + lease: &LiveEventJournalLease, + ) -> Result { + self.checkpoint(lease) + } + + fn load_projection_checkpoint( + &self, + lease: &LiveEventJournalLease, + ) -> Result, LiveEventJournalError> { + self.load_checkpoint(lease) + } + + fn store_projection_checkpoint( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result { + self.store_checkpoint(lease, expected_head, bytes) + } + + fn bind_ingress( + &self, + lease: &LiveEventJournalLease, + ) -> Result { + self.bind_ingress(lease) + } + + fn prepare_rollover( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result { + self.prepare_rollover(lease, expected_head, bytes) + } + + fn commit_rollover( + &self, + obligation: &LiveEventJournalRolloverObligation, + bytes: &[u8], + ) -> Result { + self.commit_rollover(obligation, bytes) + } + + fn classify_event( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: &MapleLiveEvent, + ) -> Result { + self.classify_event(ingress, expected_head, session_id, run_id, event) + } + + fn append_outcome( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: MapleLiveEvent, + ) -> Result { + self.append_outcome(ingress, expected_head, session_id, run_id, event) + } + + fn replay_after( + &self, + lease: &LiveEventJournalLease, + cursor: &LiveEventCursor, + limit: usize, + ) -> Result, LiveEventJournalError> { + self.replay_after(lease, cursor, limit) + } +} + +struct BlockingJournalWorker { + commands: std_mpsc::SyncSender, + replay_page_size: usize, + lease: Arc>, +} + +impl BlockingJournalWorker { + fn spawn( + journal: Arc, + lease: LiveEventJournalLease, + ) -> Result { + let replay_page_size = journal.max_replay_entries().min(REPLAY_PAGE_SIZE); + if replay_page_size == 0 { + return Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::InvalidReplayLimit, + )); + } + let shared_lease = Arc::new(TerminalMutex::new(lease)); + let worker_lease = Arc::clone(&shared_lease); + let (commands, receiver) = std_mpsc::sync_channel(1); + thread::Builder::new() + .name("maple-agent-live-journal".to_string()) + .spawn(move || { + while let Ok(command) = receiver.recv() { + match command { + DiskCommand::Checkpoint { reply } => { + let result = worker_lease + .lock() + .map_err(|_| LiveEventJournalError::StorageUnavailable) + .and_then(|lease| journal.checkpoint(&lease)); + let _ = reply.send(result); + } + DiskCommand::LoadProjectionCheckpoint { reply } => { + let result = worker_lease + .lock() + .map_err(|_| LiveEventJournalError::StorageUnavailable) + .and_then(|lease| journal.load_projection_checkpoint(&lease)); + let _ = reply.send(result); + } + DiskCommand::StoreProjectionCheckpoint { + expected_head, + bytes, + reply, + } => { + let result = worker_lease + .lock() + .map_err(|_| LiveEventJournalError::StorageUnavailable) + .and_then(|lease| { + journal.store_projection_checkpoint( + &lease, + &expected_head, + &bytes, + ) + }); + let _ = reply.send(result); + } + DiskCommand::BindIngress { reply } => { + let result = worker_lease + .lock() + .map_err(|_| LiveEventJournalError::StorageUnavailable) + .and_then(|lease| journal.bind_ingress(&lease)); + let _ = reply.send(result); + } + DiskCommand::PrepareRollover { + expected_head, + bytes, + reply, + } => { + let result = worker_lease + .lock() + .map_err(|_| LiveEventJournalError::StorageUnavailable) + .and_then(|lease| { + journal.prepare_rollover(&lease, &expected_head, &bytes) + }); + let _ = reply.send(result); + } + DiskCommand::CommitRollover { + obligation, + bytes, + reply, + } => { + let result = journal.commit_rollover(obligation.as_ref(), &bytes); + if let Ok(activation) = &result { + if let Ok(mut lease) = worker_lease.lock() { + *lease = activation.lease.clone(); + } else { + let _ = + reply.send(Err(LiveEventJournalError::StorageUnavailable)); + continue; + } + } + let _ = reply.send(result); + } + DiskCommand::Classify { + ingress, + expected_head, + session_id, + run_id, + event, + reply, + } => { + let _ = reply.send(journal.classify_event( + &ingress, + &expected_head, + &session_id, + run_id.as_deref(), + &event, + )); + } + DiskCommand::Append { + ingress, + expected_head, + session_id, + run_id, + event, + reply, + } => { + let _ = reply.send(journal.append_outcome( + &ingress, + &expected_head, + &session_id, + run_id.as_deref(), + event, + )); + } + DiskCommand::Replay { + cursor, + limit, + reply, + } => { + let result = worker_lease + .lock() + .map_err(|_| LiveEventJournalError::StorageUnavailable) + .and_then(|lease| journal.replay_after(&lease, &cursor, limit)); + let _ = reply.send(result); + } + } + } + }) + .map_err(|_| AgentLiveCoordinatorError::WorkerUnavailable)?; + Ok(Self { + commands, + replay_page_size, + lease: shared_lease, + }) + } + + fn lease(&self) -> Result { + self.lease + .lock() + .map(|lease| lease.clone()) + .map_err(|_| LiveEventJournalError::StorageUnavailable) + } + + async fn checkpoint(&self) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::Checkpoint { reply }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } + + async fn load_projection_checkpoint( + &self, + ) -> Result, LiveEventJournalError> { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::LoadProjectionCheckpoint { reply }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } + + async fn store_projection_checkpoint( + &self, + expected_head: LiveEventCursor, + bytes: Vec, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::StoreProjectionCheckpoint { + expected_head, + bytes, + reply, + }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } + + async fn bind_ingress(&self) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::BindIngress { reply }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } + + async fn prepare_rollover( + &self, + expected_head: LiveEventCursor, + bytes: Vec, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::PrepareRollover { + expected_head, + bytes, + reply, + }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } + + async fn commit_rollover( + &self, + obligation: Arc, + bytes: Vec, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::CommitRollover { + obligation, + bytes, + reply, + }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } + + async fn classify_event( + &self, + ingress: LiveEventJournalIngressLease, + expected_head: LiveEventCursor, + session_id: String, + run_id: Option, + event: MapleLiveEvent, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::Classify { + ingress, + expected_head, + session_id, + run_id, + event, + reply, + }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } + + async fn append_outcome( + &self, + ingress: LiveEventJournalIngressLease, + expected_head: LiveEventCursor, + session_id: String, + run_id: Option, + event: MapleLiveEvent, + ) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::Append { + ingress, + expected_head, + session_id, + run_id, + event, + reply, + }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } + + async fn replay_after( + &self, + cursor: LiveEventCursor, + ) -> Result, LiveEventJournalError> { + let (reply, response) = oneshot::channel(); + self.commands + .try_send(DiskCommand::Replay { + cursor, + limit: self.replay_page_size, + reply, + }) + .map_err(|_| LiveEventJournalError::StorageUnavailable)?; + response + .await + .map_err(|_| LiveEventJournalError::StorageUnavailable)? + } +} + +enum DiskCommand { + Checkpoint { + reply: oneshot::Sender>, + }, + LoadProjectionCheckpoint { + reply: oneshot::Sender, LiveEventJournalError>>, + }, + StoreProjectionCheckpoint { + expected_head: LiveEventCursor, + bytes: Vec, + reply: oneshot::Sender>, + }, + BindIngress { + reply: oneshot::Sender>, + }, + PrepareRollover { + expected_head: LiveEventCursor, + bytes: Vec, + reply: oneshot::Sender>, + }, + CommitRollover { + obligation: Arc, + bytes: Vec, + reply: oneshot::Sender>, + }, + Classify { + ingress: LiveEventJournalIngressLease, + expected_head: LiveEventCursor, + session_id: String, + run_id: Option, + event: MapleLiveEvent, + reply: oneshot::Sender>, + }, + Append { + ingress: LiveEventJournalIngressLease, + expected_head: LiveEventCursor, + session_id: String, + run_id: Option, + event: MapleLiveEvent, + reply: oneshot::Sender>, + }, + Replay { + cursor: LiveEventCursor, + limit: usize, + reply: oneshot::Sender, LiveEventJournalError>>, + }, +} + +fn delivery_from_entry(entry: LiveReplayEntry) -> AgentLiveDelivery { + let (cursor, session_id, run_id, event) = entry.into_parts(); + AgentLiveDelivery { + cursor, + session_id, + run_id, + event, + } +} + +fn validate_subscription_capacity( + capacity: Option, +) -> Result { + let capacity = capacity.unwrap_or(DEFAULT_SUBSCRIPTION_CAPACITY); + if capacity == 0 || capacity > MAX_SUBSCRIPTION_CAPACITY { + return Err(AgentLiveCoordinatorError::InvalidSubscriptionCapacity); + } + Ok(capacity) +} + +fn reserved_subscriber_bytes(capacity: usize) -> Result { + // Tokio's broadcast ring rounds the requested capacity up to a power of + // two. Account against the real slot count, not the public request, so a + // caller cannot bypass the aggregate byte cap with capacities such as 257. + let ring_slots = capacity + .checked_next_power_of_two() + .ok_or(AgentLiveCoordinatorError::SubscriberCapacityExceeded)?; + MAX_BUFFERED_DELIVERY_BYTES + .checked_mul(ring_slots) + .ok_or(AgentLiveCoordinatorError::SubscriberCapacityExceeded) +} + +/// Derive the journal owner used for account-generation rotation. The domain +/// separator and length prefixes make an execution target a cryptographic +/// owner boundary rather than advisory metadata. +pub(crate) fn target_bound_owner( + opaque_account_scope: &str, + account_generation: u64, + execution_target: &str, +) -> Result { + validate_identifier(opaque_account_scope, MAX_ACCOUNT_SCOPE_BYTES) + .map_err(|_| AgentLiveCoordinatorError::InvalidAccountScope)?; + validate_identifier(execution_target, MAX_EXECUTION_TARGET_BYTES) + .map_err(|_| AgentLiveCoordinatorError::InvalidExecutionTarget)?; + let mut digest = Sha256::new(); + digest.update(b"maple-agent-live-owner-v1\0"); + let account_scope_bytes = u64::try_from(opaque_account_scope.len()) + .map_err(|_| AgentLiveCoordinatorError::InvalidAccountScope)?; + let execution_target_bytes = u64::try_from(execution_target.len()) + .map_err(|_| AgentLiveCoordinatorError::InvalidExecutionTarget)?; + digest.update(account_scope_bytes.to_be_bytes()); + digest.update(opaque_account_scope.as_bytes()); + digest.update(execution_target_bytes.to_be_bytes()); + digest.update(execution_target.as_bytes()); + let target_scope = format!("maple-agent-live-v1:{:x}", digest.finalize()); + LiveEventAccountOwner::new(&target_scope, account_generation) + .map_err(AgentLiveCoordinatorError::Journal) +} + +fn validate_event_route( + session_id: &str, + run_id: Option<&str>, + event: &MapleLiveEvent, +) -> Result<(), AgentLiveCoordinatorError> { + match event { + MapleLiveEvent::SessionUpdated { session, .. } if session.id != session_id => { + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::ConflictingItemIdentity, + )) + } + MapleLiveEvent::RunStarted { .. } + | MapleLiveEvent::RunFinished { .. } + | MapleLiveEvent::HistoryReplaced { .. } + | MapleLiveEvent::UserFacingError { .. } + if run_id.is_none() => + { + Err(AgentLiveCoordinatorError::InvalidRun) + } + MapleLiveEvent::HistoryHeadCommitted { .. } | MapleLiveEvent::SessionDeleted { .. } + if run_id.is_some() => + { + Err(AgentLiveCoordinatorError::InvalidRun) + } + MapleLiveEvent::TimelineCleared { + reason: MapleLiveClearReason::RunStarted | MapleLiveClearReason::HistoryReplaced, + .. + } if run_id.is_none() => Err(AgentLiveCoordinatorError::InvalidRun), + MapleLiveEvent::TimelineCleared { + reason: MapleLiveClearReason::ExplicitReload, + .. + } if run_id.is_some() => Err(AgentLiveCoordinatorError::InvalidRun), + _ => Ok(()), + } +} + +#[cfg(test)] +mod route_contract_tests { + use super::*; + + fn cursor() -> LiveEventCursor { + LiveEventCursor::try_from_parts("11".repeat(16), 1).expect("test cursor") + } + + #[test] + fn lifecycle_routes_do_not_smuggle_or_drop_run_ownership() { + let committed = MapleLiveEvent::HistoryHeadCommitted { + event_id: "commit".into(), + history_revision: "revision".into(), + through_event_cursor: cursor(), + }; + let deleted = MapleLiveEvent::SessionDeleted { + event_id: "delete".into(), + }; + let replaced = MapleLiveEvent::HistoryReplaced { + event_id: "replace".into(), + }; + let run_clear = MapleLiveEvent::TimelineCleared { + event_id: "run-clear".into(), + reason: MapleLiveClearReason::RunStarted, + }; + let explicit_clear = MapleLiveEvent::TimelineCleared { + event_id: "explicit-clear".into(), + reason: MapleLiveClearReason::ExplicitReload, + }; + let user_error = MapleLiveEvent::UserFacingError { + event_id: "error".into(), + error: MapleLiveUserFacingError { + id: "error-item".into(), + kind: MapleLiveUserFacingErrorKind::Error, + title: Some("Agent error".into()), + message: SAFE_REMOTE_AGENT_ERROR.into(), + created_ms: 1, + }, + }; + assert!(validate_event_route("session", None, &committed).is_ok()); + assert!(validate_event_route("session", Some("run"), &committed).is_err()); + assert!(validate_event_route("session", None, &deleted).is_ok()); + assert!(validate_event_route("session", Some("run"), &deleted).is_err()); + assert!(validate_event_route("session", None, &replaced).is_err()); + assert!(validate_event_route("session", Some("run"), &replaced).is_ok()); + assert!(validate_event_route("session", None, &run_clear).is_err()); + assert!(validate_event_route("session", Some("run"), &run_clear).is_ok()); + assert!(validate_event_route("session", None, &explicit_clear).is_ok()); + assert!(validate_event_route("session", Some("run"), &explicit_clear).is_err()); + assert!(validate_event_route("session", None, &user_error).is_err()); + assert!(validate_event_route("session", Some("run"), &user_error).is_ok()); + } +} + +fn validate_owner_id(value: &str) -> Result<(), AgentLiveProjectionError> { + validate_identifier(value, MAX_OWNER_ID_BYTES) +} + +/// Canonical commitment used by the native persistence authority before it +/// mints a stable-operation capability. The schema is deliberately closed and +/// ordered: it contains the exact owner route, event variant, and every +/// presentation-semantic field, but never the journal `event_id`. +pub(crate) fn live_event_payload_commitment( + session_id: &str, + run_id: Option<&str>, + event: &MapleLiveEvent, +) -> Result<[u8; 32], AgentLiveCoordinatorError> { + validate_owner_id(session_id).map_err(|_| AgentLiveCoordinatorError::InvalidSession)?; + if let Some(run_id) = run_id { + validate_owner_id(run_id).map_err(|_| AgentLiveCoordinatorError::InvalidRun)?; + } + event + .validate() + .map_err(AgentLiveCoordinatorError::Projection)?; + validate_event_route(session_id, run_id, event)?; + + let mut digest = Sha256::new(); + digest.update(LIVE_PAYLOAD_COMMITMENT_DOMAIN); + digest.update(AGENT_LIVE_PROJECTION_SCHEMA_VERSION.to_be_bytes()); + update_ingress_hash_part(&mut digest, session_id.as_bytes()); + hash_optional_str(&mut digest, run_id); + match event { + MapleLiveEvent::RunStarted { .. } => digest.update([0]), + MapleLiveEvent::TimelineUpsert { item, .. } => { + digest.update([1]); + hash_timeline_item(&mut digest, item); + } + MapleLiveEvent::TimelineCleared { reason, .. } => { + digest.update([2]); + digest.update([match reason { + MapleLiveClearReason::RunStarted => 0, + MapleLiveClearReason::HistoryReplaced => 1, + MapleLiveClearReason::ExplicitReload => 2, + }]); + } + MapleLiveEvent::HistoryReplaced { .. } => digest.update([3]), + MapleLiveEvent::HistoryHeadCommitted { + history_revision, + through_event_cursor, + .. + } => { + digest.update([4]); + update_ingress_hash_part(&mut digest, history_revision.as_bytes()); + update_ingress_hash_part(&mut digest, through_event_cursor.journal_id().as_bytes()); + digest.update(through_event_cursor.sequence().to_be_bytes()); + } + MapleLiveEvent::SessionUpdated { session, .. } => { + digest.update([5]); + update_ingress_hash_part(&mut digest, session.id.as_bytes()); + update_ingress_hash_part(&mut digest, session.title.as_bytes()); + update_ingress_hash_part(&mut digest, session.project_root.as_bytes()); + digest.update(session.created_ms.to_be_bytes()); + digest.update(session.updated_ms.to_be_bytes()); + digest.update(session.page_sort_ms.to_be_bytes()); + digest.update( + u64::try_from(session.message_count) + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + hash_optional_str(&mut digest, session.model.as_deref()); + update_ingress_hash_part(&mut digest, session.mode.as_bytes()); + } + MapleLiveEvent::RunFinished { terminal, .. } => { + digest.update([6]); + digest.update([match terminal { + MapleLiveRunTerminal::Completed => 0, + MapleLiveRunTerminal::Cancelled => 1, + MapleLiveRunTerminal::Failed => 2, + }]); + } + MapleLiveEvent::SessionDeleted { .. } => digest.update([7]), + MapleLiveEvent::UserFacingError { error, .. } => { + digest.update([8]); + update_ingress_hash_part(&mut digest, error.id.as_bytes()); + digest.update([match error.kind { + MapleLiveUserFacingErrorKind::Warning => 0, + MapleLiveUserFacingErrorKind::Error => 1, + }]); + hash_optional_str(&mut digest, error.title.as_deref()); + update_ingress_hash_part(&mut digest, error.message.as_bytes()); + digest.update(error.created_ms.to_be_bytes()); + } + } + Ok(digest.finalize().into()) +} + +fn hash_timeline_item(digest: &mut Sha256, item: &MapleLiveTimelineItem) { + update_ingress_hash_part(digest, item.id.as_bytes()); + digest.update([match item.item_type { + MapleLiveItemType::Message => 0, + MapleLiveItemType::Thinking => 1, + MapleLiveItemType::Tool => 2, + MapleLiveItemType::Permission => 3, + MapleLiveItemType::System => 4, + MapleLiveItemType::Error => 5, + }]); + match item.role { + None => digest.update([0]), + Some(role) => { + digest.update([1]); + digest.update([match role { + MapleLiveRole::User => 0, + MapleLiveRole::Assistant => 1, + MapleLiveRole::Thought => 2, + MapleLiveRole::System => 3, + }]); + } + } + hash_optional_str(digest, item.title.as_deref()); + hash_optional_str(digest, item.text.as_deref()); + hash_optional_str(digest, item.status.as_deref()); + digest.update(item.created_ms.to_be_bytes()); + digest.update([match item.merge { + MapleLiveMerge::Append => 0, + MapleLiveMerge::Replace => 1, + }]); +} + +fn hash_optional_str(digest: &mut Sha256, value: Option<&str>) { + match value { + Some(value) => { + digest.update([1]); + update_ingress_hash_part(digest, value.as_bytes()); + } + None => digest.update([0]), + } +} + +fn ingress_event_wire_id( + namespace: &[u8; 32], + session_id: &str, + run_id: Option<&str>, + durable_stable_operation_id: &str, +) -> String { + let mut digest = Sha256::new(); + digest.update(INGRESS_EVENT_ID_DOMAIN); + digest.update(namespace); + update_ingress_hash_part(&mut digest, session_id.as_bytes()); + match run_id { + Some(run_id) => { + digest.update([1]); + update_ingress_hash_part(&mut digest, run_id.as_bytes()); + } + None => digest.update([0]), + } + update_ingress_hash_part(&mut digest, durable_stable_operation_id.as_bytes()); + format!("v1.{:x}", digest.finalize()) +} + +fn update_ingress_hash_part(digest: &mut Sha256, value: &[u8]) { + let length = u64::try_from(value.len()).unwrap_or(u64::MAX); + digest.update(length.to_be_bytes()); + digest.update(value); +} + +fn validate_identifier(value: &str, max_bytes: usize) -> Result<(), AgentLiveProjectionError> { + if value.is_empty() + || value.len() > max_bytes + || value.chars().any(|character| { + character.is_control() + || matches!(character, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') + }) + { + return Err(AgentLiveProjectionError::InvalidIdentifier); + } + Ok(()) +} + +fn validate_text(value: &str, max_bytes: usize) -> Result<(), AgentLiveProjectionError> { + if value.len() > max_bytes || value.contains('\0') { + return Err(AgentLiveProjectionError::TextTooLarge); + } + Ok(()) +} + +fn validate_optional_text( + value: Option<&str>, + max_bytes: usize, +) -> Result<(), AgentLiveProjectionError> { + value.map_or(Ok(()), |value| validate_text(value, max_bytes)) +} + +fn map_snapshot_reason(reason: SnapshotRequiredReason) -> HeadReloadReason { + match reason { + SnapshotRequiredReason::JournalReplaced => HeadReloadReason::JournalReplaced, + SnapshotRequiredReason::RetentionGap => HeadReloadReason::RetentionGap, + SnapshotRequiredReason::CursorAhead => HeadReloadReason::CursorAhead, + } +} + +fn map_journal_activation(error: LiveEventJournalActivationError) -> AgentLiveCoordinatorError { + match error { + LiveEventJournalActivationError::Journal(error) => map_journal_for_attach(error), + LiveEventJournalActivationError::ReseedRequired(required) => { + AgentLiveCoordinatorError::ReseedRequired(required) + } + } +} + +fn map_journal_for_attach(error: LiveEventJournalError) -> AgentLiveCoordinatorError { + match error { + LiveEventJournalError::JournalReplaced | LiveEventJournalError::JournalRetired => { + AgentLiveCoordinatorError::HeadReloadRequired(HeadReloadReason::JournalReplaced) + } + LiveEventJournalError::ReseedRequired => { + AgentLiveCoordinatorError::HeadReloadRequired(HeadReloadReason::ReseedRequired) + } + LiveEventJournalError::OwnerGenerationMismatch + | LiveEventJournalError::OwnerTransitionIncomplete => { + AgentLiveCoordinatorError::HeadReloadRequired(HeadReloadReason::OwnerChanged) + } + LiveEventJournalError::StorageCorrupt + | LiveEventJournalError::StorageUnavailable + | LiveEventJournalError::LockUnavailable => { + AgentLiveCoordinatorError::HeadReloadRequired(HeadReloadReason::JournalUnavailable) + } + other => AgentLiveCoordinatorError::Journal(other), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent_event_journal::{prepare_live_event_journal_parent, LiveEventJournalLimits}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Condvar, Mutex as StdMutex}; + use std::time::Duration; + use tempfile::TempDir; + use tokio::time::timeout; + + fn journal_limits(max_entries: usize) -> LiveEventJournalLimits { + LiveEventJournalLimits { + max_entries, + max_payload_bytes: 256 * 1_024, + max_total_payload_bytes: 512 * 1_024, + max_replay_entries: max_entries.min(REPLAY_PAGE_SIZE).max(1), + max_replay_payload_bytes: 256 * 1_024, + } + } + + fn open_journal( + max_entries: usize, + ) -> ( + TempDir, + LiveEventJournal, + LiveEventAccountOwner, + ) { + // Match the journal's own proven fixture recipe. The system temporary + // hierarchy is root-owned and sticky, while this leaf is owner-only. + let root = tempfile::tempdir().expect("temporary journal root"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)) + .expect("owner-only temporary journal root"); + } + let private_parent = root.path().join("private"); + prepare_live_event_journal_parent(&private_parent).expect("prepare private journal parent"); + let journal = + LiveEventJournal::open(private_parent.join("live"), journal_limits(max_entries)) + .expect("open live journal"); + let owner = LiveEventAccountOwner::new("account-a", 7).expect("valid owner"); + (root, journal, owner) + } + + fn timeline_event(event_id: &str, item_id: &str, text: &str) -> MapleLiveEvent { + MapleLiveEvent::TimelineUpsert { + event_id: event_id.to_string(), + item: MapleLiveTimelineItem { + id: item_id.to_string(), + item_type: MapleLiveItemType::Message, + role: Some(MapleLiveRole::Assistant), + title: None, + text: Some(text.to_string()), + status: None, + created_ms: 1, + merge: MapleLiveMerge::Replace, + }, + } + } + + fn permission_event(event_id: &str, status: Option<&str>) -> MapleLiveEvent { + MapleLiveEvent::TimelineUpsert { + event_id: event_id.to_string(), + item: MapleLiveTimelineItem { + id: "permission-request-1".to_string(), + item_type: MapleLiveItemType::Permission, + role: Some(MapleLiveRole::System), + title: Some(SAFE_REMOTE_PERMISSION_TITLE.to_string()), + text: None, + status: status.map(str::to_string), + created_ms: 1, + merge: MapleLiveMerge::Replace, + }, + } + } + + fn session_updated_event(event_id: &str) -> MapleLiveEvent { + MapleLiveEvent::SessionUpdated { + event_id: event_id.to_string(), + session: MapleLiveSessionSummary { + id: "session-a".to_string(), + title: "Task".to_string(), + project_root: "/workspace".to_string(), + created_ms: 1, + updated_ms: 2, + page_sort_ms: 3, + message_count: 4, + model: Some("model".to_string()), + mode: "auto".to_string(), + }, + } + } + + fn user_facing_error_event(event_id: &str, message: impl Into) -> MapleLiveEvent { + MapleLiveEvent::UserFacingError { + event_id: event_id.to_string(), + error: MapleLiveUserFacingError { + id: format!("error-{event_id}"), + kind: MapleLiveUserFacingErrorKind::Error, + title: Some("Agent error".to_string()), + message: message.into(), + created_ms: 4, + }, + } + } + + fn test_data_owner(target: &str) -> AgentLiveDataOwnerKey { + AgentLiveDataOwnerKey::for_test("account-a", 7, target, 0) + } + + async fn active_subscription( + coordinator: &AgentLiveCoordinator, + capacity: usize, + ) -> AgentLiveSubscription { + let attach = coordinator + .begin_account_head_attach(Some(capacity)) + .await + .expect("begin head attach"); + attach + .token + .finalize() + .await + .expect("finalize head attach") + .subscription + } + + fn head_items<'a>( + attach: &'a AgentHeadAttach, + session_id: &str, + ) -> &'a [MapleLiveTimelineItem] { + attach.live_items_for_session(session_id) + } + + async fn start_test_coordinator( + journal: LiveEventJournal, + owner: LiveEventAccountOwner, + target: &str, + ) -> AgentLiveCoordinator { + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + AgentLiveCoordinator::start_with_backend( + Arc::new(journal), + lease, + test_data_owner(target), + target.to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await + .expect("start coordinator") + } + + #[tokio::test] + async fn ingress_pairings_and_payload_commitments_fail_closed_before_append() { + let (_root, journal, owner) = open_journal(16); + let probe = journal.clone(); + let probe_lease = journal + .activate_account(&owner) + .expect("activate probe lease"); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let old_ingress = coordinator + .begin_ingress("session-a", Some("run-a".to_string())) + .await + .expect("bind old producer"); + let raw = timeline_event("stable-op", "item-1", "original"); + let old_event = coordinator + .publish_event_for_test(&old_ingress, raw.clone()) + .expect("construct old event"); + let fresh_ingress = coordinator + .begin_ingress("session-a", Some("run-a".to_string())) + .await + .expect("supersede producer"); + let fresh_event = coordinator + .publish_event_for_test(&fresh_ingress, raw.clone()) + .expect("construct fresh event"); + + assert!(matches!( + coordinator.publish(&fresh_ingress, old_event).await, + Err(AgentLiveCoordinatorError::IngressRebindRequired) + )); + assert!(matches!( + coordinator.publish(&old_ingress, fresh_event.clone()).await, + Err(AgentLiveCoordinatorError::IngressRebindRequired) + )); + assert_eq!( + probe + .checkpoint(&probe_lease) + .expect("unchanged journal") + .sequence(), + 0 + ); + + let mismatched = AgentLivePublishEvent::timeline_upsert( + fresh_event.id.clone(), + match timeline_event("ignored", "item-1", "changed payload") { + MapleLiveEvent::TimelineUpsert { item, .. } => item, + _ => unreachable!(), + }, + ); + assert!(matches!( + coordinator.publish(&fresh_ingress, mismatched).await, + Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::EventIdConflict + )) + )); + assert_eq!( + probe + .checkpoint(&probe_lease) + .expect("still unchanged") + .sequence(), + 0 + ); + assert!(matches!( + coordinator.begin_account_head_attach(Some(4)).await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost + )) + )); + } + + #[test] + fn canonical_payload_commitment_excludes_event_id_and_covers_closed_semantics() { + let first = timeline_event("id-one", "item-1", "same"); + let second = timeline_event("id-two", "item-1", "same"); + assert_eq!( + live_event_payload_commitment("session-a", Some("run-a"), &first).unwrap(), + live_event_payload_commitment("session-a", Some("run-a"), &second).unwrap() + ); + + let changed = timeline_event("id-one", "item-1", "changed"); + assert_ne!( + live_event_payload_commitment("session-a", Some("run-a"), &first).unwrap(), + live_event_payload_commitment("session-a", Some("run-a"), &changed).unwrap() + ); + assert_ne!( + live_event_payload_commitment("session-a", Some("run-a"), &first).unwrap(), + live_event_payload_commitment("session-b", Some("run-a"), &first).unwrap() + ); + + let unsafe_tool = MapleLiveEvent::TimelineUpsert { + event_id: "unsafe".to_string(), + item: MapleLiveTimelineItem { + id: "tool-1".to_string(), + item_type: MapleLiveItemType::Tool, + role: Some(MapleLiveRole::Assistant), + title: Some("provider secret argument".to_string()), + text: None, + status: Some("running".to_string()), + created_ms: 1, + merge: MapleLiveMerge::Replace, + }, + }; + assert!(matches!( + live_event_payload_commitment("session-a", Some("run-a"), &unsafe_tool), + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::UnsafePresentation + )) + )); + } + + #[tokio::test] + async fn activated_start_rejects_mismatched_data_owner_and_target() { + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + assert!(matches!( + AgentLiveCoordinator::start_activated( + journal.clone(), + lease.clone(), + AgentLiveDataOwnerKey::for_test("account-a", 8, "local", 0), + "local", + ) + .await, + Err(AgentLiveCoordinatorError::DataOwnerMismatch) + )); + assert!(matches!( + AgentLiveCoordinator::start_activated( + journal, + lease, + AgentLiveDataOwnerKey::for_test("account-a", 7, "target-b", 0), + "local", + ) + .await, + Err(AgentLiveCoordinatorError::InvalidExecutionTarget) + )); + } + + #[tokio::test] + async fn ingress_route_and_epoch_state_are_strictly_bounded() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + for index in 0..MAX_INGRESS_ROUTES_PER_ACCOUNT { + coordinator + .begin_ingress(format!("session-{index}"), None) + .await + .expect("admit bounded route"); + } + coordinator + .begin_ingress("session-0", None) + .await + .expect("existing route may explicitly rebind at capacity"); + assert!(matches!( + coordinator.begin_ingress("one-route-too-many", None).await, + Err(AgentLiveCoordinatorError::IngressRouteCapacityExceeded) + )); + + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate overflow journal"); + let disk = + BlockingJournalWorker::spawn(Arc::new(journal), lease).expect("start overflow worker"); + let cursor = disk.checkpoint().await.expect("overflow checkpoint"); + let checkpoint = disk + .load_projection_checkpoint() + .await + .expect("overflow projection checkpoint"); + let mut actor = CoordinatorActor::load(disk, test_data_owner("local"), cursor, checkpoint) + .await + .expect("load overflow actor"); + actor.next_producer_epoch = u64::MAX; + assert!(matches!( + actor.begin_ingress("session-a".to_string(), None).await, + Err(AgentLiveCoordinatorError::IngressEpochExhausted) + )); + assert!(actor.ingress_epochs.is_empty()); + assert_eq!(actor.next_producer_epoch, u64::MAX); + } + + #[tokio::test] + async fn restart_changes_actor_lineage_but_same_journal_retry_stays_idempotent() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal.clone(), owner.clone(), "local").await; + let old_ingress = coordinator + .begin_ingress("session-a", Some("run-a".to_string())) + .await + .expect("bind original producer"); + let raw = timeline_event("restart-stable-op", "item-1", "exact payload"); + let old_event = coordinator + .publish_event_for_test(&old_ingress, raw.clone()) + .expect("construct original event"); + let first = coordinator + .publish(&old_ingress, old_event.clone()) + .await + .expect("publish original event"); + + let restarted = start_test_coordinator(journal, owner, "local").await; + assert!(matches!( + restarted.publish(&old_ingress, old_event).await, + Err(AgentLiveCoordinatorError::IngressRebindRequired) + )); + let fresh_ingress = restarted + .begin_ingress("session-a", Some("run-a".to_string())) + .await + .expect("bind restarted producer"); + let retry = restarted + .publish( + &fresh_ingress, + restarted + .publish_event_for_test(&fresh_ingress, raw) + .expect("reconstruct exact retry"), + ) + .await + .expect("same-journal retry is duplicate"); + assert_eq!(retry, first); + } + + #[tokio::test] + async fn publish_during_head_load_is_delivered_exactly_once() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin head attach"); + assert!(head_items(&attach, "session-a").is_empty()); + let published = coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + timeline_event("event-1", "item-1", "hello"), + ) + .await + .expect("publish while paused"); + + let mut resumed = attach.token.finalize().await.expect("finalize attach"); + assert_eq!(resumed.through_cursor, published); + let delivered = timeout(Duration::from_secs(1), resumed.subscription.recv()) + .await + .expect("delivery timeout") + .expect("delivery"); + assert_eq!(delivered.cursor, published); + assert!(matches!( + delivered.event, + MapleLiveEvent::TimelineUpsert { ref item, .. } + if item.id == "item-1" && item.text.as_deref() == Some("hello") + )); + assert!( + timeout(Duration::from_millis(30), resumed.subscription.recv()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn paused_subscriber_overflow_requires_a_head_reload() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let attach = coordinator + .begin_account_head_attach(Some(1)) + .await + .expect("begin head attach"); + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-1", "item-1", "one"), + ) + .await + .expect("first publish"); + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-2", "item-2", "two"), + ) + .await + .expect("second publish"); + + assert!(matches!( + attach.token.finalize().await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::PausedSubscriberOverflow + )) + )); + } + + #[tokio::test] + async fn aggregate_subscriber_buffer_reservations_are_strictly_bounded() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let first = coordinator + .begin_account_head_attach(Some(128)) + .await + .expect("first bounded subscriber"); + let second = coordinator + .begin_account_head_attach(Some(128)) + .await + .expect("second bounded subscriber"); + assert!(matches!( + coordinator.begin_account_head_attach(Some(128)).await, + Err(AgentLiveCoordinatorError::SubscriberCapacityExceeded) + )); + first + .token + .cancel() + .await + .expect("acknowledged paused attach cancellation"); + let replacement = coordinator + .begin_account_head_attach(Some(128)) + .await + .expect("acknowledged cancellation reclaims reservation"); + replacement + .token + .cancel() + .await + .expect("cancel replacement attach"); + second.token.cancel().await.expect("cancel second attach"); + } + + async fn await_actor_barrier(coordinator: &AgentLiveCoordinator, label: &str) { + coordinator + .begin_ingress(format!("barrier-{label}"), None) + .await + .expect("actor barrier"); + } + + async fn assert_full_head_capacity(coordinator: &AgentLiveCoordinator) { + let first = coordinator + .begin_account_head_attach(Some(128)) + .await + .expect("first full-size subscriber after cancelled begin"); + let second = coordinator + .begin_account_head_attach(Some(128)) + .await + .expect("second full-size subscriber proves no leaked reservation"); + first + .token + .cancel() + .await + .expect("cancel first proof attach"); + second + .token + .cancel() + .await + .expect("cancel second proof attach"); + } + + #[tokio::test] + async fn cancelled_begin_results_release_subscribers_before_capacity_reuse() { + for drop_before_send in [true, false] { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + + let (sender, _receiver) = broadcast::channel(128); + let terminal_reason = Arc::new(TerminalMutex::new(None)); + let (reply, response) = oneshot::channel(); + let response = if drop_before_send { + drop(response); + None + } else { + Some(response) + }; + coordinator + .commands + .send(CoordinatorCommand::BeginHeadAttach { + capacity: 128, + sender, + terminal_reason, + cancellation_commands: coordinator.commands.clone(), + reply, + }) + .await + .expect("queue raw head begin"); + await_actor_barrier(&coordinator, "head-send").await; + drop(response); + await_actor_barrier(&coordinator, "head-drop").await; + assert_full_head_capacity(&coordinator).await; + + let seed = coordinator + .begin_account_head_attach(Some(1)) + .await + .expect("capture resume cursor"); + let resume_cursor = seed.through_cursor.clone(); + seed.token.cancel().await.expect("cancel cursor seed"); + let (sender, _receiver) = broadcast::channel(128); + let terminal_reason = Arc::new(TerminalMutex::new(None)); + let (reply, response) = oneshot::channel(); + let response = if drop_before_send { + drop(response); + None + } else { + Some(response) + }; + coordinator + .commands + .send(CoordinatorCommand::BeginResume { + cursor: resume_cursor, + capacity: 128, + sender, + terminal_reason, + cancellation_commands: coordinator.commands.clone(), + reply, + }) + .await + .expect("queue raw resume begin"); + await_actor_barrier(&coordinator, "resume-send").await; + drop(response); + await_actor_barrier(&coordinator, "resume-drop").await; + assert_full_head_capacity(&coordinator).await; + } + } + + #[tokio::test] + async fn active_unsubscribe_acknowledges_actor_buffer_reclamation() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let first = active_subscription(&coordinator, 128).await; + let second = active_subscription(&coordinator, 128).await; + assert!(matches!( + coordinator.begin_account_head_attach(Some(128)).await, + Err(AgentLiveCoordinatorError::SubscriberCapacityExceeded) + )); + + first + .unsubscribe() + .await + .expect("active unsubscribe actor acknowledgement"); + let replacement = coordinator + .begin_account_head_attach(Some(128)) + .await + .expect("active subscriber reservation reclaimed"); + replacement + .token + .cancel() + .await + .expect("cancel replacement"); + second.unsubscribe().await.expect("unsubscribe second"); + } + + fn detached_attach_token( + subscriber_id: u64, + commands: mpsc::Sender, + ) -> AgentHeadAttachToken { + let (_sender, receiver) = broadcast::channel(1); + AgentHeadAttachToken { + subscriber_id: Some(subscriber_id), + commands, + receiver: Some(receiver), + terminal_reason: Arc::new(TerminalMutex::new(None)), + } + } + + #[tokio::test] + async fn cancelled_finalize_while_command_send_is_blocked_unregisters_after_backpressure() { + let (commands, mut actor_commands) = mpsc::channel(1); + commands + .send(CoordinatorCommand::Unsubscribe { + subscriber_id: 999, + reply: None, + }) + .await + .expect("fill bounded actor queue"); + let finalize = tokio::spawn(detached_attach_token(7, commands).finalize()); + tokio::task::yield_now().await; + assert!(!finalize.is_finished()); + finalize.abort(); + let _ = finalize.await; + + assert!(matches!( + actor_commands.recv().await, + Some(CoordinatorCommand::Unsubscribe { + subscriber_id: 999, + reply: None, + }) + )); + assert!(matches!( + timeout(Duration::from_secs(1), actor_commands.recv()).await, + Ok(Some(CoordinatorCommand::Unsubscribe { + subscriber_id: 7, + reply: None, + })) + )); + } + + #[tokio::test] + async fn cancelled_finalize_while_waiting_for_actor_reply_unregisters() { + let (commands, mut actor_commands) = mpsc::channel(2); + let finalize = tokio::spawn(detached_attach_token(8, commands).finalize()); + let held_reply = match actor_commands.recv().await { + Some(CoordinatorCommand::FinalizeHeadAttach { + subscriber_id: 8, + reply, + }) => reply, + _ => panic!("expected finalize command"), + }; + assert!(!finalize.is_finished()); + finalize.abort(); + let _ = finalize.await; + + assert!(matches!( + timeout(Duration::from_secs(1), actor_commands.recv()).await, + Ok(Some(CoordinatorCommand::Unsubscribe { + subscriber_id: 8, + reply: None, + })) + )); + drop(held_reply); + } + + #[tokio::test] + async fn account_gap_recovers_only_with_one_complete_interleaved_account_head() { + let (_root, journal, owner) = open_journal(2); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin head attach"); + for (session_id, event_id, item_id) in [ + ("session-a", "A1", "item-a1"), + ("session-b", "B1", "item-b1"), + ("session-a", "A2", "item-a2"), + ] { + coordinator + .publish_for_test( + session_id, + None, + timeline_event(event_id, item_id, event_id), + ) + .await + .expect("publish retained event"); + } + + assert!(matches!( + attach.token.finalize().await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::RetentionGap + )) + )); + + let recovered = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("authoritative account recovery head"); + assert!(recovered.live_sessions_complete); + assert_eq!(recovered.through_cursor.sequence(), 3); + assert_eq!( + recovered + .live_sessions + .iter() + .map(|session| session.session_id.as_str()) + .collect::>(), + ["session-a", "session-b"] + ); + assert_eq!(head_items(&recovered, "session-a").len(), 2); + assert_eq!(head_items(&recovered, "session-b").len(), 1); + } + + #[tokio::test] + async fn owner_generation_rotation_invalidates_a_paused_attach() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal.clone(), owner.clone(), "local").await; + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin head attach"); + let current_owner = LiveEventAccountOwner::new( + "account-a", + owner + .account_generation() + .checked_add(1) + .expect("generation"), + ) + .expect("next owner"); + let lease = journal + .activate_account(&owner) + .expect("recover exact active lease"); + journal + .rotate_account_generation(&lease, ¤t_owner) + .expect("rotate account owner"); + + assert!(matches!( + attach.token.finalize().await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OwnerChanged + )) + )); + } + + #[tokio::test] + async fn retired_lease_requires_head_reload_and_closes_every_subscriber() { + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + let coordinator = AgentLiveCoordinator::start_with_backend( + Arc::new(journal.clone()), + lease.clone(), + test_data_owner("local"), + "local".to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await + .expect("start coordinator"); + let mut active = active_subscription(&coordinator, 4).await; + let pending = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin paused attach"); + journal + .seal_for_retirement(&lease, &pending.through_cursor) + .expect("externally fence exact lease"); + + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("after-retirement", "item-1", "must reload"), + ) + .await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + )); + assert_eq!( + active.recv().await, + Err(AgentLiveReceiveError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + ); + assert!(matches!( + pending.token.finalize().await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + )); + } + + #[tokio::test] + async fn replaced_journal_requires_head_reload_and_closes_every_subscriber() { + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + let coordinator = AgentLiveCoordinator::start_with_backend( + Arc::new(journal.clone()), + lease.clone(), + test_data_owner("local"), + "local".to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await + .expect("start coordinator"); + let mut active = active_subscription(&coordinator, 4).await; + let pending = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin paused attach"); + let bytes = b"external absolute projection"; + journal + .store_checkpoint(&lease, &pending.through_cursor, bytes) + .expect("store exact external checkpoint"); + let obligation = journal + .prepare_rollover(&lease, &pending.through_cursor, bytes) + .expect("prepare journal generation replacement"); + journal + .commit_rollover(&obligation, bytes) + .expect("replace journal generation"); + + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("after-replacement", "item-1", "must reload"), + ) + .await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + )); + assert_eq!( + active.recv().await, + Err(AgentLiveReceiveError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + ); + assert!(matches!( + pending.token.finalize().await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + )); + } + + #[derive(Clone)] + struct FailingAppendJournal { + inner: LiveEventJournal, + fail_next_append: Arc, + fail_next_checkpoint: Arc, + commit_before_failure: Arc, + force_next_id_capacity: Arc, + rollover_commit_failures: Arc, + rollover_commit_calls: Arc, + rollover_obligation_address: Arc, + rollover_obligation_reused: Arc, + } + + #[derive(Clone, Default)] + struct ReplayGate { + shared: Arc<(StdMutex, Condvar)>, + } + + #[derive(Default)] + struct ReplayGateState { + armed: bool, + started: bool, + released: bool, + } + + impl ReplayGate { + fn arm(&self) { + let (state, _) = &*self.shared; + let mut state = state.lock().expect("lock replay gate"); + state.armed = true; + state.started = false; + state.released = false; + } + + fn block_if_armed(&self) { + let (state, changed) = &*self.shared; + let mut state = state.lock().expect("lock replay gate"); + if !state.armed { + return; + } + state.armed = false; + state.started = true; + changed.notify_all(); + while !state.released { + state = changed.wait(state).expect("wait for replay release"); + } + } + + fn wait_until_started(&self) { + let (state, changed) = &*self.shared; + let mut state = state.lock().expect("lock replay gate"); + while !state.started { + state = changed.wait(state).expect("wait for replay start"); + } + } + + fn release(&self) { + let (state, changed) = &*self.shared; + let mut state = state.lock().expect("lock replay gate"); + state.released = true; + changed.notify_all(); + } + } + + #[derive(Clone)] + struct GatedReplayJournal { + inner: LiveEventJournal, + gate: ReplayGate, + } + + impl CoordinatorJournal for GatedReplayJournal { + fn max_replay_entries(&self) -> usize { + self.inner.max_replay_entries() + } + + fn checkpoint( + &self, + lease: &LiveEventJournalLease, + ) -> Result { + self.inner.checkpoint(lease) + } + + fn load_projection_checkpoint( + &self, + lease: &LiveEventJournalLease, + ) -> Result, LiveEventJournalError> { + self.inner.load_checkpoint(lease) + } + + fn store_projection_checkpoint( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result { + self.inner.store_checkpoint(lease, expected_head, bytes) + } + + fn bind_ingress( + &self, + lease: &LiveEventJournalLease, + ) -> Result { + self.inner.bind_ingress(lease) + } + + fn prepare_rollover( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result { + self.inner.prepare_rollover(lease, expected_head, bytes) + } + + fn commit_rollover( + &self, + obligation: &LiveEventJournalRolloverObligation, + bytes: &[u8], + ) -> Result { + self.inner.commit_rollover(obligation, bytes) + } + + fn classify_event( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: &MapleLiveEvent, + ) -> Result { + self.inner + .classify_event(ingress, expected_head, session_id, run_id, event) + } + + fn append_outcome( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: MapleLiveEvent, + ) -> Result { + self.inner + .append_outcome(ingress, expected_head, session_id, run_id, event) + } + + fn replay_after( + &self, + lease: &LiveEventJournalLease, + cursor: &LiveEventCursor, + limit: usize, + ) -> Result, LiveEventJournalError> { + self.gate.block_if_armed(); + self.inner.replay_after(lease, cursor, limit) + } + } + + impl CoordinatorJournal for FailingAppendJournal { + fn max_replay_entries(&self) -> usize { + self.inner.max_replay_entries() + } + + fn checkpoint( + &self, + lease: &LiveEventJournalLease, + ) -> Result { + if self.fail_next_checkpoint.swap(false, Ordering::SeqCst) { + return Err(LiveEventJournalError::StorageUnavailable); + } + self.inner.checkpoint(lease) + } + + fn load_projection_checkpoint( + &self, + lease: &LiveEventJournalLease, + ) -> Result, LiveEventJournalError> { + self.inner.load_checkpoint(lease) + } + + fn store_projection_checkpoint( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result { + self.inner.store_checkpoint(lease, expected_head, bytes) + } + + fn bind_ingress( + &self, + lease: &LiveEventJournalLease, + ) -> Result { + self.inner.bind_ingress(lease) + } + + fn prepare_rollover( + &self, + lease: &LiveEventJournalLease, + expected_head: &LiveEventCursor, + bytes: &[u8], + ) -> Result { + self.inner.prepare_rollover(lease, expected_head, bytes) + } + + fn commit_rollover( + &self, + obligation: &LiveEventJournalRolloverObligation, + bytes: &[u8], + ) -> Result { + self.rollover_commit_calls.fetch_add(1, Ordering::SeqCst); + let address = obligation as *const LiveEventJournalRolloverObligation as usize; + match self.rollover_obligation_address.compare_exchange( + 0, + address, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => {} + Err(first) if first != address => { + self.rollover_obligation_reused + .store(false, Ordering::SeqCst); + } + Err(_) => {} + } + if self + .rollover_commit_failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Err(LiveEventJournalError::StorageUnavailable); + } + self.inner.commit_rollover(obligation, bytes) + } + + fn classify_event( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: &MapleLiveEvent, + ) -> Result { + self.inner + .classify_event(ingress, expected_head, session_id, run_id, event) + } + + fn append_outcome( + &self, + ingress: &LiveEventJournalIngressLease, + expected_head: &LiveEventCursor, + session_id: &str, + run_id: Option<&str>, + event: MapleLiveEvent, + ) -> Result { + if self.force_next_id_capacity.swap(false, Ordering::SeqCst) { + return Err(LiveEventJournalError::IdempotencyCapacityExceeded); + } + if self.fail_next_append.swap(false, Ordering::SeqCst) { + if self.commit_before_failure.load(Ordering::SeqCst) { + self.inner + .append_outcome(ingress, expected_head, session_id, run_id, event)?; + } + return Err(LiveEventJournalError::StorageUnavailable); + } + self.inner + .append_outcome(ingress, expected_head, session_id, run_id, event) + } + + fn replay_after( + &self, + lease: &LiveEventJournalLease, + cursor: &LiveEventCursor, + limit: usize, + ) -> Result, LiveEventJournalError> { + self.inner.replay_after(lease, cursor, limit) + } + } + + #[tokio::test] + async fn append_failure_neither_updates_snapshot_nor_fans_out() { + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + let fail_next_append = Arc::new(AtomicBool::new(false)); + let backend = FailingAppendJournal { + inner: journal, + fail_next_append: fail_next_append.clone(), + fail_next_checkpoint: Arc::new(AtomicBool::new(false)), + commit_before_failure: Arc::new(AtomicBool::new(false)), + force_next_id_capacity: Arc::new(AtomicBool::new(false)), + rollover_commit_failures: Arc::new(AtomicUsize::new(0)), + rollover_commit_calls: Arc::new(AtomicUsize::new(0)), + rollover_obligation_address: Arc::new(AtomicUsize::new(0)), + rollover_obligation_reused: Arc::new(AtomicBool::new(true)), + }; + let coordinator = AgentLiveCoordinator::start_with_backend( + Arc::new(backend), + lease, + test_data_owner("local"), + "local".to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await + .expect("start coordinator"); + let mut subscription = active_subscription(&coordinator, 4).await; + fail_next_append.store(true, Ordering::SeqCst); + + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-1", "item-1", "not durable"), + ) + .await, + Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::StorageUnavailable + )) + )); + assert!(timeout(Duration::from_millis(30), subscription.recv()) + .await + .is_err()); + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin replacement head"); + assert!(head_items(&attach, "session-a").is_empty()); + } + + #[tokio::test] + async fn ambiguous_post_sync_append_reconciles_before_next_distinct_event() { + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + let fail_next_append = Arc::new(AtomicBool::new(false)); + let commit_before_failure = Arc::new(AtomicBool::new(true)); + let backend = FailingAppendJournal { + inner: journal, + fail_next_append: fail_next_append.clone(), + fail_next_checkpoint: Arc::new(AtomicBool::new(false)), + commit_before_failure, + force_next_id_capacity: Arc::new(AtomicBool::new(false)), + rollover_commit_failures: Arc::new(AtomicUsize::new(0)), + rollover_commit_calls: Arc::new(AtomicUsize::new(0)), + rollover_obligation_address: Arc::new(AtomicUsize::new(0)), + rollover_obligation_reused: Arc::new(AtomicBool::new(true)), + }; + let coordinator = AgentLiveCoordinator::start_with_backend( + Arc::new(backend), + lease, + test_data_owner("local"), + "local".to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await + .expect("start coordinator"); + let mut subscription = active_subscription(&coordinator, 4).await; + fail_next_append.store(true, Ordering::SeqCst); + + let first = coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-1", "item-1", "first"), + ) + .await + .expect("ambiguous committed append reconciles"); + assert_eq!(first.sequence(), 1); + let second = coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-2", "item-2", "second"), + ) + .await + .expect("next distinct event remains ordered"); + assert_eq!(second.sequence(), 2); + assert_eq!(subscription.recv().await.unwrap().cursor.sequence(), 1); + assert_eq!(subscription.recv().await.unwrap().cursor.sequence(), 2); + } + + #[tokio::test] + async fn ambiguous_rollover_retries_exact_obligation_and_fences_old_subscribers() { + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + let force_next_id_capacity = Arc::new(AtomicBool::new(false)); + let rollover_commit_failures = Arc::new(AtomicUsize::new(0)); + let rollover_commit_calls = Arc::new(AtomicUsize::new(0)); + let rollover_obligation_reused = Arc::new(AtomicBool::new(true)); + let backend = FailingAppendJournal { + inner: journal, + fail_next_append: Arc::new(AtomicBool::new(false)), + fail_next_checkpoint: Arc::new(AtomicBool::new(false)), + commit_before_failure: Arc::new(AtomicBool::new(false)), + force_next_id_capacity: force_next_id_capacity.clone(), + rollover_commit_failures: rollover_commit_failures.clone(), + rollover_commit_calls: rollover_commit_calls.clone(), + rollover_obligation_address: Arc::new(AtomicUsize::new(0)), + rollover_obligation_reused: rollover_obligation_reused.clone(), + }; + let coordinator = AgentLiveCoordinator::start_with_backend( + Arc::new(backend), + lease, + test_data_owner("local"), + "local".to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await + .expect("start coordinator"); + let mut active = active_subscription(&coordinator, 4).await; + let paused = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin old-generation paused attach"); + let old_cursor = paused.through_cursor.clone(); + let event = timeline_event("rollover-event", "item-1", "after rollover"); + let old_ingress = coordinator + .begin_ingress("session-a", None) + .await + .expect("bind old-generation publisher"); + let before_barrier = coordinator + .publish_event_for_test( + &old_ingress, + timeline_event("before-rollover", "item-before", "accepted before barrier"), + ) + .expect("construct pre-barrier event"); + let before_cursor = coordinator + .publish(&old_ingress, before_barrier) + .await + .expect("FIFO event before rollover is accepted"); + assert_eq!(before_cursor.sequence(), 1); + assert_eq!( + active + .recv() + .await + .expect("pre-rollover delivery remains ordered") + .cursor, + before_cursor + ); + let old_event = coordinator + .publish_event_for_test(&old_ingress, event.clone()) + .expect("construct old-generation event"); + force_next_id_capacity.store(true, Ordering::SeqCst); + rollover_commit_failures.store(2, Ordering::SeqCst); + + assert!(matches!( + coordinator.publish(&old_ingress, old_event.clone()).await, + Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::StorageUnavailable + )) + )); + assert_eq!( + active.recv().await, + Err(AgentLiveReceiveError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + ); + + // An ordinary attach may retry the exact pending commit, but it may + // not register or inspect the journal while that retry is unresolved. + assert!(matches!( + coordinator.begin_account_head_attach(Some(4)).await, + Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::StorageUnavailable + )) + )); + assert!(matches!( + paused.token.finalize().await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + )); + assert_eq!(rollover_commit_calls.load(Ordering::SeqCst), 3); + assert!(rollover_obligation_reused.load(Ordering::SeqCst)); + + assert!(matches!( + coordinator.publish(&old_ingress, old_event).await, + Err(AgentLiveCoordinatorError::IngressRebindRequired) + )); + let fresh_ingress = coordinator + .begin_ingress("session-a", None) + .await + .expect("explicitly bind fresh generation"); + let stale_payload = live_event_payload_commitment("session-a", None, &event) + .expect("canonical old payload"); + let stale_operation = AgentDurableStableOperationId::for_test( + coordinator.data_owner.clone(), + "session-a", + None, + "rollover-event", + old_ingress.namespace, + stale_payload, + ); + assert!(matches!( + fresh_ingress.event_id(&stale_operation), + Err(AgentLiveCoordinatorError::IngressRebindRequired) + )); + let fresh_event = timeline_event("post-rollover-operation", "item-2", "after rollover"); + let fresh = coordinator + .publish( + &fresh_ingress, + coordinator + .publish_event_for_test(&fresh_ingress, fresh_event) + .expect("construct explicit fresh operation"), + ) + .await + .expect("publish only after exact rollover commit completed"); + assert_eq!(fresh.sequence(), 1); + assert_ne!(fresh.journal_id(), old_cursor.journal_id()); + let head = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("attach to fresh generation"); + assert_eq!(head_items(&head, "session-a").len(), 2); + assert_eq!(head_items(&head, "session-a")[0].id, "item-before"); + assert_eq!(head_items(&head, "session-a")[1].id, "item-2"); + } + + #[tokio::test] + async fn failed_seal_verification_is_still_a_terminal_fifo_fence() { + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + let fail_next_checkpoint = Arc::new(AtomicBool::new(false)); + let backend = FailingAppendJournal { + inner: journal, + fail_next_append: Arc::new(AtomicBool::new(false)), + fail_next_checkpoint: fail_next_checkpoint.clone(), + commit_before_failure: Arc::new(AtomicBool::new(false)), + force_next_id_capacity: Arc::new(AtomicBool::new(false)), + rollover_commit_failures: Arc::new(AtomicUsize::new(0)), + rollover_commit_calls: Arc::new(AtomicUsize::new(0)), + rollover_obligation_address: Arc::new(AtomicUsize::new(0)), + rollover_obligation_reused: Arc::new(AtomicBool::new(true)), + }; + let coordinator = AgentLiveCoordinator::start_with_backend( + Arc::new(backend), + lease, + test_data_owner("local"), + "local".to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await + .expect("start coordinator"); + let mut active = active_subscription(&coordinator, 4).await; + fail_next_checkpoint.store(true, Ordering::SeqCst); + + assert!(matches!( + coordinator.seal(AgentLiveSealReason::HostShutdown).await, + Err(AgentLiveCoordinatorError::Journal( + LiveEventJournalError::StorageUnavailable + )) + )); + assert_eq!( + active.recv().await, + Err(AgentLiveReceiveError::HeadReloadRequired( + HeadReloadReason::OwnerChanged + )) + ); + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("after-failed-seal", "item-1", "must fail"), + ) + .await, + Err(AgentLiveCoordinatorError::Sealed( + AgentLiveSealReason::HostShutdown + )) + )); + let recovered = coordinator + .seal(AgentLiveSealReason::HostShutdown) + .await + .expect("same terminal seal retries exact head verification"); + assert_eq!(recovered.reason, AgentLiveSealReason::HostShutdown); + assert_eq!(recovered.through_cursor.sequence(), 0); + } + + #[tokio::test] + async fn actionable_permissions_are_rejected_before_append() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + for (event_id, status) in [ + ("missing-status", None), + ("pending-status", Some("pending")), + ] { + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + permission_event(event_id, status) + ) + .await, + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::ActionablePermission + )) + )); + } + let accepted = coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + permission_event("cancelled-status", Some("cancelled")), + ) + .await + .expect("resolved permission is safe to persist"); + assert_eq!(accepted.sequence(), 1); + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("resolved head"); + assert_eq!(head_items(&attach, "session-a").len(), 1); + assert_eq!( + head_items(&attach, "session-a")[0].status.as_deref(), + Some("cancelled") + ); + } + + #[tokio::test] + async fn javascript_unsafe_timestamps_and_counts_are_rejected_before_append() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + + let mut unsafe_item = timeline_event("unsafe-item-time", "item-1", "unsafe"); + let MapleLiveEvent::TimelineUpsert { item, .. } = &mut unsafe_item else { + unreachable!(); + }; + item.created_ms = MAX_JAVASCRIPT_SAFE_INTEGER + 1; + assert!(matches!( + coordinator + .publish_for_test("session-a", None, unsafe_item) + .await, + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::InvalidTimestamp + )) + )); + + let timestamp_mutators: [(&str, fn(&mut MapleLiveSessionSummary)); 3] = [ + ( + "negative-created", + |summary: &mut MapleLiveSessionSummary| summary.created_ms = -1, + ), + ("unsafe-updated", |summary: &mut MapleLiveSessionSummary| { + summary.updated_ms = (MAX_JAVASCRIPT_SAFE_INTEGER + 1) as i64 + }), + ("unsafe-sort", |summary: &mut MapleLiveSessionSummary| { + summary.page_sort_ms = (MAX_JAVASCRIPT_SAFE_INTEGER + 1) as i64 + }), + ]; + for (event_id, mutate) in timestamp_mutators { + let mut event = session_updated_event(event_id); + let MapleLiveEvent::SessionUpdated { session, .. } = &mut event else { + unreachable!(); + }; + mutate(session); + assert!(matches!( + coordinator.publish_for_test("session-a", None, event).await, + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::InvalidTimestamp + )) + )); + } + + if usize::BITS > 53 { + let mut unsafe_count = session_updated_event("unsafe-count"); + let MapleLiveEvent::SessionUpdated { session, .. } = &mut unsafe_count else { + unreachable!(); + }; + session.message_count = usize::try_from(MAX_JAVASCRIPT_SAFE_INTEGER + 1) + .expect("64-bit usize test platform"); + assert!(matches!( + coordinator + .publish_for_test("session-a", None, unsafe_count) + .await, + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::InvalidCount + )) + )); + } + + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("rejections do not advance journal"); + assert_eq!(attach.through_cursor.sequence(), 0); + } + + #[tokio::test] + async fn account_cursor_is_contiguous_across_sessions_but_accounts_are_isolated() { + let (_root, journal, owner_a) = open_journal(32); + let owner_b = LiveEventAccountOwner::new("account-b", 7).expect("second account"); + let coordinator_a = start_test_coordinator(journal.clone(), owner_a, "same-target").await; + let coordinator_b = start_test_coordinator(journal, owner_b, "same-target").await; + let mut subscription_a = active_subscription(&coordinator_a, 8).await; + let mut subscription_b = active_subscription(&coordinator_b, 8).await; + + for (session_id, event_id, item_id) in [ + ("session-a", "A7", "item-a7"), + ("session-b", "B8", "item-b8"), + ("session-a", "A9", "item-a9"), + ] { + coordinator_a + .publish_for_test( + session_id, + None, + timeline_event(event_id, item_id, event_id), + ) + .await + .expect("publish interleaved account event"); + } + let mut previous_sequence = 0; + for expected_text in ["A7", "B8", "A9"] { + let delivery = timeout(Duration::from_secs(1), subscription_a.recv()) + .await + .expect("account A timeout") + .expect("account A delivery"); + assert!(matches!( + delivery.event, + MapleLiveEvent::TimelineUpsert { ref item, .. } + if item.text.as_deref() == Some(expected_text) + )); + assert_eq!(delivery.cursor.sequence(), previous_sequence + 1); + previous_sequence = delivery.cursor.sequence(); + } + assert!(timeout(Duration::from_millis(30), subscription_b.recv()) + .await + .is_err()); + + coordinator_b + .publish_for_test( + "session-a", + None, + timeline_event("account-b-event", "item-b", "B"), + ) + .await + .expect("publish account B"); + let from_b = timeout(Duration::from_secs(1), subscription_b.recv()) + .await + .expect("account B timeout") + .expect("account B delivery"); + assert!(matches!( + from_b.event, + MapleLiveEvent::TimelineUpsert { ref item, .. } + if item.id == "item-b" && item.text.as_deref() == Some("B") + )); + assert!(timeout(Duration::from_millis(30), subscription_a.recv()) + .await + .is_err()); + } + + #[tokio::test] + async fn account_head_is_complete_sorted_unique_and_order_stable_at_one_c0() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + coordinator + .publish_for_test( + "session-b", + None, + timeline_event("event-b1", "item-b1", "B at C1"), + ) + .await + .expect("publish session B"); + let c0 = coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-a1", "item-a1", "A at C2"), + ) + .await + .expect("publish session A"); + + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin complete account head"); + assert!(attach.live_sessions_complete); + assert_eq!(attach.through_cursor, c0); + assert_eq!( + attach + .live_sessions + .iter() + .map(|session| session.session_id.as_str()) + .collect::>(), + ["session-a", "session-b"] + ); + assert_eq!(head_items(&attach, "session-a").len(), 1); + assert_eq!(head_items(&attach, "session-b").len(), 1); + assert!(head_items(&attach, "session-missing").is_empty()); + + let c1 = coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-a2", "item-a2", "after C0"), + ) + .await + .expect("publish after snapshot barrier"); + assert_eq!(head_items(&attach, "session-a").len(), 1); + let mut resumed = attach + .token + .finalize() + .await + .expect("finalize account head"); + assert_eq!(resumed.through_cursor, c1); + let delivery = timeout(Duration::from_secs(1), resumed.subscription.recv()) + .await + .expect("post-C0 delivery timeout") + .expect("post-C0 delivery"); + assert_eq!(delivery.cursor, c1); + assert!(matches!( + delivery.event, + MapleLiveEvent::TimelineUpsert { ref item, .. } if item.id == "item-a2" + )); + } + + #[tokio::test] + async fn empty_snapshot_is_authoritative_and_cursor_resume_is_live() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin head attach"); + assert_eq!(head_items(&attach, "session-a"), []); + assert_eq!(attach.through_cursor.sequence(), 0); + let cursor = attach.through_cursor.clone(); + drop(attach); + + let mut resumed = coordinator + .begin_resume(cursor, Some(4)) + .await + .expect("cursor resume"); + let published = coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-1", "item-1", "live"), + ) + .await + .expect("publish live event"); + let delivered = timeout(Duration::from_secs(1), resumed.subscription.recv()) + .await + .expect("delivery timeout") + .expect("delivery"); + assert_eq!(delivered.cursor, published); + } + + #[tokio::test] + async fn cursor_resume_replays_the_durable_gap_before_going_live() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("capture old cursor"); + let old_cursor = attach.through_cursor.clone(); + drop(attach); + let published = coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + timeline_event("event-1", "item-1", "during disconnect"), + ) + .await + .expect("publish replay event"); + + let mut resumed = coordinator + .begin_resume(old_cursor, Some(4)) + .await + .expect("resume cursor"); + assert_eq!(resumed.through_cursor, published); + let replayed = timeout(Duration::from_secs(1), resumed.subscription.recv()) + .await + .expect("replay timeout") + .expect("replayed delivery"); + assert_eq!(replayed.cursor, published); + assert!(matches!( + replayed.event, + MapleLiveEvent::TimelineUpsert { ref item, .. } if item.id == "item-1" + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn resume_registration_barrier_cannot_lose_a_publish_queued_during_replay() { + let (_root, journal, owner) = open_journal(16); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + let gate = ReplayGate::default(); + let backend = GatedReplayJournal { + inner: journal, + gate: gate.clone(), + }; + let coordinator = AgentLiveCoordinator::start_with_backend( + Arc::new(backend), + lease, + test_data_owner("local"), + "local".to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await + .expect("start coordinator"); + let initial = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("capture resume cursor"); + let resume_cursor = initial.through_cursor; + drop(initial.token); + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-1", "item-1", "replayed"), + ) + .await + .expect("publish replay gap"); + + let queued_ingress = coordinator + .begin_ingress("session-b", None) + .await + .expect("bind queued publisher"); + let queued_event = coordinator + .publish_event_for_test( + &queued_ingress, + timeline_event("event-2", "item-2", "queued live"), + ) + .expect("construct queued event"); + + gate.arm(); + let resume_coordinator = coordinator.clone(); + let resume_task = tokio::spawn(async move { + resume_coordinator + .begin_resume(resume_cursor, Some(4)) + .await + }); + let wait_gate = gate.clone(); + tokio::task::spawn_blocking(move || wait_gate.wait_until_started()) + .await + .expect("wait for replay worker"); + + // Queue directly while the actor is blocked at its replay barrier. A + // normal `publish` call performs this same send before awaiting reply. + let (publish_reply, publish_response) = oneshot::channel(); + coordinator + .commands + .send(CoordinatorCommand::Publish { + ingress: queued_ingress, + event: queued_event, + reply: publish_reply, + }) + .await + .expect("queue publish behind resume barrier"); + gate.release(); + + let mut resumed = resume_task + .await + .expect("join resume") + .expect("resume succeeds"); + let published = publish_response + .await + .expect("publish response") + .expect("queued publish succeeds"); + let replayed = timeout(Duration::from_secs(1), resumed.subscription.recv()) + .await + .expect("replay timeout") + .expect("replay delivery"); + let live = timeout(Duration::from_secs(1), resumed.subscription.recv()) + .await + .expect("live timeout") + .expect("live delivery"); + assert!(matches!( + replayed.event, + MapleLiveEvent::TimelineUpsert { ref item, .. } if item.id == "item-1" + )); + assert!(matches!( + live.event, + MapleLiveEvent::TimelineUpsert { ref item, .. } if item.id == "item-2" + )); + assert_eq!(live.session_id, "session-b"); + assert_eq!(published.sequence(), replayed.cursor.sequence() + 1); + assert_eq!(live.cursor, published); + } + + #[tokio::test] + async fn a_stable_event_retry_is_not_applied_or_fanned_out_twice() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let mut subscription = active_subscription(&coordinator, 4).await; + let event = timeline_event("stable-event", "item-1", "once"); + let first = coordinator + .publish_for_test("session-a", None, event.clone()) + .await + .expect("initial append"); + let retry = coordinator + .publish_for_test("session-a", None, event) + .await + .expect("idempotent retry"); + assert_eq!(first, retry); + let delivered = timeout(Duration::from_secs(1), subscription.recv()) + .await + .expect("delivery timeout") + .expect("delivery"); + assert_eq!(delivered.cursor, first); + assert!(timeout(Duration::from_millis(30), subscription.recv()) + .await + .is_err()); + + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("absolute snapshot"); + assert_eq!(head_items(&attach, "session-a").len(), 1); + assert_eq!( + head_items(&attach, "session-a")[0].text.as_deref(), + Some("once") + ); + } + + #[tokio::test] + async fn a_slow_active_subscriber_gets_an_explicit_reload_error() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let mut subscription = active_subscription(&coordinator, 1).await; + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-1", "item-1", "one"), + ) + .await + .expect("first publish"); + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-2", "item-2", "two"), + ) + .await + .expect("second publish"); + + assert!(matches!( + subscription.recv().await, + Err(AgentLiveReceiveError::HeadReloadRequired( + HeadReloadReason::SlowSubscriber + )) + )); + } + + #[tokio::test] + async fn absolute_snapshot_folds_append_events_in_stable_item_order() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-1", "item-1", "hel"), + ) + .await + .expect("initial item"); + let mut appended = timeline_event("event-2", "item-1", "lo"); + let MapleLiveEvent::TimelineUpsert { item, .. } = &mut appended else { + unreachable!(); + }; + item.merge = MapleLiveMerge::Append; + coordinator + .publish_for_test("session-a", None, appended) + .await + .expect("append item"); + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-3", "item-2", "second"), + ) + .await + .expect("second item"); + + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("head snapshot"); + assert_eq!(head_items(&attach, "session-a").len(), 2); + assert_eq!(head_items(&attach, "session-a")[0].id, "item-1"); + assert_eq!( + head_items(&attach, "session-a")[0].text.as_deref(), + Some("hello") + ); + assert_eq!( + head_items(&attach, "session-a")[0].merge, + MapleLiveMerge::Replace + ); + assert_eq!(head_items(&attach, "session-a")[1].id, "item-2"); + } + + #[tokio::test] + async fn startup_rebuilds_absolute_projection_and_preserves_terminal_suffix() { + let (_root, journal, owner) = open_journal(16); + journal + .append( + &owner, + "session-a", + Some("run-a"), + timeline_event("event-1", "item-1", "hel"), + ) + .expect("persist initial live item"); + let mut appended = timeline_event("event-2", "item-1", "lo"); + let MapleLiveEvent::TimelineUpsert { item, .. } = &mut appended else { + unreachable!(); + }; + item.merge = MapleLiveMerge::Append; + journal + .append(&owner, "session-a", Some("run-a"), appended) + .expect("persist append"); + journal + .append( + &owner, + "session-a", + Some("run-a"), + MapleLiveEvent::RunFinished { + event_id: "event-3".to_string(), + terminal: MapleLiveRunTerminal::Completed, + }, + ) + .expect("persist terminal"); + + let coordinator = start_test_coordinator(journal, owner, "local").await; + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("rebuilt head"); + assert_eq!(attach.through_cursor.sequence(), 3); + assert_eq!(head_items(&attach, "session-a").len(), 1); + assert_eq!( + head_items(&attach, "session-a")[0].text.as_deref(), + Some("hello") + ); + assert_eq!( + head_items(&attach, "session-a")[0].merge, + MapleLiveMerge::Replace + ); + } + + #[tokio::test] + async fn startup_rebuild_honors_an_explicit_timeline_clear() { + let (_root, journal, owner) = open_journal(16); + journal + .append( + &owner, + "session-a", + Some("run-a"), + timeline_event("event-1", "item-1", "stale"), + ) + .expect("persist item"); + journal + .append( + &owner, + "session-a", + Some("run-a"), + MapleLiveEvent::TimelineCleared { + event_id: "event-2".to_string(), + reason: MapleLiveClearReason::HistoryReplaced, + }, + ) + .expect("persist clear"); + + let coordinator = start_test_coordinator(journal, owner, "local").await; + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("rebuilt clear head"); + assert_eq!(attach.through_cursor.sequence(), 2); + assert!(head_items(&attach, "session-a").is_empty()); + } + + #[tokio::test] + async fn startup_rejects_a_history_commit_that_does_not_name_its_predecessor() { + let (_root, journal, owner) = open_journal(16); + let first = journal + .append( + &owner, + "session-a", + None, + timeline_event("event-1", "item-1", "must not be erased"), + ) + .expect("persist item"); + journal + .append( + &owner, + "session-a", + None, + MapleLiveEvent::HistoryHeadCommitted { + event_id: "malformed-commit".to_string(), + history_revision: "revision-1".to_string(), + through_event_cursor: first.beginning(), + }, + ) + .expect("journal accepts structurally valid projected payload"); + let lease = journal + .activate_account(&owner) + .expect("activate test journal"); + + assert!(matches!( + AgentLiveCoordinator::start_with_backend( + Arc::new(journal), + lease, + test_data_owner("local"), + "local".to_string(), + DEFAULT_COMMAND_CAPACITY, + ) + .await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::OrderingLost + )) + )); + } + + #[tokio::test] + async fn restart_restores_checkpoint_then_replays_compacted_terminal_suffix() { + let (_root, journal, owner) = open_journal(2); + let lease = journal + .activate_account(&owner) + .expect("activate checkpoint journal"); + let namespace = journal + .bind_ingress(&lease) + .expect("bind checkpoint journal ingress") + .event_namespace_commitment(); + let first = timeline_event( + &ingress_event_wire_id(&namespace, "session-a", None, "event-1"), + "item-1", + "checkpointed", + ); + let first_cursor = journal + .append(&owner, "session-a", None, first.clone()) + .expect("persist first item"); + let second_cursor = journal + .append( + &owner, + "session-b", + None, + timeline_event("event-2", "item-2", "also checkpointed"), + ) + .expect("persist second item"); + let checkpoint_bytes = serde_json::to_vec(&CoordinatorProjectionCheckpoint { + format_version: LIVE_PROJECTION_CHECKPOINT_VERSION, + live_sessions: vec![ + AgentLiveSessionProjection { + session_id: "session-a".to_string(), + live_items: vec![match first { + MapleLiveEvent::TimelineUpsert { item, .. } => item.as_absolute(), + _ => unreachable!(), + }], + }, + AgentLiveSessionProjection { + session_id: "session-b".to_string(), + live_items: vec![MapleLiveTimelineItem { + id: "item-2".to_string(), + item_type: MapleLiveItemType::Message, + role: Some(MapleLiveRole::Assistant), + title: None, + text: Some("also checkpointed".to_string()), + status: None, + created_ms: 1, + merge: MapleLiveMerge::Replace, + }], + }, + ], + }) + .expect("encode safe projection checkpoint"); + journal + .store_checkpoint(&owner, &second_cursor, &checkpoint_bytes) + .expect("store exact projection checkpoint"); + journal + .append( + &owner, + "session-a", + Some("run-a"), + MapleLiveEvent::RunFinished { + event_id: "event-3".to_string(), + terminal: MapleLiveRunTerminal::Completed, + }, + ) + .expect("compact covered entries and retain terminal suffix"); + + let coordinator = start_test_coordinator(journal, owner, "local").await; + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("restart from checkpoint plus suffix"); + assert_eq!(attach.through_cursor.sequence(), 3); + assert_eq!(head_items(&attach, "session-a").len(), 1); + assert_eq!(head_items(&attach, "session-b").len(), 1); + + let retry = coordinator + .publish_for_test( + "session-a", + None, + timeline_event("event-1", "item-1", "checkpointed"), + ) + .await + .expect("durable retry remains idempotent after payload eviction"); + assert_eq!(retry, first_cursor); + } + + #[tokio::test] + async fn account_projection_exhaustion_is_typed_and_does_not_append() { + let (_root, journal, owner) = open_journal(128); + let coordinator = start_test_coordinator(journal, owner, "local").await; + for index in 0..MAX_LIVE_SESSIONS_PER_ACCOUNT { + coordinator + .publish_for_test( + format!("session-{index}"), + None, + timeline_event( + &format!("event-{index}"), + &format!("item-{index}"), + "bounded", + ), + ) + .await + .expect("publish bounded session"); + } + assert!(matches!( + coordinator + .publish_for_test( + "session-overflow", + None, + timeline_event("overflow-event", "overflow-item", "rejected"), + ) + .await, + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::AccountProjectionCapacityExceeded + )) + )); + let attach = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("head remains usable"); + assert_eq!( + attach.through_cursor.sequence(), + MAX_LIVE_SESSIONS_PER_ACCOUNT as u64 + ); + assert!(head_items(&attach, "session-overflow").is_empty()); + } + + #[tokio::test] + async fn terminal_sessions_are_retired_only_after_a_persisted_head_commit() { + let (_root, journal, owner) = open_journal(256); + let coordinator = start_test_coordinator(journal, owner, "local").await; + for index in 0..MAX_LIVE_SESSIONS_PER_ACCOUNT { + let session_id = format!("session-{index}"); + let run_id = format!("run-{index}"); + coordinator + .publish_for_test( + session_id.clone(), + Some(run_id.clone()), + timeline_event( + &format!("item-event-{index}"), + &format!("item-{index}"), + "terminal suffix", + ), + ) + .await + .expect("publish terminal suffix"); + coordinator + .publish_for_test( + session_id, + Some(run_id), + MapleLiveEvent::RunFinished { + event_id: format!("finished-{index}"), + terminal: MapleLiveRunTerminal::Completed, + }, + ) + .await + .expect("publish terminal state"); + } + assert!(matches!( + coordinator + .publish_for_test( + "session-overflow", + Some("run-overflow".to_string()), + timeline_event("overflow", "overflow-item", "blocked"), + ) + .await, + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::AccountProjectionCapacityExceeded + )) + )); + + let current = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("capture persisted head cursor"); + let through = current.through_cursor.clone(); + drop(current); + coordinator + .acknowledge_persisted_head_for_test( + "session-0", + "commit-session-0", + "history-revision-session-0", + through, + ) + .await + .expect("retire committed terminal session"); + coordinator + .publish_for_test( + "session-after-retirement", + Some("run-after-retirement".to_string()), + timeline_event("after-retirement", "new-item", "admitted"), + ) + .await + .expect("capacity released only after commit"); + } + + #[tokio::test] + async fn an_execution_target_cannot_resume_another_targets_cursor() { + let (_root, journal, _owner) = open_journal(16); + let coordinator_a = + AgentLiveCoordinator::start(journal.clone(), "account-a", 7, "target-a") + .await + .expect("start target A"); + let coordinator_b = AgentLiveCoordinator::start(journal, "account-a", 7, "target-b") + .await + .expect("start target B"); + let target_a = coordinator_a + .begin_account_head_attach(Some(4)) + .await + .expect("target A cursor"); + + assert!(matches!( + coordinator_b + .begin_resume(target_a.through_cursor, Some(4)) + .await, + Err(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::JournalReplaced + )) + )); + } + + #[tokio::test] + async fn history_replaced_is_a_non_clearing_signal_until_explicitly_cleared() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + timeline_event("event-1", "item-1", "uncommitted suffix"), + ) + .await + .expect("publish live suffix"); + coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + MapleLiveEvent::HistoryReplaced { + event_id: "event-2".to_string(), + }, + ) + .await + .expect("publish non-clearing history signal"); + + let retained = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("head after history replacement"); + assert_eq!(head_items(&retained, "session-a").len(), 1); + assert_eq!( + head_items(&retained, "session-a")[0].text.as_deref(), + Some("uncommitted suffix") + ); + drop(retained); + + coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + MapleLiveEvent::TimelineCleared { + event_id: "event-3".to_string(), + reason: MapleLiveClearReason::HistoryReplaced, + }, + ) + .await + .expect("publish explicit clear"); + let cleared = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("head after explicit clear"); + assert!(head_items(&cleared, "session-a").is_empty()); + } + + #[tokio::test] + async fn persisted_head_commit_retires_only_the_exact_acknowledged_live_head() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let first = coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + timeline_event("event-1", "item-1", "persist me"), + ) + .await + .expect("publish first suffix"); + let delayed_cursor = first.clone(); + coordinator + .publish_for_test( + "session-b", + Some("run-b".to_string()), + timeline_event("event-2", "item-2", "newer account event"), + ) + .await + .expect("advance account cursor"); + + assert!(matches!( + coordinator + .acknowledge_persisted_head_for_test( + "session-a", + "commit-stale", + "history-revision-a", + delayed_cursor, + ) + .await, + Err(AgentLiveCoordinatorError::StaleHistoryCommit) + )); + let current = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("capture current account head"); + let through = current.through_cursor.clone(); + drop(current); + coordinator + .acknowledge_persisted_head_for_test( + "session-a", + "commit-current", + "history-revision-b", + through, + ) + .await + .expect("commit exact current head"); + + let retired = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("complete account head after retirement"); + assert!(retired.live_sessions_complete); + assert!(head_items(&retired, "session-a").is_empty()); + assert_eq!(head_items(&retired, "session-b").len(), 1); + } + + #[tokio::test] + async fn added_lifecycle_variants_are_closed_bounded_and_project_safely() { + let (_root, journal, owner) = open_journal(16); + let coordinator = start_test_coordinator(journal, owner, "local").await; + + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + None, + MapleLiveEvent::RunStarted { + event_id: "run-without-owner".to_string(), + }, + ) + .await, + Err(AgentLiveCoordinatorError::InvalidRun) + )); + coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + MapleLiveEvent::RunStarted { + event_id: "run-started".to_string(), + }, + ) + .await + .expect("publish owned run start"); + + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + user_facing_error_event( + "oversized-error", + "x".repeat(MAX_USER_FACING_ERROR_MESSAGE_BYTES + 1), + ), + ) + .await, + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::TextTooLarge + )) + )); + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + user_facing_error_event("empty-error", " "), + ) + .await, + Err(AgentLiveCoordinatorError::Projection( + AgentLiveProjectionError::InvalidUserFacingError + )) + )); + coordinator + .publish_for_test( + "session-a", + Some("run-a".to_string()), + user_facing_error_event("safe-error", SAFE_REMOTE_AGENT_ERROR), + ) + .await + .expect("publish bounded user-facing error"); + let error_head = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("error head"); + assert_eq!(head_items(&error_head, "session-a").len(), 1); + assert_eq!( + head_items(&error_head, "session-a")[0].item_type, + MapleLiveItemType::Error + ); + assert_eq!( + head_items(&error_head, "session-a")[0].text.as_deref(), + Some(SAFE_REMOTE_AGENT_ERROR) + ); + drop(error_head); + + coordinator + .publish_for_test( + "session-a", + None, + MapleLiveEvent::SessionDeleted { + event_id: "session-deleted".to_string(), + }, + ) + .await + .expect("publish session deletion"); + let deleted_head = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("deleted session head"); + assert!(head_items(&deleted_head, "session-a").is_empty()); + assert_eq!(deleted_head.through_cursor.sequence(), 3); + } + + #[tokio::test] + async fn seal_is_a_fifo_terminal_barrier_for_publishes_and_attaches() { + let (_root, journal, owner) = open_journal(16); + let retirement_journal = journal.clone(); + let coordinator = start_test_coordinator(journal, owner, "local").await; + let mut active = active_subscription(&coordinator, 4).await; + let pending = coordinator + .begin_account_head_attach(Some(4)) + .await + .expect("begin pending attach"); + + let queued_ingress = coordinator + .begin_ingress("session-a", None) + .await + .expect("bind queued publisher"); + let queued_event = coordinator + .publish_event_for_test( + &queued_ingress, + timeline_event("before-seal", "item-1", "durable first"), + ) + .expect("construct queued event"); + + let (publish_reply, publish_response) = oneshot::channel(); + coordinator + .commands + .send(CoordinatorCommand::Publish { + ingress: queued_ingress, + event: queued_event, + reply: publish_reply, + }) + .await + .expect("queue publish before seal"); + let sealed = coordinator + .seal(AgentLiveSealReason::OwnerChanged) + .await + .expect("seal after queued publish"); + + let persisted = publish_response + .await + .expect("publish reply") + .expect("prior publish completed"); + assert_eq!(persisted.sequence(), 1); + assert_eq!(sealed.reason, AgentLiveSealReason::OwnerChanged); + assert_eq!(sealed.through_cursor, persisted); + retirement_journal + .seal_for_retirement(&sealed.journal_lease, &sealed.through_cursor) + .expect("seal result is exact retirement authority"); + let delivered = active.recv().await.expect("buffered prior delivery"); + assert!(matches!( + delivered.event, + MapleLiveEvent::TimelineUpsert { ref item, .. } + if item.id == "item-1" && item.text.as_deref() == Some("durable first") + )); + assert_eq!( + active.recv().await, + Err(AgentLiveReceiveError::HeadReloadRequired( + HeadReloadReason::OwnerChanged + )) + ); + assert!(matches!( + pending.token.finalize().await, + Err(AgentLiveCoordinatorError::Sealed( + AgentLiveSealReason::OwnerChanged + )) + )); + assert!(matches!( + coordinator + .publish_for_test( + "session-a", + None, + timeline_event("after-seal", "item-2", "must fail"), + ) + .await, + Err(AgentLiveCoordinatorError::Sealed( + AgentLiveSealReason::OwnerChanged + )) + )); + assert!(matches!( + coordinator.begin_account_head_attach(Some(4)).await, + Err(AgentLiveCoordinatorError::Sealed( + AgentLiveSealReason::OwnerChanged + )) + )); + let repeated = coordinator + .seal(AgentLiveSealReason::OwnerChanged) + .await + .expect("same seal is idempotent"); + assert_eq!(repeated, sealed); + assert!(matches!( + coordinator.seal(AgentLiveSealReason::HostShutdown).await, + Err(AgentLiveCoordinatorError::Sealed( + AgentLiveSealReason::OwnerChanged + )) + )); + } +} diff --git a/frontend/src-tauri/src/agent_live_host.rs b/frontend/src-tauri/src/agent_live_host.rs new file mode 100644 index 000000000..275ea9a43 --- /dev/null +++ b/frontend/src-tauri/src/agent_live_host.rs @@ -0,0 +1,2605 @@ +//! Common host composition for native Agent history paging and synchronized +//! live delivery. +//! +//! This module is an internal native composition seam. It does not by itself +//! claim or expose a remote streaming protocol. In particular, there is no +//! global event sink and no request-supplied execution target: a coordinator +//! is created only from a currently revalidated [`AgentLiveBindingLease`]. + +#![allow( + dead_code, + reason = "the common live host remains fail-closed until its native adapters are complete" +)] + +#[cfg(test)] +use crate::remote_transport::InstalledAuthorizationContext; +use crate::{ + agent::{AgentHistoryPage, AgentHistoryPageRequest, AgentLiveEventCursor, AgentPagingError}, + agent_event_journal::{ + prepare_live_event_journal_parent, LiveEventAccountOwner, LiveEventCursor, + LiveEventJournal, LiveEventJournalActivationError, LiveEventJournalError, + LiveEventJournalReseedObligation, LiveEventJournalReseedRequired, + LiveEventJournalRetirementToken, DEFAULT_LIVE_EVENT_JOURNAL_LIMITS, + }, + agent_live_authority::{ + AgentDurableHeadCommitReceipt, AgentDurableStableOperationId, AgentLiveDataOwnerKey, + VerifiedJournalReseedAuthority, + }, + agent_live_binding::{ + AgentLiveBindOutcome, AgentLiveBindingError, AgentLiveBindingLease, + AgentLiveBindingRegistry, AgentLiveRotationObligation, VerifiedAgentTargetBinding, + }, + agent_live_coordinator::{ + target_bound_owner, AgentLiveCoordinator, AgentLiveCoordinatorError, AgentLiveDelivery, + AgentLiveIngressLease, AgentLivePublishEvent, AgentLiveReceiveError, AgentLiveSeal, + AgentLiveSealReason, AgentLiveSubscription, IngressEventId, MapleLiveEvent, MapleLiveMerge, + MapleLiveTimelineItem, + }, + remote_protocol::MAX_HISTORY_RECORD_PRESENTATION_BYTES, + remote_transport::{AuthorizationTransitionReceipt, VerifiedIncomingPeerAuthorization}, +}; +use std::{ + collections::{HashMap, HashSet}, + fmt, + future::Future, + io::Write, + path::Path, + pin::Pin, + sync::Arc, +}; +use tokio::sync::Mutex; + +const JOURNAL_DIRECTORY_NAME: &str = "journal"; +const MAX_SYNCHRONIZED_HISTORY_RECORDS: usize = 50; +const MAX_SYNCHRONIZED_ITEMS_PER_RECORD: usize = 200; +const MAX_SYNCHRONIZED_HISTORY_TOKEN_BYTES: usize = 512; +const MAX_SYNCHRONIZED_ROLE_BYTES: usize = 128; +const MAX_SYNCHRONIZED_SESSION_ID_BYTES: usize = 128; +const MAX_SYNCHRONIZED_LIVE_SESSIONS: usize = 64; +const MAX_SYNCHRONIZED_LIVE_ITEMS_PER_SESSION: usize = 200; +const MAX_SYNCHRONIZED_LIVE_ITEMS_PER_ACCOUNT: usize = 512; +const MAX_JAVASCRIPT_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +/// Narrow adapter over Maple's account-and-generation-bound runtime handle. +/// +/// The production implementation belongs beside `AgentRuntimeHandle`, where +/// its private account scope and generation can be read without accepting +/// identity claims from a Tauri command or RPC envelope. Implementations must +/// call the runtime handle's common `list_session_records_page` method. +pub(crate) trait AgentHistoryPageProvider: Send + Sync + 'static { + fn account_scope(&self) -> &str; + + fn account_generation(&self) -> u64; + + fn list_session_records_page( + &self, + request: AgentHistoryPageRequest, + ) -> Pin> + Send + '_>>; +} + +/// Exact authenticated peer context added only for synchronized operations. +/// Plain persisted-history paging remains independent of this binding. +/// +/// Implementations are security-sensitive native adapters. Every identity +/// method must describe the same account-bound runtime and authenticated +/// connection; none may be populated from renderer or RPC scalar fields. The +/// generation fence must be the actual lifecycle barrier also taken by +/// `clear_data`/`clear_history`, and must prevent generation advancement for +/// its full lifetime. +pub(crate) trait AgentLiveAttachProvider: AgentHistoryPageProvider { + type RuntimeGenerationFence: Send + 'static; + + /// Exact authenticated Iroh peer. It is captured by the native connection + /// adapter, never accepted from a renderer/RPC field. + fn controller_endpoint(&self) -> iroh::EndpointId; + + /// Re-run the endpoint's native current-peer verifier and return its opaque + /// capability. The capability constructor itself rechecks the installed + /// admission record; callers cannot reproduce it from copied scalar data. + fn reverify_current_binding( + &self, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + >; + + /// Acquire the same runtime lifecycle fence used by generation-changing + /// clear/reset operations. Host mutation methods acquire this before the + /// host lifecycle lock and retain it through the durable coordinator call. + fn acquire_runtime_generation_fence( + &self, + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + >; + + fn verify_current_generation_under_fence<'a>( + &'a self, + fence: &'a Self::RuntimeGenerationFence, + ) -> Pin> + Send + 'a>>; +} + +/// Trusted targeted-revocation hook implemented by the composed Tauri attach +/// manager. Registry revocation happens first; this hook then immediately +/// drops the exact peer's pending/active channel without inventing an ambient +/// owner or a second manager inside the host. +#[async_trait::async_trait] +pub(crate) trait AgentLivePeerRevocationHook: Send + Sync { + async fn revoke_exact_peer( + &self, + revoked: &AgentLiveBindingLease, + ) -> Result<(), AgentLiveHostError>; +} + +/// Reviewed native projection boundary for synchronized attachment state. +/// +/// The coordinator payload is already a closed, bounded Maple type. This last +/// projection converts its absolute head rows and persisted Goose page into +/// closed presentation types, and converts each delivery into the consumer's +/// internal event type. No raw [`AgentHistoryPage`] crosses the synchronized +/// attachment boundary. +pub(crate) trait AgentLiveDeliveryProjector: Send + Sync + 'static { + type Delivery: Send + 'static; + type Error: Send + 'static; + + /// Consume the rich local page and return only the reviewed, bounded + /// presentation contract. Implementations must independently enforce the + /// requested native-row limit and reject arbitrary tool input/output. + fn project_history_page( + &self, + page: AgentHistoryPage, + requested_limit: Option, + ) -> Result; + + fn project_head_items( + &self, + items: &[MapleLiveTimelineItem], + ) -> Result, Self::Error>; + + fn project_delivery(&self, delivery: &AgentLiveDelivery) + -> Result; +} + +/// Closed synchronized history row. Rich tool input/output fields are absent +/// by construction; the projector can only return the reviewed live item +/// contract that this host validates again before disclosure. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub(crate) struct AgentLiveSafeHistoryRecord { + pub(crate) record_id: String, + pub(crate) role: String, + pub(crate) created_ms: u64, + pub(crate) items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub(crate) struct AgentLiveSafeHistoryPage { + pub(crate) records: Vec, + pub(crate) next_cursor: Option, + pub(crate) history_revision: String, +} + +#[derive(Debug)] +pub(crate) enum AgentLiveHostError { + Binding(AgentLiveBindingError), + Paging(AgentPagingError), + Coordinator(AgentLiveCoordinatorError), + Journal(LiveEventJournalError), + JournalReseedRequired(LiveEventJournalReseedRequired), + RuntimeOwnerMismatch, + OrdinaryPageContainedLiveState, + SynchronizedPageProjectionRejected, + SynchronizedHistoryRecordTooLarge, + HeadAttachRequiresNewestPage, + BoundContextSealed, + BoundContextRevoked, + RotationMustBeSealed, + RotationMustBeDurable, + RotationAlreadyDurable, + RotationUnavailable, + AuthorizationCleanupPending, + NonAdjacentAccountGeneration, + JournalWorkerUnavailable, + ReseedContextMustBeClosed, +} + +impl fmt::Display for AgentLiveHostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Binding(error) => error.fmt(formatter), + Self::Paging(error) => error.fmt(formatter), + Self::Coordinator(error) => error.fmt(formatter), + Self::Journal(error) => error.fmt(formatter), + Self::JournalReseedRequired(_) => { + formatter.write_str("the Agent live journal requires a verified durable reseed") + } + Self::RuntimeOwnerMismatch => { + formatter.write_str("Agent runtime belongs to another live binding") + } + Self::OrdinaryPageContainedLiveState => formatter + .write_str("ordinary Agent history pages must not contain synchronized live state"), + Self::SynchronizedPageProjectionRejected => { + formatter.write_str("synchronized Agent history projection is invalid") + } + Self::SynchronizedHistoryRecordTooLarge => formatter + .write_str("one synchronized Agent history record is too large to display safely"), + Self::HeadAttachRequiresNewestPage => { + formatter.write_str("synchronized Agent attach requires the newest history page") + } + Self::BoundContextSealed => { + formatter.write_str("the bound Agent live context is sealed") + } + Self::BoundContextRevoked => { + formatter.write_str("the bound Agent live context is revoked") + } + Self::RotationMustBeSealed => formatter.write_str( + "the previous Agent live context must be sealed before journal rotation", + ), + Self::RotationMustBeDurable => formatter + .write_str("the Agent live journal must be durably rotated before binding commit"), + Self::RotationAlreadyDurable => { + formatter.write_str("the Agent live journal rotation is already durable") + } + Self::RotationUnavailable => { + formatter.write_str("no recoverable Agent live binding rotation is pending") + } + Self::AuthorizationCleanupPending => formatter.write_str( + "an earlier Agent authorization transition still requires exact cleanup", + ), + Self::NonAdjacentAccountGeneration => formatter + .write_str("Agent live journal rotation requires an adjacent account generation"), + Self::JournalWorkerUnavailable => { + formatter.write_str("the Agent live journal worker is unavailable") + } + Self::ReseedContextMustBeClosed => { + formatter.write_str("the Agent live context must be closed before journal reseed") + } + } + } +} + +impl std::error::Error for AgentLiveHostError {} + +impl From for AgentLiveHostError { + fn from(error: AgentLiveBindingError) -> Self { + Self::Binding(error) + } +} + +impl From for AgentLiveHostError { + fn from(error: AgentPagingError) -> Self { + Self::Paging(error) + } +} + +impl From for AgentLiveHostError { + fn from(error: AgentLiveCoordinatorError) -> Self { + match error { + AgentLiveCoordinatorError::ReseedRequired(required) => { + Self::JournalReseedRequired(required) + } + error => Self::Coordinator(error), + } + } +} + +impl From for AgentLiveHostError { + fn from(error: LiveEventJournalError) -> Self { + Self::Journal(error) + } +} + +#[derive(Debug)] +pub(crate) enum AgentLiveAttachError { + Host(AgentLiveHostError), + Projection(E), +} + +impl From for AgentLiveAttachError { + fn from(error: AgentLiveHostError) -> Self { + Self::Host(error) + } +} + +#[derive(Debug)] +pub(crate) enum AgentLiveStreamError { + Host(AgentLiveHostError), + Receive(AgentLiveReceiveError), + Projection(E), +} + +type BoundContextKey = AgentLiveDataOwnerKey; + +enum BoundContextSlot { + Active(ActiveBoundContext), + /// Installed before awaiting the FIFO seal. Cancellation leaves this exact + /// context retryable but non-recreatable and every lookup fails closed. + Sealing { + active: ActiveBoundContext, + reason: AgentLiveSealReason, + revoked: bool, + }, + Sealed(AgentLiveSeal), + /// The same stable journal key was atomically advanced to the next data + /// generation. This old lease must never enter the retirement protocol. + Superseded(AgentLiveSeal), + Retiring { + token: LiveEventJournalRetirementToken, + sealed: AgentLiveSeal, + revoked: bool, + }, + Retired { + sealed: AgentLiveSeal, + revoked: bool, + }, + Revoked(Option), +} + +#[derive(Clone)] +struct ActiveBoundContext { + coordinator: AgentLiveCoordinator, +} + +/// Exact producer admission for one bound owner and one session/run route. +/// +/// This capability is deliberately obtained separately from publication. +/// Rollover invalidates its hidden ingress generation, and `publish` never +/// looks up or substitutes a newer capability on the caller's behalf. +#[derive(Clone)] +pub(crate) struct AgentLiveIngressPublisher { + binding: AgentLiveBindingLease, + ingress: AgentLiveIngressLease, +} + +impl AgentLiveIngressPublisher { + pub(crate) fn session_id(&self) -> &str { + self.ingress.session_id() + } + + pub(crate) fn run_id(&self) -> Option<&str> { + self.ingress.run_id() + } + + /// Derive a typed event ID only from the native durable-operation proof. + /// Owner, route, projection schema, journal namespace, and payload + /// commitment are rechecked by the ingress capability. + pub(crate) fn event_id( + &self, + stable_operation: &AgentDurableStableOperationId, + ) -> Result { + self.ingress.event_id(stable_operation).map_err(Into::into) + } +} + +#[derive(Clone)] +struct PendingAccountRetirement { + /// Exact committed lease retained so an impossible owner-derivation error + /// remains fail-closed and diagnosable rather than losing recovery state. + lease: Option, + /// Data-lineage key is used only to join an already materialized context. + /// The journal owner below remains the sole disk authority. + key: Option, + owner: Option, +} + +#[derive(Clone)] +pub(crate) struct AgentLiveHost { + bindings: AgentLiveBindingRegistry, + journal: LiveEventJournal, + contexts: Arc>>, + /// Host-owned recovery copy. A cancelled edge call may drop its local + /// handle, but cannot make a binding Transition permanently unreachable. + pending_rotation: Arc>>, + /// Exact committed data owner captured from a binding transition. This is + /// independent of lazy coordinator materialization, so a never-attached + /// account journal is still retired on an account-epoch change. + pending_account_retirement: Arc>>, + /// Exact leases already revoked by a consumed endpoint transition receipt + /// but not yet acknowledged closed by the composed delivery manager. + pending_peer_revocations: Arc>>, + /// Serializes binding transitions with coordinator creation, publication, + /// head attachment, sealing, and revocation. A subscription never holds + /// this lock while waiting for its next delivery. + lifecycle: Arc>, +} + +impl AgentLiveHost { + /// Open the single process-wide journal beneath a dedicated owner-only + /// parent. Callers should pass an app-local-data child such as + /// `app_local_data/agent-live`, not the broad app-local-data directory. + pub(crate) fn open(journal_parent: &Path) -> Result { + prepare_live_event_journal_parent(journal_parent)?; + let journal = LiveEventJournal::open( + journal_parent.join(JOURNAL_DIRECTORY_NAME), + DEFAULT_LIVE_EVENT_JOURNAL_LIMITS, + )?; + Ok(Self::from_journal(journal)) + } + + fn from_journal(journal: LiveEventJournal) -> Self { + Self { + bindings: AgentLiveBindingRegistry::new(), + journal, + contexts: Arc::new(Mutex::new(HashMap::new())), + pending_rotation: Arc::new(Mutex::new(None)), + pending_account_retirement: Arc::new(Mutex::new(None)), + pending_peer_revocations: Arc::new(Mutex::new(Vec::new())), + lifecycle: Arc::new(Mutex::new(())), + } + } + + /// Consume only the capability minted by the verified native endpoint + /// adapter. + /// A target change or account-generation change returns an obligation and + /// leaves all synchronized operations fail-closed until it is completed. + pub(crate) async fn bind_verified( + &self, + verified: VerifiedAgentTargetBinding, + ) -> Result { + let _lifecycle = self.lifecycle.lock().await; + if self.pending_account_retirement.lock().await.is_some() + || !self.pending_peer_revocations.lock().await.is_empty() + { + return Err(AgentLiveHostError::AuthorizationCleanupPending); + } + // Acquire before the registry mutation. Once bind_or_refresh returns a + // Transition there is no further await before its recovery obligation + // is stored, so cancellation cannot make the Transition unreachable. + let mut pending_rotation = self.pending_rotation.lock().await; + match self.bindings.bind_or_refresh(verified).await? { + AgentLiveBindOutcome::Bound(lease) => Ok(AgentLiveHostBindOutcome::Bound(lease)), + AgentLiveBindOutcome::RotationRequired(obligation) => { + *pending_rotation = Some(obligation.clone()); + Ok(AgentLiveHostBindOutcome::RotationRequired( + AgentLiveHostRotation { + obligation, + sealed: None, + retirement: None, + journal_rotated: false, + }, + )) + } + } + } + + /// Plain storage paging deliberately remains available without a live + /// binding. The provider's common host method must return `None/None` for + /// the live overlay and event cursor on every ordinary page. + pub(crate) async fn ordinary_history_page

( + &self, + provider: &P, + request: AgentHistoryPageRequest, + ) -> Result + where + P: AgentHistoryPageProvider, + { + let page = provider.list_session_records_page(request).await?; + if page.live_items.is_some() || page.through_event_cursor.is_some() { + return Err(AgentLiveHostError::OrdinaryPageContainedLiveState); + } + Ok(page) + } + + /// Construct an account-bound manager. No target parameter is accepted; + /// the exact target is recovered from the registry's current lease. + pub(crate) async fn attach_manager( + &self, + provider: P, + projector: D, + ) -> Result, AgentLiveHostError> + where + P: AgentLiveAttachProvider, + D: AgentLiveDeliveryProjector, + { + let runtime_fence = provider.acquire_runtime_generation_fence().await?; + let _lifecycle = self.lifecycle.lock().await; + let lease = self + .require_provider_lease_under_fence(&provider, &runtime_fence) + .await?; + self.ensure_context_not_closed(&lease).await?; + let _ = &runtime_fence; + Ok(AgentLiveAttachManager { + host: self.clone(), + provider: Arc::new(provider), + projector: Arc::new(projector), + lease, + }) + } + + /// Explicitly admit one producer for an exact session/run route. + /// + /// This is called when the native runtime starts or deliberately rebinds a + /// producer. It is never called from `publish`, so journal rollover cannot + /// silently move an existing producer onto a new ingress generation. + pub(crate) async fn begin_ingress

( + &self, + provider: &P, + session_id: impl Into, + run_id: Option, + ) -> Result + where + P: AgentLiveAttachProvider, + { + let runtime_fence = provider.acquire_runtime_generation_fence().await?; + provider + .verify_current_generation_under_fence(&runtime_fence) + .await?; + let _lifecycle = self.lifecycle.lock().await; + let lease = self + .require_provider_lease_under_fence(provider, &runtime_fence) + .await?; + let coordinator = self.coordinator_for_lease(&lease).await?; + let ingress = coordinator.begin_ingress(session_id, run_id).await?; + let _ = &runtime_fence; + Ok(AgentLiveIngressPublisher { + binding: lease, + ingress, + }) + } + + /// Publish one already-projected, typed event through the exact producer. + /// This is the only intended replacement for a global event sink. + pub(crate) async fn publish

( + &self, + provider: &P, + publisher: &AgentLiveIngressPublisher, + event: AgentLivePublishEvent, + ) -> Result + where + P: AgentLiveAttachProvider, + { + // Runtime generation changes take this fence before entering host + // lifecycle hooks. Preserve that lock order to avoid inversion. + let runtime_fence = provider.acquire_runtime_generation_fence().await?; + provider + .verify_current_generation_under_fence(&runtime_fence) + .await?; + let _lifecycle = self.lifecycle.lock().await; + let lease = self + .require_provider_lease_under_fence(provider, &runtime_fence) + .await?; + if lease != publisher.binding { + return Err(AgentLiveHostError::RuntimeOwnerMismatch); + } + let coordinator = self.coordinator_for_lease(&lease).await?; + // Publication and the FIFO seal command share this lifecycle ordering. + // If this append is admitted before a rotation, its durable success + // remains success instead of becoming an ambiguous post-commit stale + // binding error. + let cursor = coordinator.publish(&publisher.ingress, event).await?; + // The runtime fence makes this postcondition non-racy: generation + // cannot advance between admission and returning its cursor. Do not + // introduce a fallible post-commit check that could turn durable + // success into an ambiguous error. + let _ = (&runtime_fence, &lease); + Ok(api_cursor(&cursor)) + } + + /// Retire a session's absolute live suffix only from an opaque receipt + /// verified by the provider that performed the exact durable Goose write. + /// + /// This deliberately does not accept a loose session/revision/cursor + /// tuple. Callers without a reviewed `AgentDurableHeadCommitReceipt` + /// cannot reach the coordinator acknowledgement API. + pub(crate) async fn acknowledge_persisted_head

( + &self, + provider: &P, + receipt: &AgentDurableHeadCommitReceipt, + ) -> Result + where + P: AgentLiveAttachProvider, + { + let runtime_fence = provider.acquire_runtime_generation_fence().await?; + provider + .verify_current_generation_under_fence(&runtime_fence) + .await?; + let through_event_cursor = receipt.through_event_cursor(); + let through_event_cursor = LiveEventCursor::try_from_parts( + through_event_cursor.journal_id.clone(), + through_event_cursor.sequence, + )?; + + let _lifecycle = self.lifecycle.lock().await; + let lease = self + .require_provider_lease_under_fence(provider, &runtime_fence) + .await?; + let coordinator = self.coordinator_for_lease(&lease).await?; + let cursor = Self::acknowledge_persisted_head_on_coordinator( + &coordinator, + &BoundContextKey::from_binding_lease(&lease), + receipt, + through_event_cursor, + ) + .await?; + // As above, retaining the runtime fence is the atomic guarantee. The + // coordinator independently CAS-checks the exact current head cursor. + let _ = (&runtime_fence, &lease); + Ok(api_cursor(&cursor)) + } + + async fn acknowledge_persisted_head_on_coordinator( + coordinator: &AgentLiveCoordinator, + expected_owner: &BoundContextKey, + receipt: &AgentDurableHeadCommitReceipt, + through_event_cursor: LiveEventCursor, + ) -> Result { + let stable_operation = receipt.stable_operation(); + if stable_operation.owner() != expected_owner || stable_operation.run_id().is_some() { + return Err(AgentLiveHostError::RuntimeOwnerMismatch); + } + // Persisted-head acknowledgement is itself an explicit producer + // lifecycle. The receipt is bound to this journal namespace; a delayed + // pre-rollover receipt fails at event-ID derivation and remains + // available to the caller for deterministic recovery. + let ingress = coordinator + .begin_ingress(stable_operation.session_id().to_string(), None) + .await?; + let event_id = ingress.event_id(stable_operation)?; + coordinator + .acknowledge_persisted_head( + &ingress, + event_id, + receipt.history_revision().to_string(), + through_event_cursor, + ) + .await + .map_err(Into::into) + } + + /// Seal a runtime's coordinator and prevent accidental recreation for the + /// same binding during this host lifetime. + pub(crate) async fn seal

( + &self, + provider: &P, + reason: AgentLiveSealReason, + ) -> Result<(), AgentLiveHostError> + where + P: AgentLiveAttachProvider, + { + let runtime_fence = provider.acquire_runtime_generation_fence().await?; + let _lifecycle = self.lifecycle.lock().await; + let lease = self + .require_provider_lease_under_fence(provider, &runtime_fence) + .await?; + let result = self.close_context(&lease, false, reason).await.map(|_| ()); + let _ = &runtime_fence; + result + } + + /// Consume the endpoint's one-use authorization swap receipt. Exact peer + /// channels are closed continuously while the host lifecycle is held. An + /// account-epoch change also FIFO-seals and durably retires every retained + /// account context before returning. + pub(crate) async fn apply_authorization_transition( + &self, + receipt: AuthorizationTransitionReceipt, + hook: Arc, + ) -> Result<(), AgentLiveHostError> + where + H: AgentLivePeerRevocationHook + 'static, + { + // Own the guard so the one-use receipt can be handed atomically to a + // task which survives cancellation of this request future. + let lifecycle = Arc::clone(&self.lifecycle).lock_owned().await; + // Acquire every host recovery slot before consuming the receipt. Once + // the registry mutates, recording exact cleanup state and spawning its + // owned task introduce no further cancellation point in this poll. + let mut pending_account = self.pending_account_retirement.lock().await; + let mut pending_peers = self.pending_peer_revocations.lock().await; + let applied = self + .bindings + .apply_authorization_transition(receipt) + .await?; + let committed_owner = applied + .revoked_peers() + .first() + .map(|revoked| revoked.lease.clone()); + for revoked in applied.revoked_peers() { + if !pending_peers.contains(&revoked.lease) { + pending_peers.push(revoked.lease.clone()); + } + } + if applied.account_epoch_changed() { + let key = committed_owner + .as_ref() + .map(BoundContextKey::from_binding_lease); + let owner = committed_owner.as_ref().and_then(|lease| { + target_bound_owner( + lease.account_scope(), + lease.account_generation(), + lease.execution_target().as_str(), + ) + .ok() + }); + // Installed without awaiting so cancellation after the consumed + // receipt cannot lose the exact old data owner. `owner: None` is a + // permanent fail-closed missing/derivation failure, never a reason + // to bind. This also covers an epoch transition observed before a + // live binding was ever materialized. + *pending_account = Some(PendingAccountRetirement { + lease: committed_owner, + key, + owner, + }); + } + drop(pending_peers); + drop(pending_account); + + let host = self.clone(); + let cleanup = tokio::spawn(async move { + let _lifecycle = lifecycle; + host.finish_pending_authorization_cleanup(hook.as_ref()) + .await + }); + cleanup + .await + .map_err(|_| AgentLiveHostError::JournalWorkerUnavailable)? + } + + /// Resume only cleanup already authorized by a consumed transition receipt. + /// Exact revoked leases and any account-retirement marker are host-owned; + /// no account, endpoint, or target scalar is accepted. The owned task keeps + /// making progress if its caller is cancelled. + pub(crate) async fn resume_pending_authorization_cleanup( + &self, + hook: Arc, + ) -> Result<(), AgentLiveHostError> + where + H: AgentLivePeerRevocationHook + 'static, + { + let lifecycle = Arc::clone(&self.lifecycle).lock_owned().await; + let host = self.clone(); + let cleanup = tokio::spawn(async move { + let _lifecycle = lifecycle; + host.finish_pending_authorization_cleanup(hook.as_ref()) + .await + }); + cleanup + .await + .map_err(|_| AgentLiveHostError::JournalWorkerUnavailable)? + } + + #[cfg(test)] + pub(crate) async fn revoke_peer( + &self, + controller_endpoint: iroh::EndpointId, + installed: &InstalledAuthorizationContext, + hook: &H, + ) -> Result + where + H: AgentLivePeerRevocationHook, + { + let _lifecycle = self.lifecycle.lock().await; + let revoked = self + .bindings + .revoke_peer(controller_endpoint, installed) + .await?; + if let Some(revoked) = revoked { + hook.revoke_exact_peer(&revoked.lease).await?; + Ok(true) + } else { + Ok(false) + } + } + + #[cfg(test)] + pub(crate) async fn revoke_account( + &self, + installed: &InstalledAuthorizationContext, + reason: AgentLiveSealReason, + ) -> Result<(), AgentLiveHostError> { + let _lifecycle = self.lifecycle.lock().await; + self.bindings.revoke_account(installed).await?; + self.close_all_contexts(reason).await + } + + /// FIFO-seal the previous owner named by an exact rotation obligation. + pub(crate) async fn seal_rotation( + &self, + rotation: &mut AgentLiveHostRotation, + ) -> Result<(), AgentLiveHostError> { + let _lifecycle = self.lifecycle.lock().await; + self.require_pending_rotation(&rotation.obligation).await?; + self.bindings.abort_rotation(&rotation.obligation).await?; + if rotation.sealed.is_some() { + return Ok(()); + } + let sealed = self + .close_context( + rotation.obligation.previous(), + false, + AgentLiveSealReason::OwnerChanged, + ) + .await? + .ok_or(AgentLiveHostError::BoundContextSealed)?; + rotation.sealed = Some(sealed); + Ok(()) + } + + /// Complete the durable half of an owner transition. Adjacent generation + /// changes on the same stable target rotate directly from the FIFO-sealed + /// lease. Target changes have a different stable key, so they retire the + /// old journal before the new binding may activate its own owner. + pub(crate) async fn rotate_journal( + &self, + rotation: &mut AgentLiveHostRotation, + ) -> Result { + let _lifecycle = self.lifecycle.lock().await; + self.require_pending_rotation(&rotation.obligation).await?; + self.bindings.abort_rotation(&rotation.obligation).await?; + let sealed = rotation + .sealed + .clone() + .ok_or(AgentLiveHostError::RotationMustBeSealed)?; + if rotation.journal_rotated { + return Err(AgentLiveHostError::RotationAlreadyDurable); + } + let previous = rotation.obligation.previous(); + let proposed = rotation.obligation.proposed(); + let cursor = if previous.execution_target() == proposed.execution_target() { + if previous.account_generation().checked_add(1) != Some(proposed.account_generation()) { + return Err(AgentLiveHostError::NonAdjacentAccountGeneration); + } + let current_owner = target_bound_owner( + proposed.account_scope(), + proposed.account_generation(), + proposed.execution_target().as_str(), + )?; + let journal = self.journal.clone(); + let previous_lease = sealed.journal_lease.clone(); + let cursor = tokio::task::spawn_blocking(move || { + match journal.rotate_account_generation(&previous_lease, ¤t_owner) { + Ok(cursor) => Ok(cursor), + Err(rotation_error) => match journal.activate_account(¤t_owner) { + Ok(current_lease) => journal.checkpoint(¤t_lease), + Err(_) => Err(rotation_error), + }, + } + }) + .await + .map_err(|_| AgentLiveHostError::JournalWorkerUnavailable)??; + let previous_key = BoundContextKey::from_binding_lease(previous); + let mut contexts = self.contexts.lock().await; + match contexts.remove(&previous_key) { + Some(BoundContextSlot::Sealed(existing)) if existing == sealed => { + contexts.insert(previous_key, BoundContextSlot::Superseded(existing)); + } + Some(BoundContextSlot::Superseded(existing)) if existing == sealed => { + contexts.insert(previous_key, BoundContextSlot::Superseded(existing)); + } + existing => { + if let Some(existing) = existing { + contexts.insert(previous_key, existing); + } + return Err(AgentLiveHostError::BoundContextSealed); + } + } + cursor + } else { + let previous_key = BoundContextKey::from_binding_lease(previous); + // Acquire the slot before minting the one-use retirement token. + // From `seal_for_retirement` through installing `Retiring` below + // there is then no cancellation point which could lose the token. + let mut contexts = self.contexts.lock().await; + let token = match rotation.retirement.as_ref() { + Some(token) => token.clone(), + None => { + let token = self + .journal + .seal_for_retirement(&sealed.journal_lease, &sealed.through_cursor)?; + rotation.retirement = Some(token.clone()); + token + } + }; + match contexts.remove(&previous_key) { + Some(BoundContextSlot::Sealed(existing)) if existing == sealed => { + contexts.insert( + previous_key.clone(), + BoundContextSlot::Retiring { + token: token.clone(), + sealed: sealed.clone(), + revoked: false, + }, + ); + } + Some(BoundContextSlot::Retiring { + token: existing_token, + sealed: existing_seal, + revoked, + }) if existing_token == token && existing_seal == sealed => { + contexts.insert( + previous_key.clone(), + BoundContextSlot::Retiring { + token: existing_token, + sealed: existing_seal, + revoked, + }, + ); + } + Some(BoundContextSlot::Retired { + sealed: existing_seal, + revoked, + }) if existing_seal == sealed => { + contexts.insert( + previous_key, + BoundContextSlot::Retired { + sealed: existing_seal, + revoked, + }, + ); + rotation.journal_rotated = true; + return Ok(api_cursor(&sealed.through_cursor)); + } + existing => { + if let Some(existing) = existing { + contexts.insert(previous_key, existing); + } + return Err(AgentLiveHostError::BoundContextSealed); + } + } + drop(contexts); + let journal = self.journal.clone(); + let retirement_result = + tokio::task::spawn_blocking(move || journal.retire_account(&token)) + .await + .map_err(|_| AgentLiveHostError::JournalWorkerUnavailable)?; + match retirement_result { + Ok(()) | Err(LiveEventJournalError::JournalRetired) => {} + Err(error) => return Err(error.into()), + } + self.contexts.lock().await.insert( + previous_key, + BoundContextSlot::Retired { + sealed: sealed.clone(), + revoked: false, + }, + ); + sealed.through_cursor.clone() + }; + rotation.journal_rotated = true; + Ok(api_cursor(&cursor)) + } + + /// Commit only a rotation whose old context was sealed and whose journal + /// replacement was proven durable. + pub(crate) async fn commit_rotation

( + &self, + rotation: &mut AgentLiveHostRotation, + proposed_provider: &P, + ) -> Result + where + P: AgentLiveAttachProvider, + { + let runtime_fence = proposed_provider.acquire_runtime_generation_fence().await?; + proposed_provider + .verify_current_generation_under_fence(&runtime_fence) + .await?; + let _lifecycle = self.lifecycle.lock().await; + // Keep the recovery copy locked across the registry commit and clear; + // once commit succeeds there is no await before stale recovery state is + // removed. + let mut pending_rotation = self.pending_rotation.lock().await; + if pending_rotation.as_ref() != Some(&rotation.obligation) { + return Err(AgentLiveHostError::RotationUnavailable); + } + if rotation.sealed.is_none() { + return Err(AgentLiveHostError::RotationMustBeSealed); + } + if !rotation.journal_rotated { + return Err(AgentLiveHostError::RotationMustBeDurable); + } + let proposed = rotation.obligation.proposed(); + if proposed.account_scope() != proposed_provider.account_scope() + || proposed.account_generation() != proposed_provider.account_generation() + || proposed.controller_endpoint() != proposed_provider.controller_endpoint() + { + return Err(AgentLiveHostError::RuntimeOwnerMismatch); + } + // Capture this opaque capability immediately before activation. Its + // constructor and the registry both revalidate the endpoint's current + // admission record; no scalar field or renderer value can substitute. + let reverified = proposed_provider.reverify_current_binding().await?; + let lease = self + .bindings + .commit_rotation(rotation.obligation.clone(), reverified) + .await?; + *pending_rotation = None; + let _ = runtime_fence; + // The new data-lineage epoch is part of the context key. A -> B -> A + // therefore cannot inherit either A's old coordinator or tombstone. + Ok(lease) + } + + /// Validate an obligation while deliberately leaving the registry in its + /// fail-closed transition state. This never resurrects the previous lease. + pub(crate) async fn abort_rotation( + &self, + rotation: &AgentLiveHostRotation, + ) -> Result<(), AgentLiveHostError> { + let _lifecycle = self.lifecycle.lock().await; + self.require_pending_rotation(&rotation.obligation).await?; + self.bindings.abort_rotation(&rotation.obligation).await?; + Ok(()) + } + + /// Recover the exact process-local rotation after an edge future or handle + /// was dropped. Durable progress is reconstructed only from fail-closed + /// host slots; no caller-provided owner or target participates. + pub(crate) async fn resume_rotation( + &self, + ) -> Result { + let _lifecycle = self.lifecycle.lock().await; + let obligation = self + .pending_rotation + .lock() + .await + .clone() + .ok_or(AgentLiveHostError::RotationUnavailable)?; + self.bindings.abort_rotation(&obligation).await?; + let key = BoundContextKey::from_binding_lease(obligation.previous()); + let (sealed, retirement, journal_rotated) = match self.contexts.lock().await.get(&key) { + Some(BoundContextSlot::Active(_) | BoundContextSlot::Sealing { .. }) | None => { + (None, None, false) + } + Some(BoundContextSlot::Sealed(sealed)) => (Some(sealed.clone()), None, false), + Some(BoundContextSlot::Superseded(sealed)) => (Some(sealed.clone()), None, true), + Some(BoundContextSlot::Retiring { token, sealed, .. }) => { + (Some(sealed.clone()), Some(token.clone()), false) + } + Some(BoundContextSlot::Retired { sealed, .. }) => (Some(sealed.clone()), None, true), + Some(BoundContextSlot::Revoked(_)) => { + return Err(AgentLiveHostError::BoundContextRevoked); + } + }; + Ok(AgentLiveHostRotation { + obligation, + sealed, + retirement, + journal_rotated, + }) + } + + /// Prepare a reseed only from the future Goose adapter's unforgeable + /// durable-head authority. The returned obligation remains unusable until + /// `seal_reseed` closes the exact in-process context and subscribers. + pub(crate) async fn prepare_reseed( + &self, + required: LiveEventJournalReseedRequired, + authority: VerifiedJournalReseedAuthority, + ) -> Result { + let _lifecycle = self.lifecycle.lock().await; + let owner_key = authority.binding_key().clone(); + if self.contexts.lock().await.contains_key(&owner_key) { + return Err(AgentLiveHostError::ReseedContextMustBeClosed); + } + let obligation = self.journal.prepare_reseed(required, authority)?; + Ok(AgentLiveHostReseed { + owner_key, + obligation, + sealed: false, + }) + } + + /// The corrupt generation has no activatable coordinator. This barrier + /// nevertheless proves under the host lifecycle that no exact context or + /// subscriber remains before the journal marks the obligation sealed. + pub(crate) async fn seal_reseed( + &self, + reseed: &mut AgentLiveHostReseed, + ) -> Result<(), AgentLiveHostError> { + let _lifecycle = self.lifecycle.lock().await; + if self.contexts.lock().await.contains_key(&reseed.owner_key) { + return Err(AgentLiveHostError::ReseedContextMustBeClosed); + } + self.journal.mark_reseed_sealed(&mut reseed.obligation)?; + reseed.sealed = true; + Ok(()) + } + + /// Durably commit the exact sealed reseed. The obligation is retained by + /// the caller on an ambiguous storage error and can be retried verbatim. + pub(crate) async fn commit_reseed( + &self, + reseed: &AgentLiveHostReseed, + ) -> Result { + let _lifecycle = self.lifecycle.lock().await; + if !reseed.sealed || self.contexts.lock().await.contains_key(&reseed.owner_key) { + return Err(AgentLiveHostError::ReseedContextMustBeClosed); + } + let activation = self.journal.commit_reseed(&reseed.obligation)?; + let (_lease, cursor) = activation.into_parts(); + // Deliberately do not install the returned lease without a freshly + // revalidated binding/provider. The next synchronized operation will + // activate and bind a new coordinator through the normal lifecycle. + Ok(api_cursor(&cursor)) + } + + /// Runs only while an owned host lifecycle guard is held. Every successful + /// peer ACK is removed individually; failures remain exact and retryable. + /// Account retirement still runs after a peer-hook error so one broken edge + /// cannot prevent the durable confidentiality fence. + async fn finish_pending_authorization_cleanup( + &self, + hook: &H, + ) -> Result<(), AgentLiveHostError> + where + H: AgentLivePeerRevocationHook, + { + // An authorization swap may have converted Transition to Fenced. Keep + // a recovery obligation only if the registry still recognizes it. + let pending_rotation = self.pending_rotation.lock().await.clone(); + if let Some(pending_rotation) = pending_rotation { + if self + .bindings + .abort_rotation(&pending_rotation) + .await + .is_err() + { + *self.pending_rotation.lock().await = None; + } + } + + let revoked = self.pending_peer_revocations.lock().await.clone(); + let mut first_error = None; + for lease in revoked { + match hook.revoke_exact_peer(&lease).await { + Ok(()) => { + self.pending_peer_revocations + .lock() + .await + .retain(|pending| pending != &lease); + } + Err(error) => { + first_error.get_or_insert(error); + } + } + } + + if let Some(pending) = self.pending_account_retirement.lock().await.clone() { + let _ = (&pending.lease, &pending.owner); + let materialized_key = match pending.key.as_ref() { + Some(key) if self.contexts.lock().await.contains_key(key) => Some(key), + _ => None, + }; + if let Some(key) = materialized_key { + match self + .retire_context_key(key, AgentLiveSealReason::AccountSignedOut) + .await + { + Ok(()) => { + *self.pending_account_retirement.lock().await = None; + } + Err(error) => { + first_error.get_or_insert(error); + } + } + } else { + // The binding transition proves the exact old data owner, but + // no coordinator ever activated a journal lease. The journal + // intentionally has no inactive-owner deletion/claim API yet. + // Retain this bounded entry and keep every new bind/sync call + // unavailable until a verified lifecycle retirement primitive + // can consume it. + first_error.get_or_insert(AgentLiveHostError::AuthorizationCleanupPending); + } + } + + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + async fn require_pending_rotation( + &self, + obligation: &AgentLiveRotationObligation, + ) -> Result<(), AgentLiveHostError> { + if self.pending_rotation.lock().await.as_ref() == Some(obligation) { + Ok(()) + } else { + Err(AgentLiveHostError::RotationUnavailable) + } + } + + async fn require_provider_lease_under_fence

( + &self, + provider: &P, + runtime_fence: &P::RuntimeGenerationFence, + ) -> Result + where + P: AgentLiveAttachProvider, + { + let account_scope = provider.account_scope().to_string(); + let account_generation = provider.account_generation(); + let controller_endpoint = provider.controller_endpoint(); + provider + .verify_current_generation_under_fence(runtime_fence) + .await?; + let current = self + .bindings + .require_bound(&account_scope, account_generation, controller_endpoint) + .await + .map_err(AgentLiveHostError::from)?; + // This revalidates the opaque endpoint admission capability retained + // by the exact current lease. Ordinary mutation/disclosure paths must + // never call `bind_or_refresh`: observing a newer owner there would + // enter Transition and discard the resulting rotation obligation. + self.bindings + .revalidate(&account_scope, account_generation, ¤t) + .await?; + provider + .verify_current_generation_under_fence(runtime_fence) + .await?; + if provider.account_scope() != account_scope.as_str() + || provider.account_generation() != account_generation + || provider.controller_endpoint() != controller_endpoint + || current.account_scope() != account_scope.as_str() + || current.account_generation() != account_generation + || current.controller_endpoint() != controller_endpoint + { + return Err(AgentLiveHostError::RuntimeOwnerMismatch); + } + Ok(current) + } + + async fn revalidate_provider_lease_under_fence

( + &self, + provider: &P, + runtime_fence: &P::RuntimeGenerationFence, + lease: &AgentLiveBindingLease, + ) -> Result<(), AgentLiveHostError> + where + P: AgentLiveAttachProvider, + { + let current = self + .require_provider_lease_under_fence(provider, runtime_fence) + .await?; + if current != *lease { + return Err(AgentLiveHostError::RuntimeOwnerMismatch); + } + Ok(()) + } + + async fn ensure_context_not_closed( + &self, + lease: &AgentLiveBindingLease, + ) -> Result<(), AgentLiveHostError> { + match self + .contexts + .lock() + .await + .get(&BoundContextKey::from_binding_lease(lease)) + { + Some( + BoundContextSlot::Sealing { .. } + | BoundContextSlot::Sealed(_) + | BoundContextSlot::Superseded(_) + | BoundContextSlot::Retiring { .. } + | BoundContextSlot::Retired { .. }, + ) => Err(AgentLiveHostError::BoundContextSealed), + Some(BoundContextSlot::Revoked(_)) => Err(AgentLiveHostError::BoundContextRevoked), + Some(BoundContextSlot::Active(_)) | None => Ok(()), + } + } + + async fn coordinator_for_lease( + &self, + lease: &AgentLiveBindingLease, + ) -> Result { + self.bindings + .revalidate(lease.account_scope(), lease.account_generation(), lease) + .await?; + let key = BoundContextKey::from_binding_lease(lease); + { + let contexts = self.contexts.lock().await; + match contexts.get(&key) { + Some(BoundContextSlot::Active(active)) => { + if active.coordinator.execution_target() != lease.execution_target().as_str() { + return Err(AgentLiveHostError::RuntimeOwnerMismatch); + } + return Ok(active.coordinator.clone()); + } + Some( + BoundContextSlot::Sealing { .. } + | BoundContextSlot::Sealed(_) + | BoundContextSlot::Superseded(_) + | BoundContextSlot::Retiring { .. } + | BoundContextSlot::Retired { .. }, + ) => { + return Err(AgentLiveHostError::BoundContextSealed); + } + Some(BoundContextSlot::Revoked(_)) => { + return Err(AgentLiveHostError::BoundContextRevoked); + } + None => {} + } + } + + let owner = target_bound_owner( + lease.account_scope(), + lease.account_generation(), + lease.execution_target().as_str(), + )?; + let journal_lease = match self.journal.activate_account(&owner) { + Ok(lease) => lease, + Err(LiveEventJournalActivationError::Journal(error)) => return Err(error.into()), + Err(LiveEventJournalActivationError::ReseedRequired(required)) => { + return Err(AgentLiveHostError::JournalReseedRequired(required)); + } + }; + let data_owner = BoundContextKey::from_binding_lease(lease); + let coordinator = AgentLiveCoordinator::start_activated( + self.journal.clone(), + journal_lease, + data_owner, + lease.execution_target().as_str().to_string(), + ) + .await?; + self.bindings + .revalidate(lease.account_scope(), lease.account_generation(), lease) + .await?; + let mut contexts = self.contexts.lock().await; + match contexts.get(&key) { + Some( + BoundContextSlot::Sealing { .. } + | BoundContextSlot::Sealed(_) + | BoundContextSlot::Superseded(_) + | BoundContextSlot::Retiring { .. } + | BoundContextSlot::Retired { .. }, + ) => Err(AgentLiveHostError::BoundContextSealed), + Some(BoundContextSlot::Revoked(_)) => Err(AgentLiveHostError::BoundContextRevoked), + Some(BoundContextSlot::Active(existing)) => Ok(existing.coordinator.clone()), + None => { + contexts.insert( + key, + BoundContextSlot::Active(ActiveBoundContext { + coordinator: coordinator.clone(), + }), + ); + Ok(coordinator) + } + } + } + + async fn revalidate_active_lease( + &self, + lease: &AgentLiveBindingLease, + ) -> Result<(), AgentLiveHostError> { + self.bindings + .revalidate(lease.account_scope(), lease.account_generation(), lease) + .await?; + match self + .contexts + .lock() + .await + .get(&BoundContextKey::from_binding_lease(lease)) + { + Some(BoundContextSlot::Active(active)) + if active.coordinator.execution_target() == lease.execution_target().as_str() => + { + Ok(()) + } + Some(BoundContextSlot::Active(_)) => Err(AgentLiveHostError::RuntimeOwnerMismatch), + Some( + BoundContextSlot::Sealing { .. } + | BoundContextSlot::Sealed(_) + | BoundContextSlot::Superseded(_) + | BoundContextSlot::Retiring { .. } + | BoundContextSlot::Retired { .. }, + ) => Err(AgentLiveHostError::BoundContextSealed), + Some(BoundContextSlot::Revoked(_)) => Err(AgentLiveHostError::BoundContextRevoked), + None => Err(AgentLiveHostError::BoundContextSealed), + } + } + + async fn close_context( + &self, + lease: &AgentLiveBindingLease, + revoked: bool, + reason: AgentLiveSealReason, + ) -> Result, AgentLiveHostError> { + let key = BoundContextKey::from_binding_lease(lease); + self.seal_context_key(&key, revoked, reason).await + } + + async fn seal_context_key( + &self, + key: &BoundContextKey, + revoked: bool, + reason: AgentLiveSealReason, + ) -> Result, AgentLiveHostError> { + let active = { + let mut contexts = self.contexts.lock().await; + let existing = contexts.remove(key); + match existing { + Some(BoundContextSlot::Active(active)) => { + contexts.insert( + key.clone(), + BoundContextSlot::Sealing { + active: active.clone(), + reason, + revoked, + }, + ); + active + } + Some(BoundContextSlot::Sealing { + active, + reason: existing_reason, + revoked: existing_revoked, + }) => { + let effective_revoked = revoked || existing_revoked; + contexts.insert( + key.clone(), + BoundContextSlot::Sealing { + active: active.clone(), + reason: existing_reason, + revoked: effective_revoked, + }, + ); + if existing_reason != reason { + return Err(AgentLiveHostError::BoundContextSealed); + } + active + } + Some(BoundContextSlot::Sealed(sealed)) => { + let result = sealed.clone(); + contexts.insert( + key.clone(), + if revoked { + BoundContextSlot::Revoked(Some(sealed)) + } else { + BoundContextSlot::Sealed(sealed) + }, + ); + return Ok(Some(result)); + } + Some(BoundContextSlot::Superseded(sealed)) => { + let result = sealed.clone(); + contexts.insert(key.clone(), BoundContextSlot::Superseded(sealed)); + return Ok(Some(result)); + } + Some(BoundContextSlot::Retiring { + token, + sealed, + revoked: existing_revoked, + }) => { + let result = sealed.clone(); + contexts.insert( + key.clone(), + BoundContextSlot::Retiring { + token, + sealed, + revoked: revoked || existing_revoked, + }, + ); + return Ok(Some(result)); + } + Some(BoundContextSlot::Retired { + sealed, + revoked: existing_revoked, + }) => { + let result = sealed.clone(); + contexts.insert( + key.clone(), + BoundContextSlot::Retired { + sealed, + revoked: revoked || existing_revoked, + }, + ); + return Ok(Some(result)); + } + Some(BoundContextSlot::Revoked(sealed)) => { + let result = sealed.clone(); + contexts.insert(key.clone(), BoundContextSlot::Revoked(sealed)); + return Ok(result); + } + None => return Ok(None), + } + }; + + // `Sealing` was installed before this await. Cancellation or an error + // leaves the exact active context retryable and all lookups closed. + // The returned proof carries the coordinator's current journal lease, + // including any activation adopted through rollover. The host must not + // compare it with or substitute the original activation lease. + let sealed = active.coordinator.seal(reason).await?; + let mut contexts = self.contexts.lock().await; + let effective_revoked = match contexts.get(key) { + Some(BoundContextSlot::Sealing { revoked, .. }) => *revoked, + _ => return Err(AgentLiveHostError::BoundContextSealed), + }; + contexts.insert( + key.clone(), + if effective_revoked { + BoundContextSlot::Revoked(Some(sealed.clone())) + } else { + BoundContextSlot::Sealed(sealed.clone()) + }, + ); + Ok(Some(sealed)) + } + + async fn close_all_contexts( + &self, + reason: AgentLiveSealReason, + ) -> Result<(), AgentLiveHostError> { + let keys = self + .contexts + .lock() + .await + .keys() + .cloned() + .collect::>(); + let mut first_error = None; + for key in keys { + if let Err(error) = self.seal_context_key(&key, true, reason).await { + first_error.get_or_insert(error); + } + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + async fn retire_all_contexts( + &self, + reason: AgentLiveSealReason, + ) -> Result<(), AgentLiveHostError> { + let keys = self + .contexts + .lock() + .await + .keys() + .cloned() + .collect::>(); + for key in keys { + self.retire_context_key(&key, reason).await?; + } + Ok(()) + } + + async fn retire_context_key( + &self, + key: &BoundContextKey, + reason: AgentLiveSealReason, + ) -> Result<(), AgentLiveHostError> { + // An adjacent same-target rotation already replaced this stable journal + // file. Its old lease must never enter the retirement protocol. + if matches!( + self.contexts.lock().await.get(key), + Some(BoundContextSlot::Superseded(_)) | Some(BoundContextSlot::Retired { .. }) + ) { + return Ok(()); + } + let sealed = self + .seal_context_key(key, true, reason) + .await? + .ok_or(AgentLiveHostError::BoundContextSealed)?; + let token = { + let mut contexts = self.contexts.lock().await; + match contexts.remove(key) { + Some(BoundContextSlot::Retiring { + token, + sealed, + revoked, + }) => { + let result = token.clone(); + contexts.insert( + key.clone(), + BoundContextSlot::Retiring { + token, + sealed, + revoked, + }, + ); + result + } + Some(BoundContextSlot::Retired { sealed, revoked }) => { + contexts.insert(key.clone(), BoundContextSlot::Retired { sealed, revoked }); + return Ok(()); + } + existing => { + if let Some(existing) = existing { + contexts.insert(key.clone(), existing); + } + // No await from minting the token until `Retiring` owns it. + let token = self + .journal + .seal_for_retirement(&sealed.journal_lease, &sealed.through_cursor)?; + contexts.insert( + key.clone(), + BoundContextSlot::Retiring { + token: token.clone(), + sealed: sealed.clone(), + revoked: true, + }, + ); + token + } + } + }; + let journal = self.journal.clone(); + let retirement_result = tokio::task::spawn_blocking(move || journal.retire_account(&token)) + .await + .map_err(|_| AgentLiveHostError::JournalWorkerUnavailable)?; + match retirement_result { + Ok(()) | Err(LiveEventJournalError::JournalRetired) => {} + Err(error) => return Err(error.into()), + } + self.contexts.lock().await.insert( + key.clone(), + BoundContextSlot::Retired { + sealed, + revoked: true, + }, + ); + Ok(()) + } +} + +pub(crate) enum AgentLiveHostBindOutcome { + Bound(AgentLiveBindingLease), + RotationRequired(AgentLiveHostRotation), +} + +/// Process-local obligation. Dropping or explicitly aborting it leaves the +/// binding registry in Transition, so synchronized operations remain closed. +#[must_use = "a live binding rotation must be sealed, durably rotated, and committed"] +pub(crate) struct AgentLiveHostRotation { + obligation: AgentLiveRotationObligation, + sealed: Option, + retirement: Option, + journal_rotated: bool, +} + +impl AgentLiveHostRotation { + pub(crate) fn previous(&self) -> &AgentLiveBindingLease { + self.obligation.previous() + } + + pub(crate) fn proposed(&self) -> &AgentLiveBindingLease { + self.obligation.proposed() + } + + pub(crate) const fn is_sealed(&self) -> bool { + self.sealed.is_some() + } + + pub(crate) const fn is_journal_rotated(&self) -> bool { + self.journal_rotated + } +} + +#[must_use = "a verified Agent journal reseed must be sealed and durably committed"] +pub(crate) struct AgentLiveHostReseed { + owner_key: BoundContextKey, + obligation: LiveEventJournalReseedObligation, + sealed: bool, +} + +pub(crate) struct AgentLiveAttachManager +where + P: AgentLiveAttachProvider, + D: AgentLiveDeliveryProjector, +{ + host: AgentLiveHost, + provider: Arc

, + projector: Arc, + lease: AgentLiveBindingLease, +} + +impl AgentLiveAttachManager +where + P: AgentLiveAttachProvider, + D: AgentLiveDeliveryProjector, +{ + /// Ordinary older-page loads retain the exact pager contract and do not + /// acquire or depend on a synchronized live binding. + pub(crate) async fn ordinary_history_page( + &self, + request: AgentHistoryPageRequest, + ) -> Result { + self.host + .ordinary_history_page(self.provider.as_ref(), request) + .await + } + + /// Capture C0, load and safely project the native newest page, retain the + /// complete account overlay, then replay C0..C1 before going live. + pub(crate) async fn attach_newest_page( + &self, + request: AgentHistoryPageRequest, + subscription_capacity: Option, + ) -> Result, AgentLiveAttachError> { + if request.cursor.is_some() { + return Err(AgentLiveHostError::HeadAttachRequiresNewestPage.into()); + } + let requested_limit = request.limit; + let initial_runtime_fence = self + .provider + .acquire_runtime_generation_fence() + .await + .map_err(AgentLiveHostError::from)?; + let head = { + let _lifecycle = self.host.lifecycle.lock().await; + self.host + .revalidate_provider_lease_under_fence( + self.provider.as_ref(), + &initial_runtime_fence, + &self.lease, + ) + .await?; + let coordinator = self.host.coordinator_for_lease(&self.lease).await?; + coordinator + .begin_account_head_attach(subscription_capacity) + .await + .map_err(AgentLiveHostError::from)? + }; + drop(initial_runtime_fence); + let page = self + .host + .ordinary_history_page(self.provider.as_ref(), request) + .await?; + let page = self + .projector + .project_history_page(page, requested_limit) + .map_err(AgentLiveAttachError::Projection)?; + validate_safe_history_page(&page, requested_limit)?; + let mut live_sessions = Vec::with_capacity(head.live_sessions.len()); + for session in &head.live_sessions { + live_sessions.push(AgentLiveProjectedSessionHead { + session_id: session.session_id.clone(), + live_items: self + .projector + .project_head_items(&session.live_items) + .map_err(AgentLiveAttachError::Projection)?, + }); + } + let live_sessions_complete = head.live_sessions_complete; + validate_safe_session_heads(live_sessions_complete, &live_sessions)?; + let through_event_cursor = api_cursor(&head.through_cursor); + let runtime_fence = self + .provider + .acquire_runtime_generation_fence() + .await + .map_err(AgentLiveHostError::from)?; + self.provider + .verify_current_generation_under_fence(&runtime_fence) + .await + .map_err(AgentLiveHostError::from)?; + let resume = { + let _lifecycle = self.host.lifecycle.lock().await; + self.host + .revalidate_provider_lease_under_fence( + self.provider.as_ref(), + &runtime_fence, + &self.lease, + ) + .await?; + let resume = head + .token + .finalize() + .await + .map_err(AgentLiveHostError::from)?; + self.host.revalidate_active_lease(&self.lease).await?; + resume + }; + let _ = &runtime_fence; + Ok(AgentLiveHeadAttachment { + page, + through_event_cursor, + live_sessions_complete, + live_sessions, + resume_through_cursor: api_cursor(&resume.through_cursor), + subscription: AgentLiveProjectedSubscription { + host: self.host.clone(), + provider: Arc::clone(&self.provider), + lease: self.lease.clone(), + projector: Arc::clone(&self.projector), + subscription: resume.subscription, + terminal: false, + }, + }) + } + + pub(crate) async fn resume( + &self, + cursor: AgentLiveEventCursor, + subscription_capacity: Option, + ) -> Result, AgentLiveHostError> { + let cursor = LiveEventCursor::try_from_parts(cursor.journal_id, cursor.sequence)?; + let runtime_fence = self.provider.acquire_runtime_generation_fence().await?; + self.provider + .verify_current_generation_under_fence(&runtime_fence) + .await?; + let coordinator = { + let _lifecycle = self.host.lifecycle.lock().await; + self.host + .revalidate_provider_lease_under_fence( + self.provider.as_ref(), + &runtime_fence, + &self.lease, + ) + .await?; + self.host.coordinator_for_lease(&self.lease).await? + }; + let resume = coordinator + .begin_resume(cursor, subscription_capacity) + .await?; + { + let _lifecycle = self.host.lifecycle.lock().await; + self.host + .revalidate_provider_lease_under_fence( + self.provider.as_ref(), + &runtime_fence, + &self.lease, + ) + .await?; + self.host.revalidate_active_lease(&self.lease).await?; + } + let _ = &runtime_fence; + Ok(AgentLiveResumeAttachment { + through_cursor: api_cursor(&resume.through_cursor), + subscription: AgentLiveProjectedSubscription { + host: self.host.clone(), + provider: Arc::clone(&self.provider), + lease: self.lease.clone(), + projector: Arc::clone(&self.projector), + subscription: resume.subscription, + terminal: false, + }, + }) + } +} + +pub(crate) struct AgentLiveHeadAttachment +where + P: AgentLiveAttachProvider, + D: AgentLiveDeliveryProjector, +{ + pub(crate) page: AgentLiveSafeHistoryPage, + /// C0 paired with the complete account-wide live snapshot below. + pub(crate) through_event_cursor: AgentLiveEventCursor, + pub(crate) live_sessions_complete: bool, + /// Complete account-wide C0 overlay. Consumers must clear cached session + /// overlays absent from this list when `live_sessions_complete` is true. + pub(crate) live_sessions: Vec, + /// C1 is internal attachment state. The response snapshot acknowledges C0; + /// deliveries queued during finalization cover C0..C1 exactly once. + pub(crate) resume_through_cursor: AgentLiveEventCursor, + pub(crate) subscription: AgentLiveProjectedSubscription, +} + +pub(crate) struct AgentLiveProjectedSessionHead { + pub(crate) session_id: String, + pub(crate) live_items: Vec, +} + +/// Per-peer factory for the object-safe remote attachment service below. +/// +/// The endpoint-global RPC host must bind exclusively from the opaque +/// authority minted by the current incoming admission. It must never select +/// an account, target, controller, pairing lineage, or connection generation +/// from request fields. The returned service is therefore irreversibly scoped +/// to this exact peer authority and must retain/revalidate it at every mutation +/// and disclosure boundary. +/// +/// `bind` is an authority-scoping constructor, not a native lifecycle +/// acquisition. The RPC edge may call it again for another request from the +/// same admitted peer, including concurrent requests which later lose the +/// stable-occupancy race. Implementations must therefore acquire no exclusive +/// subscriber, paused token, stream, or other asynchronously-released native +/// capacity here. Those resources may be acquired only by `begin_newest` or +/// `resume`, after the RPC lifecycle slot has been reserved. +#[async_trait::async_trait] +pub(crate) trait AgentLiveRemoteAttachProvider: Send + Sync { + /// Bind one exact peer-scoped, non-exclusive service. Dropping this future + /// or the returned service must require no asynchronous native cleanup. + async fn bind( + &self, + authority: VerifiedIncomingPeerAuthorization, + ) -> Result, AgentLiveRemoteAttachError>; +} + +/// Object-safe per-peer core seam consumed by the authenticated remote RPC +/// edge. +/// +/// The service is constructed around a native [`AgentLiveAttachProvider`]; no +/// account, execution-target, endpoint, authorization, or generation scalar is +/// accepted on these methods. A production implementation must retain the +/// provider's runtime-generation fence and revalidate its exact binding and +/// current peer admission before every mutation or disclosure. +#[async_trait::async_trait] +pub(crate) trait AgentLiveRemoteAttachService: Send + Sync { + /// Capture C0 and return one safely projected newest persisted page plus + /// the complete account-wide absolute live snapshot at C0. The subscriber + /// remains paused: this method must not finalize the coordinator token. + /// + /// This acquisition is cancellation-safe: if the returned future is + /// dropped before yielding `Ok`, an owned task/guard inside the production + /// implementation must await or otherwise reliably complete unsubscribe. + /// It must keep the affected native capacity unavailable for reuse until + /// that cleanup completes. A caller cannot acknowledge a lifecycle handle + /// which it never received, so deferring this duty back to the RPC edge is + /// forbidden. + async fn begin_newest( + &self, + request: AgentHistoryPageRequest, + subscription_capacity: Option, + ) -> Result; + + /// Resume directly from an opaque live-event cursor. The returned C1 is + /// the FIFO replay barrier and the stream owns all later deliveries. + /// Dropping this acquisition future before `Ok` is subject to the same + /// owned-cleanup requirement as `begin_newest`. + async fn resume( + &self, + cursor: AgentLiveEventCursor, + subscription_capacity: Option, + ) -> Result; +} + +/// One unactivated synchronized attach. The page and snapshot are safe to +/// serialize, but no event can be consumed until the edge has installed both +/// and explicitly calls `activate`. +pub(crate) struct AgentLiveRemoteHeadBegin { + pub(crate) page: AgentLiveSafeHistoryPage, + pub(crate) through_event_cursor: AgentLiveEventCursor, + pub(crate) live_sessions_complete: bool, + pub(crate) live_sessions: Vec, + pub(crate) pending: Box, +} + +#[async_trait::async_trait] +pub(crate) trait AgentLiveRemotePendingAttach: Send { + /// Revalidate the exact runtime generation, binding, and installed peer, + /// then finalize C0..C1. + /// + /// Activation is cancellation-safe at this object boundary. Until this + /// method returns `Ok`, dropping its future leaves the object valid and + /// `cancel(self: Box)` must reclaim and acknowledge the exact paused + /// or partially-finalized native lifecycle. Returning `Err` has the same + /// rule: the caller must still consume the object through `cancel`. After + /// `Ok`, the implementation has consumed its internal pending token and + /// dropping this wrapper is inert. + async fn activate(&mut self) -> Result; + + /// Cancel the paused coordinator token and await its actor acknowledgement + /// so aggregate subscriber capacity is reclaimed before returning. + async fn cancel(self: Box) -> Result<(), AgentLiveRemoteAttachError>; +} + +pub(crate) struct AgentLiveRemoteActivated { + /// C1 reached by replaying every event after the response's C0 snapshot. + pub(crate) through_event_cursor: AgentLiveEventCursor, + pub(crate) stream: Box, +} + +pub(crate) struct AgentLiveRemoteResume { + pub(crate) through_event_cursor: AgentLiveEventCursor, + pub(crate) stream: Box, +} + +#[async_trait::async_trait] +pub(crate) trait AgentLiveRemoteStream: Send { + /// Wait for one durable closed delivery. Implementations must revalidate + /// before waiting and again after consuming the event but before returning + /// it. Any failure after consumption is terminal, preventing a caller from + /// skipping a sequence by invoking `recv` again. + async fn recv(&mut self) -> Result; + + /// Stop the stream and await the coordinator's unsubscribe acknowledgement. + async fn unsubscribe(self: Box) -> Result<(), AgentLiveRemoteAttachError>; +} + +/// Closed remote delivery. It contains only the reviewed Maple live event +/// contract and opaque ordering metadata; rich Goose/tool values are absent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentLiveRemoteDelivery { + pub(crate) cursor: AgentLiveEventCursor, + pub(crate) session_id: String, + pub(crate) run_id: Option, + pub(crate) event: MapleLiveEvent, +} + +impl AgentLiveRemoteDelivery { + fn from_closed(delivery: AgentLiveDelivery) -> Result { + delivery + .validate() + .map_err(|_| AgentLiveRemoteAttachError::ProjectionRejected)?; + Ok(Self { + cursor: api_cursor(&delivery.cursor), + session_id: delivery.session_id, + run_id: delivery.run_id, + event: delivery.event, + }) + } +} + +#[derive(Debug)] +pub(crate) enum AgentLiveRemoteAttachError { + Host(AgentLiveHostError), + ProjectionRejected, + /// The core contract exists, but its verified runtime/provider adapter is + /// intentionally unavailable until the pinned Goose pager is integrated. + Unavailable, +} + +impl From for AgentLiveRemoteAttachError { + fn from(error: AgentLiveHostError) -> Self { + Self::Host(error) + } +} + +impl fmt::Display for AgentLiveRemoteAttachError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Host(error) => error.fmt(formatter), + Self::ProjectionRejected => { + formatter.write_str("the closed Agent live delivery projection was rejected") + } + Self::Unavailable => { + formatter.write_str("verified remote Agent live attachment is unavailable") + } + } + } +} + +impl std::error::Error for AgentLiveRemoteAttachError {} + +#[derive(Debug)] +pub(crate) enum AgentLiveRemoteStreamError { + Attach(AgentLiveRemoteAttachError), + Receive(AgentLiveReceiveError), +} + +impl fmt::Display for AgentLiveRemoteStreamError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Attach(error) => error.fmt(formatter), + Self::Receive(AgentLiveReceiveError::HeadReloadRequired(_)) => { + formatter.write_str("the Agent live stream requires an authoritative head reload") + } + Self::Receive(AgentLiveReceiveError::Closed) => { + formatter.write_str("the Agent live stream is closed") + } + } + } +} + +impl std::error::Error for AgentLiveRemoteStreamError {} + +/// Fail-closed default used until a native `AgentRuntimeHandle` provider and +/// safe Goose-row projector are composed. It cannot disclose a page, capture +/// a coordinator token, or create a stream. +#[derive(Debug, Default)] +pub(crate) struct UnavailableAgentLiveRemoteAttachService; + +#[async_trait::async_trait] +impl AgentLiveRemoteAttachService for UnavailableAgentLiveRemoteAttachService { + async fn begin_newest( + &self, + _request: AgentHistoryPageRequest, + _subscription_capacity: Option, + ) -> Result { + Err(AgentLiveRemoteAttachError::Unavailable) + } + + async fn resume( + &self, + _cursor: AgentLiveEventCursor, + _subscription_capacity: Option, + ) -> Result { + Err(AgentLiveRemoteAttachError::Unavailable) + } +} + +pub(crate) struct UnavailableAgentLiveRemoteAttachProvider; + +#[async_trait::async_trait] +impl AgentLiveRemoteAttachProvider for UnavailableAgentLiveRemoteAttachProvider { + async fn bind( + &self, + _authority: VerifiedIncomingPeerAuthorization, + ) -> Result, AgentLiveRemoteAttachError> { + Err(AgentLiveRemoteAttachError::Unavailable) + } +} + +pub(crate) struct AgentLiveResumeAttachment +where + P: AgentLiveAttachProvider, + D: AgentLiveDeliveryProjector, +{ + pub(crate) through_cursor: AgentLiveEventCursor, + pub(crate) subscription: AgentLiveProjectedSubscription, +} + +pub(crate) struct AgentLiveProjectedSubscription +where + P: AgentLiveAttachProvider, + D: AgentLiveDeliveryProjector, +{ + host: AgentLiveHost, + provider: Arc

, + lease: AgentLiveBindingLease, + projector: Arc, + subscription: AgentLiveSubscription, + terminal: bool, +} + +impl AgentLiveProjectedSubscription +where + P: AgentLiveAttachProvider, + D: AgentLiveDeliveryProjector, +{ + pub(crate) async fn recv(&mut self) -> Result> { + if self.terminal { + return Err(AgentLiveStreamError::Receive(AgentLiveReceiveError::Closed)); + } + let pre_receive_fence = match self.provider.acquire_runtime_generation_fence().await { + Ok(fence) => fence, + Err(error) => { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error.into())); + } + }; + { + let _lifecycle = self.host.lifecycle.lock().await; + if let Err(error) = self + .host + .revalidate_provider_lease_under_fence( + self.provider.as_ref(), + &pre_receive_fence, + &self.lease, + ) + .await + { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error)); + } + if let Err(error) = self.host.revalidate_active_lease(&self.lease).await { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error)); + } + } + drop(pre_receive_fence); + let delivery = match self.subscription.recv().await { + Ok(delivery) => delivery, + Err(error) => { + self.terminal = true; + return Err(AgentLiveStreamError::Receive(error)); + } + }; + + // From this point on the durable delivery has been consumed. Every + // failure is terminal: allowing another `recv` would silently skip + // the consumed sequence and violate the edge's replay contract. Set + // the bit before the next await so cancelling this future is terminal + // too; clear it only on the synchronous success return below. + self.terminal = true; + let runtime_fence = match self.provider.acquire_runtime_generation_fence().await { + Ok(fence) => fence, + Err(error) => { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error.into())); + } + }; + if let Err(error) = self + .provider + .verify_current_generation_under_fence(&runtime_fence) + .await + { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error.into())); + } + let _lifecycle = self.host.lifecycle.lock().await; + if let Err(error) = self + .host + .revalidate_provider_lease_under_fence( + self.provider.as_ref(), + &runtime_fence, + &self.lease, + ) + .await + { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error)); + } + if let Err(error) = self.host.revalidate_active_lease(&self.lease).await { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error)); + } + let projected = match self.projector.project_delivery(&delivery) { + Ok(projected) => projected, + Err(error) => { + self.terminal = true; + return Err(AgentLiveStreamError::Projection(error)); + } + }; + if let Err(error) = self + .host + .revalidate_provider_lease_under_fence( + self.provider.as_ref(), + &runtime_fence, + &self.lease, + ) + .await + { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error)); + } + if let Err(error) = self.host.revalidate_active_lease(&self.lease).await { + self.terminal = true; + return Err(AgentLiveStreamError::Host(error)); + } + let _ = &runtime_fence; + self.terminal = false; + Ok(projected) + } +} + +fn api_cursor(cursor: &LiveEventCursor) -> AgentLiveEventCursor { + AgentLiveEventCursor { + journal_id: cursor.journal_id().to_string(), + sequence: cursor.sequence(), + } +} + +fn validate_safe_history_page( + page: &AgentLiveSafeHistoryPage, + requested_limit: Option, +) -> Result<(), AgentLiveHostError> { + if requested_limit.is_some_and(|limit| !(1..=MAX_SYNCHRONIZED_HISTORY_RECORDS).contains(&limit)) + { + return Err(AgentLiveHostError::SynchronizedPageProjectionRejected); + } + let maximum_records = requested_limit.unwrap_or(MAX_SYNCHRONIZED_HISTORY_RECORDS); + if page.records.len() > maximum_records { + return Err(AgentLiveHostError::SynchronizedPageProjectionRejected); + } + if !is_safe_history_token(&page.history_revision, MAX_SYNCHRONIZED_HISTORY_TOKEN_BYTES) + || page.next_cursor.as_deref().is_some_and(|cursor| { + !is_safe_history_token(cursor, MAX_SYNCHRONIZED_HISTORY_TOKEN_BYTES) + }) + { + return Err(AgentLiveHostError::SynchronizedPageProjectionRejected); + } + + let mut record_ids = HashSet::with_capacity(page.records.len()); + for record in &page.records { + if !is_safe_history_token(&record.record_id, MAX_SYNCHRONIZED_HISTORY_TOKEN_BYTES) + || !record_ids.insert(record.record_id.as_str()) + || record.role.is_empty() + || record.role.len() > MAX_SYNCHRONIZED_ROLE_BYTES + || !record + .role + .bytes() + .all(|byte| byte.is_ascii_graphic() || byte == b' ') + || record.created_ms > MAX_JAVASCRIPT_SAFE_INTEGER + || record.items.len() > MAX_SYNCHRONIZED_ITEMS_PER_RECORD + || record.items.iter().any(|item| item.validate().is_err()) + { + return Err(AgentLiveHostError::SynchronizedPageProjectionRejected); + } + let mut encoded = SerializedHistoryByteCounter::new(MAX_HISTORY_RECORD_PRESENTATION_BYTES); + let encoding = ciborium::ser::into_writer(record, &mut encoded); + if encoded.limit_exceeded { + return Err(AgentLiveHostError::SynchronizedHistoryRecordTooLarge); + } + encoding.map_err(|_| AgentLiveHostError::SynchronizedPageProjectionRejected)?; + } + Ok(()) +} + +struct SerializedHistoryByteCounter { + bytes: usize, + limit: usize, + limit_exceeded: bool, +} + +impl SerializedHistoryByteCounter { + const fn new(limit: usize) -> Self { + Self { + bytes: 0, + limit, + limit_exceeded: false, + } + } +} + +impl Write for SerializedHistoryByteCounter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + let next = self + .bytes + .checked_add(buffer.len()) + .ok_or_else(|| std::io::Error::other("safe history CBOR length overflow"))?; + if next > self.limit { + self.limit_exceeded = true; + return Err(std::io::Error::other( + "safe history CBOR presentation limit exceeded", + )); + } + self.bytes = next; + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn validate_safe_session_heads( + live_sessions_complete: bool, + sessions: &[AgentLiveProjectedSessionHead], +) -> Result<(), AgentLiveHostError> { + if !live_sessions_complete || sessions.len() > MAX_SYNCHRONIZED_LIVE_SESSIONS { + return Err(AgentLiveHostError::SynchronizedPageProjectionRejected); + } + let mut previous_session_id: Option<&str> = None; + let mut account_item_count = 0usize; + for session in sessions { + if !is_safe_history_token(&session.session_id, MAX_SYNCHRONIZED_SESSION_ID_BYTES) + || previous_session_id.is_some_and(|previous| previous >= session.session_id.as_str()) + || session.live_items.len() > MAX_SYNCHRONIZED_LIVE_ITEMS_PER_SESSION + { + return Err(AgentLiveHostError::SynchronizedPageProjectionRejected); + } + previous_session_id = Some(&session.session_id); + account_item_count = account_item_count + .checked_add(session.live_items.len()) + .ok_or(AgentLiveHostError::SynchronizedPageProjectionRejected)?; + if account_item_count > MAX_SYNCHRONIZED_LIVE_ITEMS_PER_ACCOUNT { + return Err(AgentLiveHostError::SynchronizedPageProjectionRejected); + } + let mut item_ids = HashSet::with_capacity(session.live_items.len()); + if session.live_items.iter().any(|item| { + item.validate().is_err() + || item.merge != MapleLiveMerge::Replace + || !item_ids.insert(item.id.as_str()) + }) { + return Err(AgentLiveHostError::SynchronizedPageProjectionRejected); + } + } + Ok(()) +} + +fn is_safe_history_token(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent_event_journal::LiveEventJournalLease; + use crate::agent_live_coordinator::live_event_payload_commitment; + use tempfile::TempDir; + + const TEST_TARGET: &str = "11111111-1111-4111-8111-111111111111"; + const TEST_SESSION: &str = "session-a"; + const TEST_HISTORY_REVISION: &str = "history-revision-1"; + + fn open_test_journal() -> ( + TempDir, + LiveEventJournal, + LiveEventAccountOwner, + ) { + let root = tempfile::tempdir().expect("temporary journal root"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)) + .expect("owner-only temporary journal root"); + } + let private_parent = root.path().join("private"); + prepare_live_event_journal_parent(&private_parent).expect("prepare private journal parent"); + let journal = LiveEventJournal::open( + private_parent.join("live"), + DEFAULT_LIVE_EVENT_JOURNAL_LIMITS, + ) + .expect("open live journal"); + let owner = target_bound_owner("account-a", 7, TEST_TARGET).expect("valid test owner"); + (root, journal, owner) + } + + async fn start_ack_coordinator( + journal: &LiveEventJournal, + owner: &LiveEventAccountOwner, + data_owner: &AgentLiveDataOwnerKey, + ) -> ( + AgentLiveCoordinator, + LiveEventJournalLease, + LiveEventCursor, + [u8; 32], + ) { + let lease = journal + .activate_account(owner) + .expect("activate test journal"); + let probe_lease = lease.clone(); + let through_cursor = journal + .checkpoint(&lease) + .expect("capture initial journal head"); + let namespace = journal + .bind_ingress(&lease) + .expect("bind journal ingress") + .event_namespace_commitment(); + let coordinator = AgentLiveCoordinator::start_activated( + journal.clone(), + lease, + data_owner.clone(), + TEST_TARGET, + ) + .await + .expect("start test coordinator"); + (coordinator, probe_lease, through_cursor, namespace) + } + + fn persisted_head_receipt( + data_owner: AgentLiveDataOwnerKey, + namespace: [u8; 32], + through_cursor: &LiveEventCursor, + ) -> AgentDurableHeadCommitReceipt { + let event = MapleLiveEvent::HistoryHeadCommitted { + // The canonical payload commitment deliberately excludes this + // derived wire identifier. + event_id: "not-the-wire-id".to_string(), + history_revision: TEST_HISTORY_REVISION.to_string(), + through_event_cursor: through_cursor.clone(), + }; + let payload_commitment = live_event_payload_commitment(TEST_SESSION, None, &event) + .expect("commit canonical persisted-head payload"); + let stable_operation = AgentDurableStableOperationId::for_test( + data_owner, + TEST_SESSION, + None, + "durable-head-operation-1", + namespace, + payload_commitment, + ); + AgentDurableHeadCommitReceipt::for_test( + stable_operation, + TEST_HISTORY_REVISION, + api_cursor(through_cursor), + ) + } + + #[tokio::test] + async fn remote_attach_seam_is_object_safe_and_unavailable_without_native_adapter() { + let _provider: &dyn AgentLiveRemoteAttachProvider = + &UnavailableAgentLiveRemoteAttachProvider; + let service: Box = + Box::new(UnavailableAgentLiveRemoteAttachService); + assert!(matches!( + service + .begin_newest( + AgentHistoryPageRequest { + session_id: TEST_SESSION.to_string(), + cursor: None, + limit: Some(1), + }, + Some(4), + ) + .await, + Err(AgentLiveRemoteAttachError::Unavailable) + )); + assert!(matches!( + service + .resume( + AgentLiveEventCursor { + journal_id: "opaque-cursor".to_string(), + sequence: 0, + }, + Some(4), + ) + .await, + Err(AgentLiveRemoteAttachError::Unavailable) + )); + } + + #[tokio::test] + async fn persisted_head_receipt_retries_in_generation_but_not_after_namespace_change() { + let data_owner = AgentLiveDataOwnerKey::for_test("account-a", 7, TEST_TARGET, 1); + let (_root, journal, owner) = open_test_journal(); + let (old_coordinator, old_probe, old_head, old_namespace) = + start_ack_coordinator(&journal, &owner, &data_owner).await; + let receipt = persisted_head_receipt(data_owner.clone(), old_namespace, &old_head); + + let first = AgentLiveHost::acknowledge_persisted_head_on_coordinator( + &old_coordinator, + &data_owner, + &receipt, + old_head.clone(), + ) + .await + .expect("same-generation acknowledgement succeeds"); + let retry = AgentLiveHost::acknowledge_persisted_head_on_coordinator( + &old_coordinator, + &data_owner, + &receipt, + old_head.clone(), + ) + .await + .expect("same-generation receipt is retryable"); + assert_eq!(retry, first); + assert_eq!( + journal + .checkpoint(&old_probe) + .expect("read old journal head") + .sequence(), + 1 + ); + + // Rollover the exact owner after FIFO-sealing the old coordinator. The + // returned seal lease, rather than the initial activation clone, is + // the current journal authority. + let sealed = old_coordinator + .seal(AgentLiveSealReason::OwnerChanged) + .await + .expect("FIFO-seal old coordinator"); + let empty_projection = br#"{"formatVersion":1,"liveSessions":[]}"#; + journal + .store_checkpoint( + &sealed.journal_lease, + &sealed.through_cursor, + empty_projection, + ) + .expect("store exact empty absolute projection"); + let rollover = journal + .prepare_rollover( + &sealed.journal_lease, + &sealed.through_cursor, + empty_projection, + ) + .expect("prepare exact journal rollover"); + let activation = journal + .commit_rollover(&rollover, empty_projection) + .expect("commit journal rollover"); + let (fresh_lease, fresh_head) = activation.into_parts(); + let fresh_probe = fresh_lease.clone(); + let fresh_namespace = journal + .bind_ingress(&fresh_lease) + .expect("bind fresh journal ingress") + .event_namespace_commitment(); + assert_ne!(fresh_namespace, old_namespace); + let fresh_coordinator = AgentLiveCoordinator::start_activated( + journal.clone(), + fresh_lease, + data_owner.clone(), + TEST_TARGET, + ) + .await + .expect("start fresh-generation coordinator"); + + // The borrowed pre-rollover receipt is rejected before append and + // remains available for deterministic recovery. A post-rollover + // persistence commit must mint a fresh receipt in the new namespace. + assert!(matches!( + AgentLiveHost::acknowledge_persisted_head_on_coordinator( + &fresh_coordinator, + &data_owner, + &receipt, + fresh_head, + ) + .await, + Err(AgentLiveHostError::Coordinator( + AgentLiveCoordinatorError::IngressRebindRequired + )) + )); + assert_eq!( + journal + .checkpoint(&fresh_probe) + .expect("fresh journal remains unchanged") + .sequence(), + 0 + ); + } +} diff --git a/frontend/src-tauri/src/agent_live_projection.rs b/frontend/src-tauri/src/agent_live_projection.rs new file mode 100644 index 000000000..6fa6b2f02 --- /dev/null +++ b/frontend/src-tauri/src/agent_live_projection.rs @@ -0,0 +1,650 @@ +//! Closed projection between Maple's rich in-process Agent events and the +//! durable, remotely safe live-event contract. +//! +//! Rich desktop events may contain arbitrary tool input/output values and an +//! actionable permission capability. Neither is admitted here. Projection is +//! deliberately two-step: first build the closed payload used for the durable +//! stable-operation commitment, then consume that projection with a typed +//! ingress event ID. A raw string can therefore never become publish authority. + +#![allow( + dead_code, + reason = "the projector is consumed by the synchronized Agent attach slice" +)] + +use crate::{ + agent::{ + AgentRunEvent, AgentRunTerminal, AgentServiceEvent, AgentSessionSummary, AgentTimelineItem, + }, + agent_live_coordinator::{ + AgentLivePublishEvent, IngressEventId, MapleLiveEvent, MapleLiveItemType, MapleLiveMerge, + MapleLiveRole, MapleLiveRunTerminal, MapleLiveSessionSummary, MapleLiveTimelineItem, + MapleLiveUserFacingError, MapleLiveUserFacingErrorKind, + }, + remote_protocol::{ + SAFE_REMOTE_AGENT_ERROR as REMOTE_AGENT_ERROR, + SAFE_REMOTE_PERMISSION_TITLE as REMOTE_PERMISSION_TITLE, + SAFE_REMOTE_SETUP_WARNING as REMOTE_SETUP_WARNING, + SAFE_REMOTE_TOOL_CANCELLED as REMOTE_TOOL_CANCELLED, + SAFE_REMOTE_TOOL_FAILED as REMOTE_TOOL_FAILED, SAFE_REMOTE_TOOL_TITLE as REMOTE_TOOL_TITLE, + }, +}; + +const MAX_JAVASCRIPT_SAFE_INTEGER: u128 = 9_007_199_254_740_991; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentLiveProjectionBoundaryError { + ControlPlaneOnly, + InvalidItemType, + InvalidRole, + InvalidMerge, + InvalidTimestamp, + InvalidToolStatus, + ActionablePermission, +} + +/// Input outside [`AgentServiceEvent`] for account-visible lifecycle mutations +/// that did not historically have a rich Desktop event variant. +pub(crate) enum AgentLiveProjectionSource<'a> { + Service(&'a AgentServiceEvent), + SessionDeleted { session_id: &'a str }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProjectedAgentLiveEvent { + pub(crate) session_id: String, + pub(crate) run_id: Option, + pub(crate) event: MapleLiveEvent, +} + +impl ProjectedAgentLiveEvent { + /// Consume the reviewed closed projection and replace its untrusted + /// presentation-only event ID with the exact typed ingress identity. + /// This is the only route from rich local data into a publishable event. + pub(crate) fn into_publish_event(self, event_id: IngressEventId) -> AgentLivePublishEvent { + match self.event { + MapleLiveEvent::RunStarted { .. } => AgentLivePublishEvent::run_started(event_id), + MapleLiveEvent::TimelineUpsert { item, .. } => { + AgentLivePublishEvent::timeline_upsert(event_id, item) + } + MapleLiveEvent::TimelineCleared { reason, .. } => { + AgentLivePublishEvent::timeline_cleared(event_id, reason) + } + MapleLiveEvent::HistoryReplaced { .. } => { + AgentLivePublishEvent::history_replaced(event_id) + } + MapleLiveEvent::HistoryHeadCommitted { + history_revision, + through_event_cursor, + .. + } => AgentLivePublishEvent::history_head_committed( + event_id, + history_revision, + through_event_cursor, + ), + MapleLiveEvent::SessionUpdated { session, .. } => { + AgentLivePublishEvent::session_updated(event_id, session) + } + MapleLiveEvent::RunFinished { terminal, .. } => { + AgentLivePublishEvent::run_finished(event_id, terminal) + } + MapleLiveEvent::SessionDeleted { .. } => { + AgentLivePublishEvent::session_deleted(event_id) + } + MapleLiveEvent::UserFacingError { error, .. } => { + AgentLivePublishEvent::user_facing_error(event_id, error) + } + } + } +} + +/// Project one account-visible event into the closed durable contract. +/// +/// `presentation_id` is a bounded, non-secret ID durably assigned to the same +/// logical mutation. It is presentation data only; the returned event cannot +/// be published until [`ProjectedAgentLiveEvent::into_publish_event`] receives +/// a typed ingress ID whose stable-operation commitment covers this payload. +/// +/// `created_ms` is used only for a setup warning, whose legacy event carries no +/// timestamp. All timeline items retain their source timestamp. +pub(crate) fn project_agent_service_event( + source: AgentLiveProjectionSource<'_>, + presentation_id: &str, + created_ms: u64, +) -> Result { + match source { + AgentLiveProjectionSource::SessionDeleted { session_id } => Ok(ProjectedAgentLiveEvent { + session_id: session_id.to_string(), + run_id: None, + event: MapleLiveEvent::SessionDeleted { + event_id: presentation_id.to_string(), + }, + }), + AgentLiveProjectionSource::Service(event) => { + project_rich_service_event(event, presentation_id, created_ms) + } + } +} + +fn project_rich_service_event( + source: &AgentServiceEvent, + presentation_id: &str, + created_ms: u64, +) -> Result { + let presentation_id = presentation_id.to_string(); + let projected = match source { + AgentServiceEvent::RuntimeStatus(_) => { + return Err(AgentLiveProjectionBoundaryError::ControlPlaneOnly); + } + AgentServiceEvent::SessionCreated(session) => ProjectedAgentLiveEvent { + session_id: session.id.clone(), + run_id: None, + event: MapleLiveEvent::SessionUpdated { + event_id: presentation_id, + session: project_session_summary(session), + }, + }, + AgentServiceEvent::SessionUpdated { + session_id, + run_id, + session, + } => ProjectedAgentLiveEvent { + session_id: session_id.clone(), + run_id: run_id.clone(), + event: MapleLiveEvent::SessionUpdated { + event_id: presentation_id, + session: project_session_summary(session), + }, + }, + AgentServiceEvent::TimelineItem { + session_id, + run_id, + item, + } => ProjectedAgentLiveEvent { + session_id: session_id.clone(), + run_id: run_id.clone(), + event: MapleLiveEvent::TimelineUpsert { + event_id: presentation_id, + item: project_timeline_item(item)?, + }, + }, + AgentServiceEvent::Run { + session_id, + run_id, + event, + } => { + let event = match event { + AgentRunEvent::SessionUpdated(session) => MapleLiveEvent::SessionUpdated { + event_id: presentation_id, + session: project_session_summary(session), + }, + AgentRunEvent::Started => MapleLiveEvent::RunStarted { + event_id: presentation_id, + }, + AgentRunEvent::TimelineItem(item) | AgentRunEvent::Error(item) => { + MapleLiveEvent::TimelineUpsert { + event_id: presentation_id, + item: project_timeline_item(item)?, + } + } + AgentRunEvent::PermissionRequested { .. } => { + return Err(AgentLiveProjectionBoundaryError::ControlPlaneOnly); + } + AgentRunEvent::SetupWarning(message) => { + MapleLiveEvent::UserFacingError { + event_id: presentation_id.clone(), + error: MapleLiveUserFacingError { + id: presentation_id, + kind: MapleLiveUserFacingErrorKind::Warning, + title: Some("Agent warning".to_string()), + // MCP loader diagnostics can contain provider strings, + // local paths, or environment details. The rich local + // event retains them; the durable remote event does not. + message: sanitize_setup_warning(message), + created_ms, + }, + } + } + AgentRunEvent::HistoryReplaced => MapleLiveEvent::HistoryReplaced { + event_id: presentation_id, + }, + AgentRunEvent::Finished(terminal) => MapleLiveEvent::RunFinished { + event_id: presentation_id, + terminal: project_terminal(*terminal), + }, + }; + ProjectedAgentLiveEvent { + session_id: session_id.clone(), + run_id: Some(run_id.clone()), + event, + } + } + }; + Ok(projected) +} + +pub(crate) fn project_timeline_item( + item: &AgentTimelineItem, +) -> Result { + let item_type = match item.item_type.as_str() { + "message" => MapleLiveItemType::Message, + "thinking" => MapleLiveItemType::Thinking, + "tool" => MapleLiveItemType::Tool, + "permission" => MapleLiveItemType::Permission, + "system" => MapleLiveItemType::System, + "error" => MapleLiveItemType::Error, + _ => return Err(AgentLiveProjectionBoundaryError::InvalidItemType), + }; + let role = match item_type { + // These presentation rows cross the remote boundary only in their + // fixed reviewed roles. Never retain a provider- or argument-derived + // source role for them. + MapleLiveItemType::Tool => Some(MapleLiveRole::Assistant), + MapleLiveItemType::Permission | MapleLiveItemType::Error => Some(MapleLiveRole::System), + MapleLiveItemType::Message | MapleLiveItemType::Thinking | MapleLiveItemType::System => { + match item.role.as_deref() { + None => None, + Some("user") => Some(MapleLiveRole::User), + Some("assistant") => Some(MapleLiveRole::Assistant), + Some("thought") => Some(MapleLiveRole::Thought), + Some("system") => Some(MapleLiveRole::System), + Some(_) => return Err(AgentLiveProjectionBoundaryError::InvalidRole), + } + } + }; + let merge = match item.merge.as_str() { + "append" => MapleLiveMerge::Append, + "replace" => MapleLiveMerge::Replace, + _ => return Err(AgentLiveProjectionBoundaryError::InvalidMerge), + }; + if item.created_ms > MAX_JAVASCRIPT_SAFE_INTEGER { + return Err(AgentLiveProjectionBoundaryError::InvalidTimestamp); + } + let status = match item_type { + MapleLiveItemType::Permission => Some(normalize_terminal_permission_status( + item.status.as_deref(), + )?), + MapleLiveItemType::Tool => normalize_tool_status(item.status.as_deref())?, + MapleLiveItemType::Error => Some("failed".to_string()), + _ => item.status.clone(), + }; + // Rich tool titles are constructed from commands, paths, queries, URLs, + // and skill arguments. Failure text can be a raw parser/runtime error. + // Neither is a reviewed disclosure surface, so the durable/remote row uses + // a fixed presentation. The original local event remains unchanged. + let (title, text) = match item_type { + MapleLiveItemType::Tool => { + let text = match status.as_deref() { + Some("failed" | "error") => Some(REMOTE_TOOL_FAILED.to_string()), + Some("cancelled") => Some(REMOTE_TOOL_CANCELLED.to_string()), + _ => None, + }; + (Some(REMOTE_TOOL_TITLE.to_string()), text) + } + MapleLiveItemType::Permission => (Some(REMOTE_PERMISSION_TITLE.to_string()), None), + MapleLiveItemType::Error => ( + Some("Agent error".to_string()), + Some(REMOTE_AGENT_ERROR.to_string()), + ), + _ => (item.title.clone(), item.text.clone()), + }; + + Ok(MapleLiveTimelineItem { + id: item.id.clone(), + item_type, + role, + title, + text, + status, + created_ms: item.created_ms as u64, + merge, + }) +} + +fn normalize_tool_status( + status: Option<&str>, +) -> Result, AgentLiveProjectionBoundaryError> { + match status { + None => Ok(None), + Some("pending" | "running" | "completed" | "failed" | "error") => { + Ok(status.map(str::to_string)) + } + Some("cancel" | "canceled" | "cancelled") => Ok(Some("cancelled".to_string())), + Some(_) => Err(AgentLiveProjectionBoundaryError::InvalidToolStatus), + } +} + +fn normalize_terminal_permission_status( + status: Option<&str>, +) -> Result { + match status { + // Current Desktop sends the `_once` spellings. The shorter spellings + // remain accepted by the Rust command boundary for compatibility. + Some("allow_once" | "allow" | "allow_always") => Ok("allow_once".to_string()), + Some("deny_once" | "deny" | "deny_always") => Ok("deny_once".to_string()), + Some("cancel" | "cancelled") => Ok("cancelled".to_string()), + Some("completed") => Ok("completed".to_string()), + // Missing, pending, or any future unreviewed state could still carry + // an actionable capability and therefore remains local control-plane. + _ => Err(AgentLiveProjectionBoundaryError::ActionablePermission), + } +} + +fn sanitize_setup_warning(_message: &str) -> String { + REMOTE_SETUP_WARNING.to_string() +} + +pub(crate) fn project_session_summary(session: &AgentSessionSummary) -> MapleLiveSessionSummary { + MapleLiveSessionSummary { + id: session.id.clone(), + title: session.title.clone(), + project_root: session.project_root.clone(), + created_ms: session.created_ms, + updated_ms: session.updated_ms, + page_sort_ms: session.page_sort_ms, + message_count: session.message_count, + model: session.model.clone(), + mode: session.mode.clone(), + } +} + +fn project_terminal(terminal: AgentRunTerminal) -> MapleLiveRunTerminal { + match terminal { + AgentRunTerminal::Completed => MapleLiveRunTerminal::Completed, + AgentRunTerminal::Cancelled => MapleLiveRunTerminal::Cancelled, + AgentRunTerminal::Failed => MapleLiveRunTerminal::Failed, + } +} + +/// Convert a closed live row back into Maple's established safe presentation +/// item. Rich `input` and `output` are always absent by construction. +pub(crate) fn restore_safe_timeline_item(item: &MapleLiveTimelineItem) -> AgentTimelineItem { + AgentTimelineItem { + id: item.id.clone(), + item_type: match item.item_type { + MapleLiveItemType::Message => "message", + MapleLiveItemType::Thinking => "thinking", + MapleLiveItemType::Tool => "tool", + MapleLiveItemType::Permission => "permission", + MapleLiveItemType::System => "system", + MapleLiveItemType::Error => "error", + } + .to_string(), + role: item.role.map(|role| { + match role { + MapleLiveRole::User => "user", + MapleLiveRole::Assistant => "assistant", + MapleLiveRole::Thought => "thought", + MapleLiveRole::System => "system", + } + .to_string() + }), + title: item.title.clone(), + text: item.text.clone(), + status: item.status.clone(), + input: None, + output: None, + created_ms: u128::from(item.created_ms), + merge: match item.merge { + MapleLiveMerge::Append => "append", + MapleLiveMerge::Replace => "replace", + } + .to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn item(item_type: &str, status: Option<&str>) -> AgentTimelineItem { + AgentTimelineItem { + id: "item-1".to_string(), + item_type: item_type.to_string(), + role: Some("assistant".to_string()), + title: Some("Visible title".to_string()), + text: Some("Visible text".to_string()), + status: status.map(str::to_string), + input: Some(json!({"secretProviderInput": "must not cross"})), + output: Some(json!({"secretProviderOutput": "must not cross"})), + created_ms: 123, + merge: "replace".to_string(), + } + } + + #[test] + fn projection_drops_arbitrary_tool_input_and_output() { + let projected = project_timeline_item(&item("tool", Some("completed"))).unwrap(); + let encoded = serde_json::to_string(&projected).unwrap(); + assert!(!encoded.contains("secretProviderInput")); + assert!(!encoded.contains("secretProviderOutput")); + let restored = restore_safe_timeline_item(&projected); + assert!(restored.input.is_none()); + assert!(restored.output.is_none()); + } + + #[test] + fn durable_tool_projection_redacts_argument_titles_and_failure_diagnostics() { + let secrets = [ + "sk-live-secret", + "/Users/private/.env", + "DATABASE_URL=postgres://admin:password@host/db", + "https://example.invalid/?token=secret", + "curl -H 'Authorization: Bearer secret'", + ]; + for (index, secret) in secrets.iter().enumerate() { + let mut rich = item( + "tool", + Some(if index % 2 == 0 { + "failed" + } else { + "cancelled" + }), + ); + rich.title = Some(format!("Terminal: {secret}")); + rich.text = Some(format!("runtime parser diagnostic: {secret}")); + let projected = project_timeline_item(&rich).unwrap(); + assert_eq!(projected.title.as_deref(), Some(REMOTE_TOOL_TITLE)); + assert!(matches!( + projected.text.as_deref(), + Some(REMOTE_TOOL_FAILED | REMOTE_TOOL_CANCELLED) + )); + + // Exercise the exact durable representation a coordinator writes, + // then reconstruct it as a process restart would. + let durable = MapleLiveEvent::TimelineUpsert { + event_id: format!("event-{index}"), + item: projected, + }; + let encoded = serde_json::to_vec(&durable).unwrap(); + let restored: MapleLiveEvent = serde_json::from_slice(&encoded).unwrap(); + let reencoded = serde_json::to_string(&restored).unwrap(); + assert!(!reencoded.contains(secret), "leaked {secret}"); + assert!(!reencoded.contains("runtime parser diagnostic")); + } + } + + #[test] + fn durable_tool_status_is_closed_and_success_does_not_copy_rich_text() { + let mut completed = item("tool", Some("completed")); + completed.title = Some("Web Search: secret query".to_string()); + completed.text = Some("provider-private successful output".to_string()); + let projected = project_timeline_item(&completed).unwrap(); + assert_eq!(projected.title.as_deref(), Some(REMOTE_TOOL_TITLE)); + assert!(projected.text.is_none()); + + let unknown = item("tool", Some("provider_private")); + assert_eq!( + project_timeline_item(&unknown), + Err(AgentLiveProjectionBoundaryError::InvalidToolStatus) + ); + } + + #[test] + fn actionable_permission_is_control_plane_only() { + assert_eq!( + project_timeline_item(&item("permission", Some("pending"))), + Err(AgentLiveProjectionBoundaryError::ActionablePermission) + ); + let source = AgentServiceEvent::Run { + session_id: "session".to_string(), + run_id: "run".to_string(), + event: AgentRunEvent::PermissionRequested { + request: crate::agent::AgentPermissionRequest { + request_id: "permission".to_string(), + tool_name: "shell".to_string(), + arguments: Default::default(), + prompt: None, + }, + item: item("permission", Some("pending")), + }, + }; + assert_eq!( + project_agent_service_event( + AgentLiveProjectionSource::Service(&source), + "event-1", + 123, + ), + Err(AgentLiveProjectionBoundaryError::ControlPlaneOnly) + ); + } + + #[test] + fn every_accepted_permission_spelling_is_terminal_and_normalized() { + for (source, expected) in [ + ("allow_once", "allow_once"), + ("allow", "allow_once"), + ("allow_always", "allow_once"), + ("deny_once", "deny_once"), + ("deny", "deny_once"), + ("deny_always", "deny_once"), + ("cancel", "cancelled"), + ("cancelled", "cancelled"), + ("completed", "completed"), + ] { + let projected = project_timeline_item(&item("permission", Some(source))).unwrap(); + assert_eq!(projected.status.as_deref(), Some(expected), "{source}"); + } + for unsafe_status in [None, Some("pending"), Some("running"), Some("unknown")] { + assert_eq!( + project_timeline_item(&item("permission", unsafe_status)), + Err(AgentLiveProjectionBoundaryError::ActionablePermission), + "{unsafe_status:?}" + ); + } + } + + #[test] + fn durable_warnings_and_errors_redact_host_diagnostics() { + let warning = AgentServiceEvent::Run { + session_id: "session".to_string(), + run_id: "run".to_string(), + event: AgentRunEvent::SetupWarning( + "provider token at /Users/private/.config failed: sk-secret".to_string(), + ), + }; + let projected = project_agent_service_event( + AgentLiveProjectionSource::Service(&warning), + "event-warning", + 123, + ) + .unwrap(); + let encoded = serde_json::to_string(&projected.event).unwrap(); + assert!(encoded.contains(REMOTE_SETUP_WARNING)); + assert!(!encoded.contains("/Users/private")); + assert!(!encoded.contains("sk-secret")); + + let mut rich_error = item("error", Some("failed")); + rich_error.text = Some("provider request included sk-secret".to_string()); + let projected = project_timeline_item(&rich_error).unwrap(); + assert_eq!(projected.text.as_deref(), Some(REMOTE_AGENT_ERROR)); + } + + #[test] + fn fixed_remote_rows_ignore_hostile_source_roles_and_error_status() { + let mut tool = item("tool", Some("completed")); + tool.role = Some("provider-private-role".to_string()); + let tool = project_timeline_item(&tool).unwrap(); + assert_eq!(tool.role, Some(MapleLiveRole::Assistant)); + tool.validate().unwrap(); + + let mut permission = item("permission", Some("completed")); + permission.role = Some("user".to_string()); + let permission = project_timeline_item(&permission).unwrap(); + assert_eq!(permission.role, Some(MapleLiveRole::System)); + permission.validate().unwrap(); + + let mut error = item("error", Some("DATABASE_URL=postgres://secret")); + error.role = Some("thought".to_string()); + let error = project_timeline_item(&error).unwrap(); + assert_eq!(error.role, Some(MapleLiveRole::System)); + assert_eq!(error.status.as_deref(), Some("failed")); + error.validate().unwrap(); + let encoded = serde_json::to_string(&error).unwrap(); + assert!(!encoded.contains("DATABASE_URL")); + assert!(!encoded.contains("secret")); + } + + #[test] + fn session_projection_preserves_storage_sort_key() { + let summary = AgentSessionSummary { + id: "session".to_string(), + title: "Task".to_string(), + project_root: "/project".to_string(), + created_ms: 1, + updated_ms: 2, + page_sort_ms: 9, + message_count: 3, + model: Some("model".to_string()), + mode: "chat".to_string(), + }; + assert_eq!(project_session_summary(&summary).page_sort_ms, 9); + } + + #[test] + fn unknown_enum_strings_and_unsafe_timestamp_fail_closed() { + let mut candidate = item("provider_private", None); + assert_eq!( + project_timeline_item(&candidate), + Err(AgentLiveProjectionBoundaryError::InvalidItemType) + ); + candidate.item_type = "message".to_string(); + candidate.role = Some("provider".to_string()); + assert_eq!( + project_timeline_item(&candidate), + Err(AgentLiveProjectionBoundaryError::InvalidRole) + ); + candidate.role = Some("assistant".to_string()); + candidate.merge = "splice".to_string(); + assert_eq!( + project_timeline_item(&candidate), + Err(AgentLiveProjectionBoundaryError::InvalidMerge) + ); + candidate.merge = "replace".to_string(); + candidate.created_ms = MAX_JAVASCRIPT_SAFE_INTEGER + 1; + assert_eq!( + project_timeline_item(&candidate), + Err(AgentLiveProjectionBoundaryError::InvalidTimestamp) + ); + } + + #[test] + fn runtime_status_never_enters_the_durable_journal() { + let source = AgentServiceEvent::RuntimeStatus(crate::agent::AgentRuntimeStatus { + running: false, + project_root: None, + model: None, + mode: None, + active_runs: Default::default(), + }); + assert_eq!( + project_agent_service_event( + AgentLiveProjectionSource::Service(&source), + "event-1", + 123, + ), + Err(AgentLiveProjectionBoundaryError::ControlPlaneOnly) + ); + } +} diff --git a/frontend/src-tauri/src/agent_live_tauri.rs b/frontend/src-tauri/src/agent_live_tauri.rs new file mode 100644 index 000000000..e5adb9cf5 --- /dev/null +++ b/frontend/src-tauri/src/agent_live_tauri.rs @@ -0,0 +1,4158 @@ +//! Tauri-owned synchronized history-head attachment. +//! +//! The coordinator owns durable ordering; this module owns only the IPC lease +//! and channel lifecycle. A pending lease keeps the exact coordinator token +//! returned at C0. Activation finalizes that same token, queues every account- +//! wide delivery in `(C0, C1]` on the exact channel supplied to `begin`, and +//! only then replaces the prior active stream for the same account and target. +//! +//! Account ownership is always supplied by a verified service binding. This +//! adapter never derives an account or execution target from a global Tauri +//! event sink, a session ID, or an incoming channel payload. + +#![allow( + dead_code, + reason = "the command wrappers are composed by the Agent host integration" +)] + +use crate::{ + agent::{AgentHistoryPage, AgentHistoryPageRequest, AgentLiveEventCursor}, + agent_event_journal::{LiveEventCursor, LiveEventJournalError}, + agent_live_binding::{AgentLiveBindingLease, LocalAuthorizationContext}, + agent_live_coordinator::{ + AgentHeadAttach, AgentHeadAttachToken, AgentLiveCoordinatorError, AgentLiveDelivery, + AgentLiveReceiveError, AgentLiveResume, AgentLiveSessionProjection, AgentLiveSubscription, + HeadReloadReason, MapleLiveClearReason, MapleLiveEvent, MapleLiveRunTerminal, + MapleLiveSessionSummary, MapleLiveTimelineItem, + }, + agent_live_host::{AgentLiveHostError, AgentLivePeerRevocationHook}, + agent_live_projection::project_timeline_item, + remote_protocol::{ + ConnectionStamp, MAX_HISTORY_RECORD_PRESENTATION_BYTES, SAFE_REMOTE_AGENT_ERROR, + SAFE_REMOTE_TOOL_CANCELLED, SAFE_REMOTE_TOOL_FAILED, SAFE_REMOTE_TOOL_TITLE, + }, + remote_transport::{PairingFence, VerifiedIncomingPeerAuthorization}, +}; +use async_trait::async_trait; +use getrandom::fill as fill_random; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fmt, + io::Write, + sync::{Arc, Mutex, MutexGuard, Weak}, + time::{Duration, Instant}, +}; +use tauri::{ipc::Channel, State}; +use tokio::{sync::oneshot, task::JoinHandle}; + +const DEFAULT_PENDING_ATTACH_TTL: Duration = Duration::from_secs(30); +const DEFAULT_SUBSCRIPTION_CAPACITY: usize = 128; +const MAX_PENDING_ATTACHES_PER_ACCOUNT_TARGET: usize = 16; +const MAX_PENDING_ATTACHES_TOTAL: usize = 128; +const MAX_LIVE_SESSIONS: usize = 64; +const MAX_LIVE_ITEMS: usize = 512; +const MAX_HISTORY_RECORDS_PER_PAGE: usize = 50; +const MAX_HISTORY_ITEMS_PER_RECORD: usize = 200; +const MAX_ACCOUNT_SCOPE_BYTES: usize = 256; +const MAX_EXECUTION_TARGET_BYTES: usize = 128; +const MAX_JAVASCRIPT_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const ATTACH_ID_RANDOM_BYTES: usize = 16; +const MAX_ATTACH_ID_ATTEMPTS: usize = 8; +const AGENT_LIVE_PRESENTATION_VERSION: u16 = 1; + +/// Exact revocable ownership captured from the service's verified target +/// binding. Only the target ID and full connection stamp cross the IPC wire. +#[derive(Debug, Clone)] +pub(crate) struct AgentLiveLeaseOwner { + pub(crate) opaque_account_scope: String, + pub(crate) account_data_generation: u64, + pub(crate) target_id: String, + pub(crate) authorization: LocalAuthorizationContext, + pub(crate) controller_endpoint: iroh::EndpointId, + pub(crate) pairing_fence: PairingFence, + pub(crate) connection_stamp: ConnectionStamp, + pub(crate) binding_lineage_epoch: u64, + pub(crate) peer_lineage_epoch: u64, + native_authority: Option, +} + +impl PartialEq for AgentLiveLeaseOwner { + fn eq(&self, other: &Self) -> bool { + self.opaque_account_scope == other.opaque_account_scope + && self.account_data_generation == other.account_data_generation + && self.target_id == other.target_id + && self.authorization == other.authorization + && self.controller_endpoint == other.controller_endpoint + && self.pairing_fence == other.pairing_fence + && self.connection_stamp == other.connection_stamp + && self.binding_lineage_epoch == other.binding_lineage_epoch + && self.peer_lineage_epoch == other.peer_lineage_epoch + && match (&self.native_authority, &other.native_authority) { + (Some(left), Some(right)) => left.same_admission_instance(right), + (None, None) => true, + _ => false, + } + } +} + +impl Eq for AgentLiveLeaseOwner {} + +impl AgentLiveLeaseOwner { + pub(crate) fn from_binding(lease: &AgentLiveBindingLease) -> Self { + Self { + opaque_account_scope: lease.account_scope().to_string(), + account_data_generation: lease.account_generation(), + target_id: lease.execution_target().as_str().to_string(), + authorization: lease.authorization().clone(), + controller_endpoint: lease.controller_endpoint(), + pairing_fence: lease.pairing_fence(), + connection_stamp: lease.connection_stamp(), + binding_lineage_epoch: lease.lineage_epoch(), + peer_lineage_epoch: lease.peer_lineage_epoch(), + native_authority: lease.remote_authority().cloned(), + } + } + + fn validate(&self) -> Result<(), AgentLiveAttachError> { + validate_bounded_id( + &self.opaque_account_scope, + MAX_ACCOUNT_SCOPE_BYTES, + "Agent live account scope is invalid", + )?; + validate_bounded_id( + &self.target_id, + MAX_EXECUTION_TARGET_BYTES, + "Agent execution target is invalid", + )?; + if self.authorization.account_epoch() == 0 + || self.authorization.snapshot_revision() == 0 + || self.binding_lineage_epoch == 0 + || self.peer_lineage_epoch == 0 + || self.connection_stamp.validate().is_err() + || self.connection_stamp.generation() > MAX_JAVASCRIPT_SAFE_INTEGER + { + return Err(AgentLiveAttachError::InvalidRequest { + message: "Agent live binding is invalid", + }); + } + match self.native_authority.as_ref() { + Some(authority) => authority + .revalidate_current() + .map_err(|_| AgentLiveAttachError::StaleLease)?, + #[cfg(test)] + None => {} + #[cfg(not(test))] + None => return Err(AgentLiveAttachError::Unavailable), + } + Ok(()) + } + + fn stream_key(&self) -> AccountTargetKey { + AccountTargetKey { + opaque_account_scope: self.opaque_account_scope.clone(), + account_data_generation: self.account_data_generation, + target_id: self.target_id.clone(), + controller_endpoint: self.controller_endpoint, + pairing_fence: self.pairing_fence, + binding_lineage_epoch: self.binding_lineage_epoch, + peer_lineage_epoch: self.peer_lineage_epoch, + connection_stamp: self.connection_stamp, + } + } + + fn stream_lineage_key(&self) -> AccountTargetLineageKey { + AccountTargetLineageKey { + opaque_account_scope: self.opaque_account_scope.clone(), + account_data_generation: self.account_data_generation, + target_id: self.target_id.clone(), + controller_endpoint: self.controller_endpoint, + pairing_fence: self.pairing_fence, + binding_lineage_epoch: self.binding_lineage_epoch, + peer_lineage_epoch: self.peer_lineage_epoch, + } + } + + fn with_current_authority( + &self, + operation: impl FnOnce() -> R, + ) -> Result { + match self.native_authority.as_ref() { + Some(authority) => authority + .with_current(operation) + .map_err(|_| AgentLiveAttachError::StaleLease), + #[cfg(test)] + None => Ok(operation()), + #[cfg(not(test))] + None => Err(AgentLiveAttachError::Unavailable), + } + } +} + +/// Renderer-supplied rejection precondition. Native code resolves the current +/// owner independently, then compares all three fields before retaining or +/// writing the channel. It is never treated as authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AgentExpectedLiveLease { + pub(crate) target_id: String, + pub(crate) host_epoch: String, + pub(crate) connection_generation: u64, +} + +impl AgentExpectedLiveLease { + pub(crate) fn validate_against( + &self, + owner: &AgentLiveLeaseOwner, + ) -> Result<(), AgentLiveAttachError> { + validate_bounded_id( + &self.target_id, + MAX_EXECUTION_TARGET_BYTES, + "Agent execution target is invalid", + )?; + let host_epoch = parse_canonical_host_epoch(&self.host_epoch)?; + if self.connection_generation == 0 + || self.connection_generation > MAX_JAVASCRIPT_SAFE_INTEGER + { + return Err(AgentLiveAttachError::InvalidRequest { + message: "Agent connection generation is invalid", + }); + } + if self.target_id != owner.target_id + || host_epoch != owner.connection_stamp.host_epoch() + || self.connection_generation != owner.connection_stamp.generation() + { + return Err(AgentLiveAttachError::StaleLease); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct AccountTargetKey { + opaque_account_scope: String, + account_data_generation: u64, + target_id: String, + controller_endpoint: iroh::EndpointId, + pairing_fence: PairingFence, + binding_lineage_epoch: u64, + peer_lineage_epoch: u64, + connection_stamp: ConnectionStamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct AccountTargetLineageKey { + opaque_account_scope: String, + account_data_generation: u64, + target_id: String, + controller_endpoint: iroh::EndpointId, + pairing_fence: PairingFence, + binding_lineage_epoch: u64, + peer_lineage_epoch: u64, +} + +/// Literal complete account snapshot entry captured at C0. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentLiveSessionSnapshot { + pub(crate) session_id: String, + pub(crate) live_items: Vec, +} + +/// One Goose persisted row with a closed presentation-safe item projection. +/// Record count semantics remain native-row based even when `items` contains +/// several timeline cards. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentSafeHistoryRecord { + pub(crate) record_id: String, + pub(crate) role: String, + pub(crate) created_ms: u64, + pub(crate) items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentSafeHistoryPage { + pub(crate) records: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) next_cursor: Option, + pub(crate) history_revision: String, +} + +/// Begin wire contract. `live_session_count` is deliberately serialized and +/// independently checked against `live_sessions.len()` before this is built. +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentBeginSessionHistoryAttachResponse { + pub(crate) attach_id: String, + pub(crate) page: AgentSafeHistoryPage, + pub(crate) live_sessions_complete: bool, + pub(crate) live_session_count: usize, + pub(crate) live_sessions: Vec, + pub(crate) through_event_cursor: AgentLiveEventCursor, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentLiveBarrierResponse { + pub(crate) through_event_cursor: AgentLiveEventCursor, + pub(crate) live_stream_id: String, +} + +/// Every ordinary live event carries the account-wide durable cursor before a +/// frontend decides whether its session route is currently visible. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentOrderedLiveEvent { + pub(crate) live_event_version: u16, + pub(crate) target_id: String, + pub(crate) host_epoch: String, + pub(crate) connection_generation: u64, + pub(crate) event_epoch: String, + pub(crate) event_sequence: u64, + pub(crate) session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) run_id: Option, + #[serde(flatten)] + pub(crate) event: AgentPresentedLiveEvent, +} + +/// Version-one closed presentation payload. Every field originates in the +/// already-validated durable Maple event; arbitrary provider JSON, tool +/// input/output, prompts, credentials, and actionable permissions have no +/// representation here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "eventType", rename_all = "camelCase")] +pub(crate) enum AgentPresentedLiveEvent { + RunStarted, + TimelineUpsert { + item: MapleLiveTimelineItem, + }, + TimelineCleared { + reason: MapleLiveClearReason, + }, + HistoryReplaced, + /// Advances the durable account cursor for an internal persisted-head + /// acknowledgement without exposing its storage revision or event ID. + CursorAdvanced, + SessionUpdated { + session: MapleLiveSessionSummary, + }, + RunFinished { + terminal: MapleLiveRunTerminal, + }, + SessionDeleted, + UserFacingError { + item: MapleLiveTimelineItem, + }, +} + +/// A channel can also terminate with a typed reload instruction. This control +/// frame is not assigned a synthetic event sequence and therefore cannot be +/// mistaken for a durable delivery. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentLiveSnapshotRequiredFrame { + pub(crate) live_event_version: u16, + pub(crate) event_type: &'static str, + pub(crate) target_id: String, + pub(crate) host_epoch: String, + pub(crate) connection_generation: u64, + pub(crate) reason: AgentLiveSnapshotReason, + pub(crate) last_event_cursor: AgentLiveEventCursor, +} + +/// Untagged keeps ordinary event frames byte-compatible with the established +/// flat `eventType` envelope while retaining a closed control-frame shape. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub(crate) enum AgentLiveChannelFrame { + Event(AgentOrderedLiveEvent), + SnapshotRequired(AgentLiveSnapshotRequiredFrame), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub(crate) enum AgentLiveSnapshotReason { + #[serde(rename = "paused_overflow")] + PausedSubscriberOverflow, + #[serde(rename = "slow_subscriber")] + SlowSubscriber, + #[serde(rename = "journal_replaced")] + JournalReplaced, + #[serde(rename = "retention_gap")] + RetentionGap, + #[serde(rename = "cursor_ahead")] + CursorAhead, + #[serde(rename = "owner_changed")] + OwnerChanged, + #[serde(rename = "ordering_lost")] + OrderingLost, + #[serde(rename = "journal_unavailable")] + JournalUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "code", rename_all = "snake_case")] +pub(crate) enum AgentLiveAttachError { + InvalidRequest { message: &'static str }, + StaleLease, + AttachNotFound, + CapacityExceeded, + ChannelClosed, + ProjectionRejected, + HistoryRecordTooLarge, + SnapshotRequired { reason: AgentLiveSnapshotReason }, + Unavailable, +} + +impl fmt::Display for AgentLiveAttachError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidRequest { message } => message, + Self::StaleLease => "Agent live attachment is stale", + Self::AttachNotFound => "Agent live attachment was not found", + Self::CapacityExceeded => "Too many Agent live attachments are pending", + Self::ChannelClosed => "Agent live event channel is closed", + Self::ProjectionRejected => "Agent live event projection was rejected", + Self::HistoryRecordTooLarge => { + "One Agent history record is too large to display safely" + } + Self::SnapshotRequired { .. } => "Agent history head must be reloaded", + Self::Unavailable => "Agent live history is unavailable", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AgentLiveAttachError {} + +/// Service seam that resolves the exact account+target coordinator and the +/// exact account-data generation. Implementations must not silently substitute +/// a newly current owner when the supplied lease is stale. +#[async_trait] +pub(crate) trait AgentLiveAttachProvider: Send + Sync { + async fn validate_lease(&self, owner: &AgentLiveLeaseOwner) + -> Result<(), AgentLiveAttachError>; + + async fn begin_account_head_attach( + &self, + owner: &AgentLiveLeaseOwner, + capacity: usize, + ) -> Result; + + async fn list_history_page( + &self, + owner: &AgentLiveLeaseOwner, + request: AgentHistoryPageRequest, + ) -> Result; + + async fn begin_resume( + &self, + owner: &AgentLiveLeaseOwner, + cursor: LiveEventCursor, + capacity: usize, + ) -> Result; +} + +/// Closed delivery-to-legacy-envelope projection supplied by the reviewed +/// projection module. Ordering metadata is added only after this succeeds. +pub(crate) trait AgentLiveDeliveryProjector: Send + Sync { + fn project_delivery( + &self, + delivery: &AgentLiveDelivery, + ) -> Result; +} + +/// Production projector for the durable closed event set. The trait remains so +/// lifecycle tests can inject a rejection, but even injected projectors can +/// return only this closed enum. +#[derive(Debug, Default)] +pub(crate) struct ClosedAgentLiveDeliveryProjector; + +impl AgentLiveDeliveryProjector for ClosedAgentLiveDeliveryProjector { + fn project_delivery( + &self, + delivery: &AgentLiveDelivery, + ) -> Result { + delivery + .validate() + .map_err(|_| AgentLiveAttachError::ProjectionRejected)?; + validate_wire_safe_event(&delivery.event)?; + Ok(match &delivery.event { + MapleLiveEvent::RunStarted { .. } => AgentPresentedLiveEvent::RunStarted, + MapleLiveEvent::TimelineUpsert { item, .. } => { + AgentPresentedLiveEvent::TimelineUpsert { item: item.clone() } + } + MapleLiveEvent::TimelineCleared { reason, .. } => { + AgentPresentedLiveEvent::TimelineCleared { reason: *reason } + } + MapleLiveEvent::HistoryReplaced { .. } => AgentPresentedLiveEvent::HistoryReplaced, + MapleLiveEvent::HistoryHeadCommitted { .. } => AgentPresentedLiveEvent::CursorAdvanced, + MapleLiveEvent::SessionUpdated { session, .. } => { + AgentPresentedLiveEvent::SessionUpdated { + session: session.clone(), + } + } + MapleLiveEvent::RunFinished { terminal, .. } => AgentPresentedLiveEvent::RunFinished { + terminal: *terminal, + }, + MapleLiveEvent::SessionDeleted { .. } => AgentPresentedLiveEvent::SessionDeleted, + MapleLiveEvent::UserFacingError { error, .. } => { + AgentPresentedLiveEvent::UserFacingError { + item: error.to_timeline_item(), + } + } + }) + } +} + +fn validate_wire_safe_event(event: &MapleLiveEvent) -> Result<(), AgentLiveAttachError> { + let item = match event { + MapleLiveEvent::TimelineUpsert { item, .. } => item, + _ => return Ok(()), + }; + match item.item_type { + crate::agent_live_coordinator::MapleLiveItemType::Tool => { + let expected_text = match item.status.as_deref() { + None | Some("pending" | "running" | "completed") => None, + Some("failed" | "error") => Some(SAFE_REMOTE_TOOL_FAILED), + Some("cancelled") => Some(SAFE_REMOTE_TOOL_CANCELLED), + Some(_) => return Err(AgentLiveAttachError::ProjectionRejected), + }; + if item.title.as_deref() != Some(SAFE_REMOTE_TOOL_TITLE) + || item.text.as_deref() != expected_text + { + return Err(AgentLiveAttachError::ProjectionRejected); + } + } + crate::agent_live_coordinator::MapleLiveItemType::Error => { + if item.title.as_deref() != Some("Agent error") + || item.text.as_deref() != Some(SAFE_REMOTE_AGENT_ERROR) + { + return Err(AgentLiveAttachError::ProjectionRejected); + } + } + _ => {} + } + Ok(()) +} + +pub(crate) trait AgentLiveEventSender: Send + Sync { + fn send(&self, frame: AgentLiveChannelFrame) -> Result<(), AgentLiveAttachError>; +} + +struct TauriAgentLiveEventSender { + channel: Channel, +} + +impl AgentLiveEventSender for TauriAgentLiveEventSender { + fn send(&self, frame: AgentLiveChannelFrame) -> Result<(), AgentLiveAttachError> { + self.channel + .send(frame) + .map_err(|_| AgentLiveAttachError::ChannelClosed) + } +} + +pub(crate) fn tauri_agent_live_sender( + channel: Channel, +) -> Arc { + Arc::new(TauriAgentLiveEventSender { channel }) +} + +/// Native authority resolver installed only after the verified pairing and +/// account-generation host composition is available. Renderer lease fields +/// are rejection preconditions and never reach this trait as authority. +#[async_trait] +pub(crate) trait AgentLiveOwnerResolver: Send + Sync { + async fn resolve_current_owner( + &self, + user_id: &str, + ) -> Result; +} + +#[derive(Clone)] +struct EnabledAgentLiveTauriRuntime { + manager: AgentLiveAttachManager, + owner_resolver: Arc, +} + +/// Managed even while synchronized live mode is unavailable, so every command +/// fails with the stable typed contract instead of an unmanaged-State detail. +#[derive(Clone, Default)] +pub(crate) struct AgentLiveTauriState { + runtime: Arc>>, +} + +impl AgentLiveTauriState { + pub(crate) fn disabled() -> Self { + Self::default() + } + + #[allow(dead_code, reason = "installed by the verified host composition")] + pub(crate) fn install_verified_runtime( + &self, + manager: AgentLiveAttachManager, + owner_resolver: Arc, + ) -> Result<(), AgentLiveAttachError> { + let mut runtime = self + .runtime + .write() + .map_err(|_| AgentLiveAttachError::Unavailable)?; + if runtime.is_some() { + return Err(AgentLiveAttachError::Unavailable); + } + *runtime = Some(EnabledAgentLiveTauriRuntime { + manager, + owner_resolver, + }); + Ok(()) + } + + fn enabled(&self) -> Result { + self.runtime + .read() + .map_err(|_| AgentLiveAttachError::Unavailable)? + .clone() + .ok_or(AgentLiveAttachError::Unavailable) + } + + pub(crate) async fn revoke_exact_owner( + &self, + owner: &AgentLiveLeaseOwner, + ) -> Result<(), AgentLiveAttachError> { + let runtime = { + self.runtime + .read() + .map_err(|_| AgentLiveAttachError::Unavailable)? + .clone() + }; + if let Some(runtime) = runtime { + runtime.manager.revoke_owner(owner).await; + } + Ok(()) + } +} + +#[async_trait] +impl AgentLivePeerRevocationHook for AgentLiveTauriState { + async fn revoke_exact_peer( + &self, + revoked: &AgentLiveBindingLease, + ) -> Result<(), AgentLiveHostError> { + self.revoke_exact_owner(&AgentLiveLeaseOwner::from_binding(revoked)) + .await + .map_err(|_| AgentLiveHostError::BoundContextRevoked) + } +} + +async fn resolve_expected_owner( + state: &AgentLiveTauriState, + user_id: &str, + expected_lease: &AgentExpectedLiveLease, +) -> Result<(EnabledAgentLiveTauriRuntime, AgentLiveLeaseOwner), AgentLiveAttachError> { + validate_bounded_id(user_id, 512, "Agent user ID is invalid")?; + let runtime = state.enabled()?; + let owner = runtime + .owner_resolver + .resolve_current_owner(user_id) + .await?; + owner.validate()?; + expected_lease.validate_against(&owner)?; + Ok((runtime, owner)) +} + +#[tauri::command] +pub(crate) async fn agent_begin_session_history_attach( + state: State<'_, AgentLiveTauriState>, + user_id: String, + request: AgentHistoryPageRequest, + expected_lease: AgentExpectedLiveLease, + events: Channel, +) -> Result { + let (runtime, owner) = resolve_expected_owner(state.inner(), &user_id, &expected_lease).await?; + runtime + .manager + .begin(owner, request, tauri_agent_live_sender(events)) + .await +} + +#[tauri::command] +pub(crate) async fn agent_activate_session_history_attach( + state: State<'_, AgentLiveTauriState>, + user_id: String, + attach_id: String, + expected_lease: AgentExpectedLiveLease, +) -> Result { + let (runtime, owner) = resolve_expected_owner(state.inner(), &user_id, &expected_lease).await?; + runtime.manager.activate(owner, &attach_id).await +} + +#[tauri::command] +pub(crate) async fn agent_cancel_session_history_attach( + state: State<'_, AgentLiveTauriState>, + user_id: String, + attach_id: String, + expected_lease: AgentExpectedLiveLease, +) -> Result<(), AgentLiveAttachError> { + let (runtime, owner) = resolve_expected_owner(state.inner(), &user_id, &expected_lease).await?; + runtime.manager.cancel(owner, &attach_id).await +} + +#[tauri::command] +pub(crate) async fn agent_resume_live_events( + state: State<'_, AgentLiveTauriState>, + user_id: String, + cursor: AgentLiveEventCursor, + expected_lease: AgentExpectedLiveLease, + events: Channel, +) -> Result { + let (runtime, owner) = resolve_expected_owner(state.inner(), &user_id, &expected_lease).await?; + runtime + .manager + .resume(owner, cursor, tauri_agent_live_sender(events)) + .await +} + +#[tauri::command] +pub(crate) async fn agent_cancel_live_events( + state: State<'_, AgentLiveTauriState>, + user_id: String, + live_stream_id: String, + expected_lease: AgentExpectedLiveLease, +) -> Result<(), AgentLiveAttachError> { + let (runtime, owner) = resolve_expected_owner(state.inner(), &user_id, &expected_lease).await?; + runtime + .manager + .cancel_live_events(owner, &live_stream_id) + .await +} + +/// Object-safe pending token used so lifecycle tests do not need to fabricate +/// coordinator-private subscriber IDs. +#[async_trait] +pub(crate) trait AgentLivePendingAttach: Send { + async fn finalize(self: Box) -> Result; + + async fn cancel(self: Box) -> Result<(), AgentLiveAttachError>; +} + +#[async_trait] +pub(crate) trait AgentLiveProviderStream: Send { + async fn recv(&mut self) -> Result; + + async fn unsubscribe(self: Box) -> Result<(), AgentLiveAttachError>; +} + +pub(crate) struct AgentLiveProviderHeadAttach { + pub(crate) through_cursor: LiveEventCursor, + pub(crate) live_sessions_complete: bool, + pub(crate) live_sessions: Vec, + pub(crate) token: Box, +} + +pub(crate) struct AgentLiveProviderResume { + pub(crate) through_cursor: LiveEventCursor, + pub(crate) stream: Box, +} + +struct CoordinatorPendingAttach { + token: Option, +} + +#[async_trait] +impl AgentLivePendingAttach for CoordinatorPendingAttach { + async fn finalize( + mut self: Box, + ) -> Result { + let token = self + .token + .take() + .ok_or(AgentLiveAttachError::AttachNotFound)?; + token + .finalize() + .await + .map(coordinator_resume) + .map_err(map_coordinator_error) + } + + async fn cancel(mut self: Box) -> Result<(), AgentLiveAttachError> { + let token = self + .token + .take() + .ok_or(AgentLiveAttachError::AttachNotFound)?; + token.cancel().await.map_err(map_coordinator_error) + } +} + +struct CoordinatorProviderStream { + subscription: AgentLiveSubscription, +} + +#[async_trait] +impl AgentLiveProviderStream for CoordinatorProviderStream { + async fn recv(&mut self) -> Result { + self.subscription.recv().await + } + + async fn unsubscribe(self: Box) -> Result<(), AgentLiveAttachError> { + self.subscription + .unsubscribe() + .await + .map_err(map_coordinator_error) + } +} + +pub(crate) fn coordinator_head_attach(attach: AgentHeadAttach) -> AgentLiveProviderHeadAttach { + AgentLiveProviderHeadAttach { + through_cursor: attach.through_cursor, + live_sessions_complete: attach.live_sessions_complete, + live_sessions: attach.live_sessions, + token: Box::new(CoordinatorPendingAttach { + token: Some(attach.token), + }), + } +} + +pub(crate) fn coordinator_resume(resume: AgentLiveResume) -> AgentLiveProviderResume { + AgentLiveProviderResume { + through_cursor: resume.through_cursor, + stream: Box::new(CoordinatorProviderStream { + subscription: resume.subscription, + }), + } +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentLiveAttachManagerConfig { + pub(crate) pending_ttl: Duration, + pub(crate) subscription_capacity: usize, + pub(crate) max_pending_per_account_target: usize, + pub(crate) max_pending_total: usize, +} + +impl Default for AgentLiveAttachManagerConfig { + fn default() -> Self { + Self { + pending_ttl: DEFAULT_PENDING_ATTACH_TTL, + subscription_capacity: DEFAULT_SUBSCRIPTION_CAPACITY, + max_pending_per_account_target: MAX_PENDING_ATTACHES_PER_ACCOUNT_TARGET, + max_pending_total: MAX_PENDING_ATTACHES_TOTAL, + } + } +} + +impl AgentLiveAttachManagerConfig { + fn validate(&self) -> Result<(), AgentLiveAttachError> { + if self.pending_ttl.is_zero() + || self.subscription_capacity == 0 + || self.max_pending_per_account_target == 0 + || self.max_pending_total == 0 + || self.max_pending_per_account_target > self.max_pending_total + { + return Err(AgentLiveAttachError::InvalidRequest { + message: "Agent live attachment limits are invalid", + }); + } + Ok(()) + } +} + +#[derive(Clone)] +pub(crate) struct AgentLiveAttachManager { + inner: Arc, +} + +struct AgentLiveAttachManagerInner { + provider: Arc, + projector: Arc, + config: AgentLiveAttachManagerConfig, + state: Mutex, +} + +#[derive(Default)] +struct AgentLiveAttachState { + reservations: HashMap, + pending: HashMap, + activating: HashMap, + active: HashMap, +} + +struct PendingAttach { + owner: AgentLiveLeaseOwner, + through_cursor: LiveEventCursor, + token: Box, + sender: Arc, + expires_at: Instant, +} + +struct ActiveStream { + stream_id: String, + owner: AgentLiveLeaseOwner, + send_fence: Arc>, + cancel: Option>, + task: JoinHandle<()>, +} + +struct ActivatingStream { + operation_id: String, + owner: AgentLiveLeaseOwner, + owner_key: AccountTargetKey, + send_fence: Arc>, + cancel: Option>, + done: Option>, +} + +#[derive(Clone)] +struct ActiveStreamIdentity { + key: AccountTargetKey, + stream_id: String, + owner: AgentLiveLeaseOwner, +} + +struct ActivationReservation { + lineage_key: AccountTargetLineageKey, + owner_key: AccountTargetKey, + operation_id: String, + owner: AgentLiveLeaseOwner, + superseded: Option, + send_fence: Arc>, + cancel: oneshot::Receiver<()>, + done: Option>, +} + +enum ActivationSource { + Pending(PendingAttach), + Resume { + from: LiveEventCursor, + sender: Arc, + }, +} + +#[derive(Default)] +struct CleanupBatch { + pending: Vec>, + activating: Vec, + active: Vec, +} + +impl CleanupBatch { + async fn run(mut self) { + for activating in &self.activating { + close_send_fence(&activating.send_fence); + } + for active in &self.active { + close_send_fence(&active.send_fence); + } + for activating in &mut self.activating { + if let Some(cancel) = activating.cancel.take() { + let _ = cancel.send(()); + } + } + for active in &mut self.active { + if let Some(cancel) = active.cancel.take() { + let _ = cancel.send(()); + } + } + for token in self.pending { + let _ = token.cancel().await; + } + for mut activating in self.activating { + if let Some(done) = activating.done.take() { + let _ = done.await; + } + } + for active in self.active { + let _ = active.task.await; + } + } + + fn is_empty(&self) -> bool { + self.pending.is_empty() && self.activating.is_empty() && self.active.is_empty() + } +} + +struct PendingTokenGuard { + token: Option>, +} + +impl PendingTokenGuard { + fn new(token: Box) -> Self { + Self { token: Some(token) } + } + + fn take(&mut self) -> Result, AgentLiveAttachError> { + self.token.take().ok_or(AgentLiveAttachError::Unavailable) + } + + async fn cancel(mut self) { + if let Some(token) = self.token.take() { + let _ = token.cancel().await; + } + } +} + +impl Drop for PendingTokenGuard { + fn drop(&mut self) { + if let Some(token) = self.token.take() { + spawn_pending_cancel(token); + } + } +} + +struct ProviderStreamGuard { + stream: Option>, +} + +impl ProviderStreamGuard { + fn new(stream: Box) -> Self { + Self { + stream: Some(stream), + } + } + + async fn recv(&mut self) -> Result { + match self.stream.as_mut() { + Some(stream) => stream.recv().await, + None => Err(AgentLiveReceiveError::Closed), + } + } + + async fn unsubscribe(mut self) { + if let Some(stream) = self.stream.take() { + let _ = stream.unsubscribe().await; + } + } + + fn take(&mut self) -> Result, AgentLiveAttachError> { + self.stream.take().ok_or(AgentLiveAttachError::Unavailable) + } +} + +impl Drop for ProviderStreamGuard { + fn drop(&mut self) { + if let Some(stream) = self.stream.take() { + spawn_stream_unsubscribe(stream); + } + } +} + +/// Releases an in-flight capacity reservation if the async begin future is +/// cancelled at any await point before the paused token is committed. +struct PendingReservationGuard { + inner: Weak, + attach_id: String, + owner: AgentLiveLeaseOwner, + committed: bool, +} + +impl PendingReservationGuard { + fn new( + inner: &Arc, + attach_id: String, + owner: AgentLiveLeaseOwner, + ) -> Self { + Self { + inner: Arc::downgrade(inner), + attach_id, + owner, + committed: false, + } + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for PendingReservationGuard { + fn drop(&mut self) { + if self.committed { + return; + } + let Some(inner) = self.inner.upgrade() else { + return; + }; + let Ok(mut state) = inner.state.lock() else { + return; + }; + if state + .reservations + .get(&self.attach_id) + .is_some_and(|owner| owner == &self.owner) + { + state.reservations.remove(&self.attach_id); + } + } +} + +struct ActivationDoneGuard { + inner: Weak, + lineage_key: AccountTargetLineageKey, + operation_id: String, + send_fence: Arc>, + committed_active: bool, + done: Option>, +} + +impl ActivationDoneGuard { + fn new( + inner: &Arc, + reservation: &mut ActivationReservation, + ) -> Self { + Self { + inner: Arc::downgrade(inner), + lineage_key: reservation.lineage_key.clone(), + operation_id: reservation.operation_id.clone(), + send_fence: Arc::clone(&reservation.send_fence), + committed_active: false, + done: reservation.done.take(), + } + } + + fn commit_active(&mut self) { + self.committed_active = true; + } +} + +impl Drop for ActivationDoneGuard { + fn drop(&mut self) { + if let Some(inner) = self.inner.upgrade() { + if let Ok(mut state) = inner.state.lock() { + if state + .activating + .get(&self.lineage_key) + .is_some_and(|activating| activating.operation_id == self.operation_id) + { + state.activating.remove(&self.lineage_key); + } + } + } + if !self.committed_active { + close_send_fence(&self.send_fence); + } + if let Some(done) = self.done.take() { + let _ = done.send(()); + } + } +} + +impl Drop for AgentLiveAttachManagerInner { + fn drop(&mut self) { + if let Ok(mut state) = self.state.lock() { + let cleanup = CleanupBatch { + pending: state + .pending + .drain() + .map(|(_, pending)| pending.token) + .collect(), + activating: state + .activating + .drain() + .map(|(_, activating)| activating) + .collect(), + active: state.active.drain().map(|(_, active)| active).collect(), + }; + state.reservations.clear(); + drop(state); + spawn_cleanup(cleanup); + } + } +} + +impl AgentLiveAttachManager { + pub(crate) fn new( + provider: Arc, + projector: Arc, + ) -> Self { + Self::with_config(provider, projector, AgentLiveAttachManagerConfig::default()) + .expect("default Agent live attachment limits must be valid") + } + + pub(crate) fn with_config( + provider: Arc, + projector: Arc, + config: AgentLiveAttachManagerConfig, + ) -> Result { + config.validate()?; + Ok(Self { + inner: Arc::new(AgentLiveAttachManagerInner { + provider, + projector, + config, + state: Mutex::new(AgentLiveAttachState::default()), + }), + }) + } + + pub(crate) async fn begin( + &self, + owner: AgentLiveLeaseOwner, + request: AgentHistoryPageRequest, + sender: Arc, + ) -> Result { + owner.validate()?; + if request.cursor.is_some() { + return Err(AgentLiveAttachError::InvalidRequest { + message: "A synchronized Agent history attach must start at the newest page", + }); + } + validate_bounded_id(&request.session_id, 128, "Agent task ID is invalid")?; + let requested_limit = request.limit.unwrap_or(25); + if !(1..=MAX_HISTORY_RECORDS_PER_PAGE).contains(&requested_limit) { + return Err(AgentLiveAttachError::InvalidRequest { + message: "Agent history page limit must be between 1 and 50", + }); + } + self.inner.provider.validate_lease(&owner).await?; + + let attach_id = owner.with_current_authority(|| self.reserve_pending_slot(&owner))??; + let mut reservation = + PendingReservationGuard::new(&self.inner, attach_id.clone(), owner.clone()); + let begun = match self + .inner + .provider + .begin_account_head_attach(&owner, self.inner.config.subscription_capacity) + .await + { + Ok(begun) => begun, + Err(error) => { + self.release_reservation(&attach_id, &owner); + return Err(error); + } + }; + + let AgentLiveProviderHeadAttach { + through_cursor, + live_sessions_complete, + live_sessions, + token, + } = begun; + let mut token = PendingTokenGuard::new(token); + let snapshots = match validate_and_restore_snapshot(live_sessions_complete, live_sessions) { + Ok(snapshots) => snapshots, + Err(error) => { + self.release_reservation(&attach_id, &owner); + token.cancel().await; + return Err(error); + } + }; + let through_event_cursor = cursor_to_wire(&through_cursor); + + let page = match self.inner.provider.list_history_page(&owner, request).await { + Ok(page) => match project_safe_history_page(page, requested_limit) { + Ok(page) => page, + Err(error) => { + self.release_reservation(&attach_id, &owner); + token.cancel().await; + return Err(error); + } + }, + Err(error) => { + self.release_reservation(&attach_id, &owner); + token.cancel().await; + return Err(error); + } + }; + if let Err(error) = self.inner.provider.validate_lease(&owner).await { + self.release_reservation(&attach_id, &owner); + token.cancel().await; + return Err(error); + } + + let live_session_count = snapshots.len(); + let pending = PendingAttach { + owner: owner.clone(), + through_cursor, + token: token.take()?, + sender, + expires_at: Instant::now() + self.inner.config.pending_ttl, + }; + if let Err((error, pending)) = self.finish_reservation(&attach_id, pending) { + self.release_reservation(&attach_id, &owner); + let _ = pending.token.cancel().await; + return Err(error); + } + reservation.commit(); + self.spawn_expiry(attach_id.clone(), owner.clone()); + + Ok(AgentBeginSessionHistoryAttachResponse { + attach_id, + page, + live_sessions_complete: true, + live_session_count, + live_sessions: snapshots, + through_event_cursor, + }) + } + + pub(crate) async fn activate( + &self, + owner: AgentLiveLeaseOwner, + attach_id: &str, + ) -> Result { + owner.validate()?; + self.inner.provider.validate_lease(&owner).await?; + let (pending, reservation) = self.reserve_pending_activation(&owner, attach_id)?; + self.spawn_activation(reservation, ActivationSource::Pending(pending)) + .await + } + + /// Idempotent for an already-cancelled or unknown ID, but never permits a + /// caller with a stale owner to cancel a current owner's opaque lease. + pub(crate) async fn cancel( + &self, + owner: AgentLiveLeaseOwner, + attach_id: &str, + ) -> Result<(), AgentLiveAttachError> { + owner.validate()?; + validate_attach_or_stream_id(attach_id, "Agent live attachment ID is invalid")?; + self.inner.provider.validate_lease(&owner).await?; + let cleanup = + owner.with_current_authority(|| self.fence_attachment(&owner, attach_id))??; + cleanup.run().await; + Ok(()) + } + + pub(crate) async fn resume( + &self, + owner: AgentLiveLeaseOwner, + cursor: AgentLiveEventCursor, + sender: Arc, + ) -> Result { + owner.validate()?; + self.inner.provider.validate_lease(&owner).await?; + let from = wire_to_cursor(&cursor)?; + let live_stream_id = random_opaque_id()?; + let reservation = owner + .with_current_authority(|| self.reserve_resume_activation(&owner, &live_stream_id))??; + self.spawn_activation(reservation, ActivationSource::Resume { from, sender }) + .await + } + + /// Abort the exact active channel retained by an attached UI. Unknown or + /// already-removed IDs are idempotent only when there is no different + /// active stream for this current account+target. + pub(crate) async fn cancel_live_events( + &self, + owner: AgentLiveLeaseOwner, + live_stream_id: &str, + ) -> Result<(), AgentLiveAttachError> { + owner.validate()?; + validate_attach_or_stream_id(live_stream_id, "Agent live stream ID is invalid")?; + self.inner.provider.validate_lease(&owner).await?; + let cleanup = + owner.with_current_authority(|| self.fence_live_stream(&owner, live_stream_id))??; + cleanup.run().await; + Ok(()) + } + + /// Trusted binding-transition hook. It fences every matching lifecycle + /// phase, then awaits paused-token cancellation and stream unsubscribe. + pub(crate) async fn revoke_owner(&self, owner: &AgentLiveLeaseOwner) { + let cleanup = { + let Ok(mut state) = self.inner.state.lock() else { + return; + }; + state + .reservations + .retain(|_, reserved_owner| reserved_owner != owner); + let mut cleanup = CleanupBatch::default(); + let pending_ids = state + .pending + .iter() + .filter_map(|(attach_id, pending)| { + (&pending.owner == owner).then_some(attach_id.clone()) + }) + .collect::>(); + for attach_id in pending_ids { + if let Some(pending) = state.pending.remove(&attach_id) { + cleanup.pending.push(pending.token); + } + } + let activating_keys = state + .activating + .iter() + .filter_map(|(key, activating)| (&activating.owner == owner).then_some(key.clone())) + .collect::>(); + for key in activating_keys { + if let Some(activating) = state.activating.remove(&key) { + cleanup.activating.push(activating); + } + } + let active_keys = state + .active + .iter() + .filter_map(|(key, active)| (&active.owner == owner).then_some(key.clone())) + .collect::>(); + for key in active_keys { + if let Some(active) = state.active.remove(&key) { + cleanup.active.push(active); + } + } + cleanup + }; + cleanup.run().await; + } + + fn reserve_pending_slot( + &self, + owner: &AgentLiveLeaseOwner, + ) -> Result { + let (result, expired) = { + let mut state = self.lock_state()?; + let expired = take_expired_pending(&mut state); + let result = (|| { + let total = state + .pending + .len() + .checked_add(state.reservations.len()) + .and_then(|count| count.checked_add(state.activating.len())) + .ok_or(AgentLiveAttachError::CapacityExceeded)?; + if total >= self.inner.config.max_pending_total { + return Err(AgentLiveAttachError::CapacityExceeded); + } + let key = owner.stream_lineage_key(); + let per_account = state + .pending + .values() + .filter(|pending| pending.owner.stream_lineage_key() == key) + .count() + + state + .reservations + .values() + .filter(|reserved_owner| reserved_owner.stream_lineage_key() == key) + .count() + + usize::from(state.activating.contains_key(&key)); + if per_account >= self.inner.config.max_pending_per_account_target { + return Err(AgentLiveAttachError::CapacityExceeded); + } + let attach_id = allocate_attach_id(&state)?; + state.reservations.insert(attach_id.clone(), owner.clone()); + Ok(attach_id) + })(); + (result, expired) + }; + spawn_pending_cancels(expired); + result + } + + fn finish_reservation( + &self, + attach_id: &str, + pending: PendingAttach, + ) -> Result<(), (AgentLiveAttachError, PendingAttach)> { + let mut state = match self.lock_state() { + Ok(state) => state, + Err(error) => return Err((error, pending)), + }; + match state.reservations.get(attach_id) { + Some(owner) if owner == &pending.owner => { + if state.pending.contains_key(attach_id) { + return Err((AgentLiveAttachError::Unavailable, pending)); + } + state.reservations.remove(attach_id); + state.pending.insert(attach_id.to_string(), pending); + Ok(()) + } + _ => Err((AgentLiveAttachError::StaleLease, pending)), + } + } + + fn release_reservation(&self, attach_id: &str, owner: &AgentLiveLeaseOwner) { + if let Ok(mut state) = self.inner.state.lock() { + if state + .reservations + .get(attach_id) + .is_some_and(|reserved| reserved == owner) + { + state.reservations.remove(attach_id); + } + } + } + + fn spawn_expiry(&self, attach_id: String, owner: AgentLiveLeaseOwner) { + let weak = Arc::downgrade(&self.inner); + let ttl = self.inner.config.pending_ttl; + tokio::spawn(async move { + tokio::time::sleep(ttl).await; + let Some(inner) = weak.upgrade() else { + return; + }; + let pending = { + let Ok(mut state) = inner.state.lock() else { + return; + }; + if state.pending.get(&attach_id).is_some_and(|pending| { + pending.owner == owner && Instant::now() >= pending.expires_at + }) { + state.pending.remove(&attach_id) + } else { + None + } + }; + if let Some(pending) = pending { + let _ = pending.token.cancel().await; + } + }); + } + + fn reserve_pending_activation( + &self, + owner: &AgentLiveLeaseOwner, + attach_id: &str, + ) -> Result<(PendingAttach, ActivationReservation), AgentLiveAttachError> { + validate_attach_or_stream_id(attach_id, "Agent live attachment ID is invalid")?; + let (result, expired) = { + let mut state = self.lock_state()?; + let expired = take_expired_pending(&mut state); + let result = (|| { + let pending = state + .pending + .get(attach_id) + .ok_or(AgentLiveAttachError::AttachNotFound)?; + if &pending.owner != owner { + return Err(AgentLiveAttachError::StaleLease); + } + let reservation = reserve_activation_locked(&mut state, owner, attach_id)?; + let pending = state + .pending + .remove(attach_id) + .ok_or(AgentLiveAttachError::AttachNotFound)?; + Ok((pending, reservation)) + })(); + (result, expired) + }; + spawn_pending_cancels(expired); + result + } + + fn reserve_resume_activation( + &self, + owner: &AgentLiveLeaseOwner, + live_stream_id: &str, + ) -> Result { + let (result, expired) = { + let mut state = self.lock_state()?; + let expired = take_expired_pending(&mut state); + let result = if state_id_in_use(&state, live_stream_id) { + Err(AgentLiveAttachError::Unavailable) + } else { + reserve_activation_locked(&mut state, owner, live_stream_id) + }; + (result, expired) + }; + spawn_pending_cancels(expired); + result + } + + async fn spawn_activation( + &self, + reservation: ActivationReservation, + source: ActivationSource, + ) -> Result { + let (result, response) = oneshot::channel(); + let inner = Arc::clone(&self.inner); + tokio::spawn(async move { + run_activation(inner, reservation, source, result).await; + }); + response + .await + .map_err(|_| AgentLiveAttachError::Unavailable)? + } + + fn fence_attachment( + &self, + owner: &AgentLiveLeaseOwner, + attach_id: &str, + ) -> Result { + let mut state = self.lock_state()?; + if state + .reservations + .get(attach_id) + .is_some_and(|reserved| reserved != owner) + || state + .pending + .get(attach_id) + .is_some_and(|pending| &pending.owner != owner) + || state.activating.values().any(|activating| { + activating.operation_id == attach_id && &activating.owner != owner + }) + || state + .active + .values() + .any(|active| active.stream_id == attach_id && &active.owner != owner) + { + return Err(AgentLiveAttachError::StaleLease); + } + let mut cleanup = CleanupBatch::default(); + cleanup.pending.extend( + take_expired_pending(&mut state) + .into_iter() + .map(|pending| pending.token), + ); + state.reservations.remove(attach_id); + if let Some(pending) = state.pending.remove(attach_id) { + cleanup.pending.push(pending.token); + } + if let Some(key) = state.activating.iter().find_map(|(key, activating)| { + (activating.operation_id == attach_id).then_some(key.clone()) + }) { + if let Some(activating) = state.activating.remove(&key) { + cleanup.activating.push(activating); + } + } + if let Some(key) = state + .active + .iter() + .find_map(|(key, active)| (active.stream_id == attach_id).then_some(key.clone())) + { + if let Some(active) = state.active.remove(&key) { + cleanup.active.push(active); + } + } + Ok(cleanup) + } + + fn fence_live_stream( + &self, + owner: &AgentLiveLeaseOwner, + live_stream_id: &str, + ) -> Result { + let mut state = self.lock_state()?; + let lineage_key = owner.stream_lineage_key(); + if state + .active + .values() + .any(|active| active.stream_id == live_stream_id && &active.owner != owner) + || state.activating.values().any(|activating| { + activating.operation_id == live_stream_id && &activating.owner != owner + }) + { + return Err(AgentLiveAttachError::StaleLease); + } + if state + .activating + .get(&lineage_key) + .is_some_and(|activating| activating.operation_id != live_stream_id) + || active_for_lineage(&state, &lineage_key)? + .is_some_and(|active| active.stream_id != live_stream_id) + { + return Err(AgentLiveAttachError::StaleLease); + } + let mut cleanup = CleanupBatch::default(); + cleanup.pending.extend( + take_expired_pending(&mut state) + .into_iter() + .map(|pending| pending.token), + ); + if state + .activating + .get(&lineage_key) + .is_some_and(|activating| activating.operation_id == live_stream_id) + { + if let Some(activating) = state.activating.remove(&lineage_key) { + cleanup.activating.push(activating); + } + } + if let Some(key) = state.active.iter().find_map(|(key, active)| { + (active.stream_id == live_stream_id && active.owner.stream_lineage_key() == lineage_key) + .then_some(key.clone()) + }) { + if let Some(active) = state.active.remove(&key) { + cleanup.active.push(active); + } + } + Ok(cleanup) + } + + fn lock_state(&self) -> Result, AgentLiveAttachError> { + self.inner + .state + .lock() + .map_err(|_| AgentLiveAttachError::Unavailable) + } +} + +fn reserve_activation_locked( + state: &mut AgentLiveAttachState, + owner: &AgentLiveLeaseOwner, + operation_id: &str, +) -> Result { + let lineage_key = owner.stream_lineage_key(); + let owner_key = owner.stream_key(); + if state.activating.contains_key(&lineage_key) { + return Err(AgentLiveAttachError::StaleLease); + } + if state + .active + .get(&owner_key) + .is_some_and(|active| active.owner != *owner) + { + return Err(AgentLiveAttachError::StaleLease); + } + let superseded = active_identity_for_lineage(state, &lineage_key)?; + let send_fence = Arc::new(Mutex::new(false)); + let (cancel_send, cancel) = oneshot::channel(); + let (done, done_receive) = oneshot::channel(); + state.activating.insert( + lineage_key.clone(), + ActivatingStream { + operation_id: operation_id.to_string(), + owner: owner.clone(), + owner_key: owner_key.clone(), + send_fence: Arc::clone(&send_fence), + cancel: Some(cancel_send), + done: Some(done_receive), + }, + ); + Ok(ActivationReservation { + lineage_key, + owner_key, + operation_id: operation_id.to_string(), + owner: owner.clone(), + superseded, + send_fence, + cancel, + done: Some(done), + }) +} + +fn active_identity_for_lineage( + state: &AgentLiveAttachState, + lineage_key: &AccountTargetLineageKey, +) -> Result, AgentLiveAttachError> { + let mut matches = state + .active + .iter() + .filter(|(_, active)| active.owner.stream_lineage_key() == *lineage_key); + let Some((key, active)) = matches.next() else { + return Ok(None); + }; + if matches.next().is_some() { + return Err(AgentLiveAttachError::Unavailable); + } + Ok(Some(ActiveStreamIdentity { + key: key.clone(), + stream_id: active.stream_id.clone(), + owner: active.owner.clone(), + })) +} + +fn active_for_lineage<'a>( + state: &'a AgentLiveAttachState, + lineage_key: &AccountTargetLineageKey, +) -> Result, AgentLiveAttachError> { + let mut matches = state + .active + .values() + .filter(|active| active.owner.stream_lineage_key() == *lineage_key); + let active = matches.next(); + if matches.next().is_some() { + return Err(AgentLiveAttachError::Unavailable); + } + Ok(active) +} + +fn state_id_in_use(state: &AgentLiveAttachState, id: &str) -> bool { + state.reservations.contains_key(id) + || state.pending.contains_key(id) + || state + .activating + .values() + .any(|activating| activating.operation_id == id) + || state.active.values().any(|active| active.stream_id == id) +} + +fn take_expired_pending(state: &mut AgentLiveAttachState) -> Vec { + let now = Instant::now(); + let expired = state + .pending + .iter() + .filter_map(|(attach_id, pending)| (pending.expires_at <= now).then_some(attach_id.clone())) + .collect::>(); + expired + .into_iter() + .filter_map(|attach_id| state.pending.remove(&attach_id)) + .collect() +} + +fn spawn_pending_cancels(pending: Vec) { + if pending.is_empty() { + return; + } + spawn_cleanup(CleanupBatch { + pending: pending.into_iter().map(|pending| pending.token).collect(), + ..CleanupBatch::default() + }); +} + +fn spawn_pending_cancel(token: Box) { + spawn_cleanup(CleanupBatch { + pending: vec![token], + ..CleanupBatch::default() + }); +} + +fn spawn_stream_unsubscribe(stream: Box) { + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = stream.unsubscribe().await; + }); + } +} + +fn close_send_fence(send_fence: &Arc>) { + if let Ok(mut closed) = send_fence.lock() { + *closed = true; + } +} + +fn spawn_cleanup(mut cleanup: CleanupBatch) { + if cleanup.is_empty() { + return; + } + match tokio::runtime::Handle::try_current() { + Ok(runtime) => { + runtime.spawn(cleanup.run()); + } + Err(_) => { + for activating in &cleanup.activating { + close_send_fence(&activating.send_fence); + } + for active in &cleanup.active { + close_send_fence(&active.send_fence); + } + for activating in &mut cleanup.activating { + if let Some(cancel) = activating.cancel.take() { + let _ = cancel.send(()); + } + } + for active in &mut cleanup.active { + if let Some(cancel) = active.cancel.take() { + let _ = cancel.send(()); + } + } + } + } +} + +fn activation_cancelled(cancel: &mut oneshot::Receiver<()>) -> bool { + match cancel.try_recv() { + Ok(()) | Err(oneshot::error::TryRecvError::Closed) => true, + Err(oneshot::error::TryRecvError::Empty) => false, + } +} + +async fn run_activation( + inner: Arc, + mut reservation: ActivationReservation, + source: ActivationSource, + mut response: oneshot::Sender>, +) { + let mut done = ActivationDoneGuard::new(&inner, &mut reservation); + let owner = reservation.owner.clone(); + let operation_id = reservation.operation_id.clone(); + let result = execute_activation(&inner, &mut reservation, source, &mut response).await; + let installed = result.is_ok(); + if installed { + done.commit_active(); + } + if response.send(result).is_err() && installed { + let cleanup = fence_installed_stream(&inner, &owner, &operation_id); + cleanup.run().await; + } +} + +async fn execute_activation( + inner: &Arc, + reservation: &mut ActivationReservation, + source: ActivationSource, + response: &mut oneshot::Sender>, +) -> Result { + let (from, sender, resume) = match source { + ActivationSource::Pending(pending) => { + if activation_cancelled(&mut reservation.cancel) || response.is_closed() { + let _ = pending.token.cancel().await; + return Err(AgentLiveAttachError::AttachNotFound); + } + let from = pending.through_cursor; + let sender = pending.sender; + let resume = pending.token.finalize().await?; + if response.is_closed() || activation_cancelled(&mut reservation.cancel) { + let _ = resume.stream.unsubscribe().await; + return Err(AgentLiveAttachError::AttachNotFound); + } + (from, sender, resume) + } + ActivationSource::Resume { from, sender } => { + if activation_cancelled(&mut reservation.cancel) || response.is_closed() { + return Err(AgentLiveAttachError::AttachNotFound); + } + // Do not cancel this future after it may have registered a + // subscriber. A concurrent fence is observed immediately after + // the actor returns the exact stream, which is then unsubscribed. + let resume = inner + .provider + .begin_resume( + &reservation.owner, + from.clone(), + inner.config.subscription_capacity, + ) + .await?; + if response.is_closed() || activation_cancelled(&mut reservation.cancel) { + let _ = resume.stream.unsubscribe().await; + return Err(AgentLiveAttachError::AttachNotFound); + } + (from, sender, resume) + } + }; + + let through = resume.through_cursor.clone(); + let mut stream = ProviderStreamGuard::new(resume.stream); + if activation_cancelled(&mut reservation.cancel) || response.is_closed() { + stream.unsubscribe().await; + return Err(AgentLiveAttachError::AttachNotFound); + } + if let Err(error) = validate_cursor_range(&from, &through) { + stream.unsubscribe().await; + return Err(error); + } + if let Err(error) = queue_activation_replay( + inner, + reservation, + response, + &sender, + &from, + &through, + &mut stream, + ) + .await + { + stream.unsubscribe().await; + return Err(error); + } + if let Err(error) = validate_activation_lease(inner, reservation, response).await { + stream.unsubscribe().await; + return Err(error); + } + + let previous = match fence_superseded_active(inner, reservation) { + Ok(previous) => previous, + Err(error) => { + stream.unsubscribe().await; + return Err(error); + } + }; + previous.run().await; + if activation_cancelled(&mut reservation.cancel) || response.is_closed() { + stream.unsubscribe().await; + return Err(AgentLiveAttachError::AttachNotFound); + } + if let Err(error) = validate_activation_lease(inner, reservation, response).await { + stream.unsubscribe().await; + return Err(error); + } + + if response.is_closed() { + stream.unsubscribe().await; + return Err(AgentLiveAttachError::AttachNotFound); + } + + let provider_stream = stream.take()?; + if let Err(error) = + install_reserved_active(inner, reservation, sender, through.clone(), provider_stream).await + { + return Err(error); + } + Ok(AgentLiveBarrierResponse { + through_event_cursor: cursor_to_wire(&through), + live_stream_id: reservation.operation_id.clone(), + }) +} + +async fn validate_activation_lease( + inner: &AgentLiveAttachManagerInner, + reservation: &mut ActivationReservation, + response: &mut oneshot::Sender>, +) -> Result<(), AgentLiveAttachError> { + tokio::select! { + biased; + _ = &mut reservation.cancel => Err(AgentLiveAttachError::AttachNotFound), + _ = response.closed() => Err(AgentLiveAttachError::AttachNotFound), + result = inner.provider.validate_lease(&reservation.owner) => result, + } +} + +async fn queue_activation_replay( + inner: &AgentLiveAttachManagerInner, + reservation: &mut ActivationReservation, + response: &mut oneshot::Sender>, + sender: &Arc, + from: &LiveEventCursor, + through: &LiveEventCursor, + stream: &mut ProviderStreamGuard, +) -> Result<(), AgentLiveAttachError> { + let mut last = from.clone(); + while last.sequence() < through.sequence() { + validate_activation_lease(inner, reservation, response).await?; + let delivery = tokio::select! { + biased; + _ = &mut reservation.cancel => return Err(AgentLiveAttachError::AttachNotFound), + _ = response.closed() => return Err(AgentLiveAttachError::AttachNotFound), + delivery = stream.recv() => delivery.map_err(map_receive_error)?, + }; + validate_next_delivery(&last, &delivery.cursor, through)?; + validate_activation_lease(inner, reservation, response).await?; + let frame = project_ordered_event(&*inner.projector, &reservation.owner, &delivery)?; + if response.is_closed() { + return Err(AgentLiveAttachError::AttachNotFound); + } + send_activating_if_current( + inner, + reservation, + sender, + AgentLiveChannelFrame::Event(frame), + )?; + last = delivery.cursor; + } + if last != *through { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + Ok(()) +} + +fn fence_superseded_active( + inner: &AgentLiveAttachManagerInner, + reservation: &ActivationReservation, +) -> Result { + reservation.owner.with_current_authority(|| { + let mut state = inner + .state + .lock() + .map_err(|_| AgentLiveAttachError::Unavailable)?; + ensure_activation_current(&state, reservation)?; + let current = active_identity_for_lineage(&state, &reservation.lineage_key)?; + match (&reservation.superseded, current) { + (None, None) => Ok(CleanupBatch::default()), + (Some(_), None) => Ok(CleanupBatch::default()), + (None, Some(_)) => Err(AgentLiveAttachError::StaleLease), + (Some(expected), Some(current)) + if expected.key == current.key + && expected.stream_id == current.stream_id + && expected.owner == current.owner => + { + let active = state + .active + .remove(¤t.key) + .ok_or(AgentLiveAttachError::StaleLease)?; + Ok(CleanupBatch { + active: vec![active], + ..CleanupBatch::default() + }) + } + (Some(_), Some(_)) => Err(AgentLiveAttachError::StaleLease), + } + })? +} + +async fn install_reserved_active( + inner: &Arc, + reservation: &ActivationReservation, + sender: Arc, + through: LiveEventCursor, + stream: Box, +) -> Result<(), AgentLiveAttachError> { + let key = reservation.owner_key.clone(); + let lineage_key = reservation.lineage_key.clone(); + let stream_id = reservation.operation_id.clone(); + let owner = reservation.owner.clone(); + let weak = Arc::downgrade(inner); + let task_key = key.clone(); + let task_stream_id = stream_id.clone(); + let task_owner = owner.clone(); + let task_send_fence = Arc::clone(&reservation.send_fence); + let (start, started) = oneshot::channel(); + let (cancel, cancelled) = oneshot::channel(); + let task = tokio::spawn(async move { + let mut stream = ProviderStreamGuard::new(stream); + if started.await.is_err() { + stream.unsubscribe().await; + return; + } + run_active_stream( + weak.clone(), + &task_owner, + &task_key, + &task_stream_id, + &task_send_fence, + sender, + through, + &mut stream, + cancelled, + ) + .await; + stream.unsubscribe().await; + remove_active_if_current(&weak, &task_key, &task_stream_id); + }); + let mut active = Some(ActiveStream { + stream_id: stream_id.clone(), + owner: owner.clone(), + send_fence: Arc::clone(&reservation.send_fence), + cancel: Some(cancel), + task, + }); + let commit = owner.with_current_authority(|| { + let mut state = inner + .state + .lock() + .map_err(|_| AgentLiveAttachError::Unavailable)?; + ensure_activation_current(&state, reservation)?; + if active_for_lineage(&state, &lineage_key)?.is_some() { + return Err(AgentLiveAttachError::StaleLease); + } + state.activating.remove(&lineage_key); + state + .active + .insert(key, active.take().ok_or(AgentLiveAttachError::Unavailable)?); + Ok(()) + }); + let commit = match commit { + Ok(result) => result, + Err(error) => Err(error), + }; + if let Err(error) = commit { + drop(start); + if let Some(mut active) = active { + if let Some(cancel) = active.cancel.take() { + let _ = cancel.send(()); + } + let _ = active.task.await; + } + return Err(error); + } + if start.send(()).is_err() { + let cleanup = fence_installed_stream(inner, &owner, &stream_id); + cleanup.run().await; + return Err(AgentLiveAttachError::Unavailable); + } + Ok(()) +} + +fn ensure_activation_current( + state: &AgentLiveAttachState, + reservation: &ActivationReservation, +) -> Result<(), AgentLiveAttachError> { + match state.activating.get(&reservation.lineage_key) { + Some(activating) + if activating.operation_id == reservation.operation_id + && activating.owner == reservation.owner + && activating.owner_key == reservation.owner_key + && Arc::ptr_eq(&activating.send_fence, &reservation.send_fence) => + { + Ok(()) + } + _ => Err(AgentLiveAttachError::StaleLease), + } +} + +fn send_activating_if_current( + inner: &AgentLiveAttachManagerInner, + reservation: &ActivationReservation, + sender: &Arc, + frame: AgentLiveChannelFrame, +) -> Result<(), AgentLiveAttachError> { + reservation.owner.with_current_authority(|| { + let fence = reservation + .send_fence + .lock() + .map_err(|_| AgentLiveAttachError::Unavailable)?; + if *fence { + return Err(AgentLiveAttachError::StaleLease); + } + { + let state = inner + .state + .lock() + .map_err(|_| AgentLiveAttachError::Unavailable)?; + ensure_activation_current(&state, reservation)?; + if !state + .activating + .get(&reservation.lineage_key) + .is_some_and(|activating| { + Arc::ptr_eq(&activating.send_fence, &reservation.send_fence) + }) + { + return Err(AgentLiveAttachError::StaleLease); + } + } + sender.send(frame) + })? +} + +fn project_ordered_event( + projector: &dyn AgentLiveDeliveryProjector, + owner: &AgentLiveLeaseOwner, + delivery: &AgentLiveDelivery, +) -> Result { + let event = projector.project_delivery(delivery)?; + Ok(AgentOrderedLiveEvent { + live_event_version: AGENT_LIVE_PRESENTATION_VERSION, + target_id: owner.target_id.clone(), + host_epoch: owner.connection_stamp.host_epoch().to_string(), + connection_generation: owner.connection_stamp.generation(), + event_epoch: delivery.cursor.journal_id().to_string(), + event_sequence: delivery.cursor.sequence(), + session_id: delivery.session_id.clone(), + run_id: delivery.run_id.clone(), + event, + }) +} + +async fn run_active_stream( + weak: Weak, + owner: &AgentLiveLeaseOwner, + key: &AccountTargetKey, + stream_id: &str, + send_fence: &Arc>, + sender: Arc, + mut last: LiveEventCursor, + stream: &mut ProviderStreamGuard, + mut cancelled: oneshot::Receiver<()>, +) { + loop { + let Some(inner) = weak.upgrade() else { + return; + }; + let validation = tokio::select! { + biased; + _ = &mut cancelled => return, + result = inner.provider.validate_lease(owner) => result, + }; + if validation.is_err() { + send_snapshot_notice_if_current( + &inner, + key, + stream_id, + send_fence, + &sender, + owner, + AgentLiveSnapshotReason::OwnerChanged, + &last, + ); + return; + } + let delivery = tokio::select! { + biased; + _ = &mut cancelled => return, + delivery = stream.recv() => delivery, + }; + let delivery = match delivery { + Ok(delivery) => delivery, + Err(AgentLiveReceiveError::HeadReloadRequired(reason)) => { + send_snapshot_notice_if_current( + &inner, + key, + stream_id, + send_fence, + &sender, + owner, + map_head_reload_reason(reason), + &last, + ); + return; + } + Err(AgentLiveReceiveError::Closed) => { + send_snapshot_notice_if_current( + &inner, + key, + stream_id, + send_fence, + &sender, + owner, + AgentLiveSnapshotReason::OrderingLost, + &last, + ); + return; + } + }; + let validation = tokio::select! { + biased; + _ = &mut cancelled => return, + result = inner.provider.validate_lease(owner) => result, + }; + if validation.is_err() { + send_snapshot_notice_if_current( + &inner, + key, + stream_id, + send_fence, + &sender, + owner, + AgentLiveSnapshotReason::OwnerChanged, + &last, + ); + return; + } + if validate_next_cursor(&last, &delivery.cursor).is_err() { + send_snapshot_notice_if_current( + &inner, + key, + stream_id, + send_fence, + &sender, + owner, + AgentLiveSnapshotReason::OrderingLost, + &last, + ); + return; + } + let ordered = match project_ordered_event(&*inner.projector, owner, &delivery) { + Ok(ordered) => ordered, + Err(_) => { + send_snapshot_notice_if_current( + &inner, + key, + stream_id, + send_fence, + &sender, + owner, + AgentLiveSnapshotReason::OrderingLost, + &last, + ); + return; + } + }; + if send_active_if_current( + &inner, + key, + stream_id, + send_fence, + sender.as_ref(), + owner, + AgentLiveChannelFrame::Event(ordered), + ) + .is_err() + { + return; + } + last = delivery.cursor; + } +} + +fn send_snapshot_notice_if_current( + inner: &AgentLiveAttachManagerInner, + key: &AccountTargetKey, + stream_id: &str, + send_fence: &Arc>, + sender: &Arc, + owner: &AgentLiveLeaseOwner, + reason: AgentLiveSnapshotReason, + last: &LiveEventCursor, +) { + let _ = send_active_if_current( + inner, + key, + stream_id, + send_fence, + sender.as_ref(), + owner, + AgentLiveChannelFrame::SnapshotRequired(AgentLiveSnapshotRequiredFrame { + live_event_version: AGENT_LIVE_PRESENTATION_VERSION, + event_type: "snapshotRequired", + target_id: owner.target_id.clone(), + host_epoch: owner.connection_stamp.host_epoch().to_string(), + connection_generation: owner.connection_stamp.generation(), + reason, + last_event_cursor: cursor_to_wire(last), + }), + ); +} + +fn send_active_if_current( + inner: &AgentLiveAttachManagerInner, + key: &AccountTargetKey, + stream_id: &str, + send_fence: &Arc>, + sender: &dyn AgentLiveEventSender, + owner: &AgentLiveLeaseOwner, + frame: AgentLiveChannelFrame, +) -> Result<(), AgentLiveAttachError> { + owner.with_current_authority(|| { + let fence = send_fence + .lock() + .map_err(|_| AgentLiveAttachError::Unavailable)?; + if *fence { + return Err(AgentLiveAttachError::StaleLease); + } + { + let state = inner + .state + .lock() + .map_err(|_| AgentLiveAttachError::Unavailable)?; + if !state.active.get(key).is_some_and(|active| { + active.stream_id == stream_id + && active.owner == *owner + && Arc::ptr_eq(&active.send_fence, send_fence) + }) { + return Err(AgentLiveAttachError::StaleLease); + } + } + sender.send(frame) + })? +} + +fn remove_active_if_current( + weak: &Weak, + key: &AccountTargetKey, + stream_id: &str, +) { + let Some(inner) = weak.upgrade() else { + return; + }; + let Ok(mut state) = inner.state.lock() else { + return; + }; + if state + .active + .get(key) + .is_some_and(|active| active.stream_id == stream_id) + { + if let Some(active) = state.active.remove(key) { + drop(state); + close_send_fence(&active.send_fence); + } + } +} + +fn fence_installed_stream( + inner: &AgentLiveAttachManagerInner, + owner: &AgentLiveLeaseOwner, + stream_id: &str, +) -> CleanupBatch { + let Ok(mut state) = inner.state.lock() else { + return CleanupBatch::default(); + }; + let key = state.active.iter().find_map(|(key, active)| { + (active.stream_id == stream_id && &active.owner == owner).then_some(key.clone()) + }); + let mut cleanup = CleanupBatch::default(); + if let Some(key) = key { + if let Some(active) = state.active.remove(&key) { + cleanup.active.push(active); + } + } + cleanup +} + +fn project_safe_history_page( + page: AgentHistoryPage, + requested_limit: usize, +) -> Result { + if page.live_items.is_some() || page.through_event_cursor.is_some() { + return Err(AgentLiveAttachError::InvalidRequest { + message: "The persisted Agent history pager returned live attachment fields", + }); + } + if page.records.len() > requested_limit || page.records.len() > MAX_HISTORY_RECORDS_PER_PAGE { + return Err(AgentLiveAttachError::ProjectionRejected); + } + if !is_safe_history_token(&page.history_revision, 512) { + return Err(AgentLiveAttachError::ProjectionRejected); + } + if let Some(next_cursor) = page.next_cursor.as_deref() { + if !is_safe_history_token(next_cursor, 512) { + return Err(AgentLiveAttachError::ProjectionRejected); + } + } + let mut record_ids = std::collections::HashSet::with_capacity(page.records.len()); + let records = page + .records + .into_iter() + .map(|record| { + if !is_safe_history_token(&record.record_id, 512) { + return Err(AgentLiveAttachError::ProjectionRejected); + } + if record.role.is_empty() + || record.role.len() > 128 + || !record + .role + .bytes() + .all(|byte| byte.is_ascii_graphic() || byte == b' ') + { + return Err(AgentLiveAttachError::ProjectionRejected); + } + if record.created_ms > MAX_JAVASCRIPT_SAFE_INTEGER + || !record_ids.insert(record.record_id.clone()) + || record.items.len() > MAX_HISTORY_ITEMS_PER_RECORD + { + return Err(AgentLiveAttachError::ProjectionRejected); + } + let items = record + .items + .iter() + .map(project_timeline_item) + .collect::, _>>() + .map_err(|_| AgentLiveAttachError::ProjectionRejected)?; + for item in &items { + item.validate() + .map_err(|_| AgentLiveAttachError::ProjectionRejected)?; + } + let safe_record = AgentSafeHistoryRecord { + record_id: record.record_id, + role: record.role, + created_ms: record.created_ms, + items, + }; + let mut encoded = SerializedHistoryByteCounter::default(); + ciborium::ser::into_writer(&safe_record, &mut encoded) + .map_err(|_| AgentLiveAttachError::ProjectionRejected)?; + if encoded.bytes > MAX_HISTORY_RECORD_PRESENTATION_BYTES { + return Err(AgentLiveAttachError::HistoryRecordTooLarge); + } + Ok(safe_record) + }) + .collect::, AgentLiveAttachError>>()?; + Ok(AgentSafeHistoryPage { + records, + next_cursor: page.next_cursor, + history_revision: page.history_revision, + }) +} + +fn is_safe_history_token(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +#[derive(Default)] +struct SerializedHistoryByteCounter { + bytes: usize, +} + +impl Write for SerializedHistoryByteCounter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + self.bytes = self + .bytes + .checked_add(buffer.len()) + .ok_or_else(|| std::io::Error::other("serialized safe history length overflow"))?; + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn validate_and_restore_snapshot( + live_sessions_complete: bool, + sessions: Vec, +) -> Result, AgentLiveAttachError> { + if !live_sessions_complete { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + if sessions.len() > MAX_LIVE_SESSIONS { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + let mut previous: Option<&str> = None; + let mut item_count = 0usize; + let mut restored = Vec::with_capacity(sessions.len()); + for session in &sessions { + validate_bounded_id(&session.session_id, 128, "Agent task ID is invalid")?; + if previous.is_some_and(|value| value >= session.session_id.as_str()) { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + previous = Some(&session.session_id); + item_count = item_count + .checked_add(session.live_items.len()) + .ok_or_else(|| snapshot_required(AgentLiveSnapshotReason::OrderingLost))?; + if item_count > MAX_LIVE_ITEMS { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + if session.live_items.len() > 200 { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + let mut previous_item_id = std::collections::HashSet::new(); + let mut live_items = Vec::with_capacity(session.live_items.len()); + for item in &session.live_items { + item.validate() + .map_err(|_| snapshot_required(AgentLiveSnapshotReason::OrderingLost))?; + if item.merge != crate::agent_live_coordinator::MapleLiveMerge::Replace + || !previous_item_id.insert(item.id.as_str()) + { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + live_items.push(item.clone()); + } + restored.push(AgentLiveSessionSnapshot { + session_id: session.session_id.clone(), + live_items, + }); + } + if restored.len() != sessions.len() { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + Ok(restored) +} + +fn validate_cursor_range( + from: &LiveEventCursor, + through: &LiveEventCursor, +) -> Result<(), AgentLiveAttachError> { + if from.journal_id() != through.journal_id() || from.sequence() > through.sequence() { + return Err(snapshot_required( + if from.journal_id() != through.journal_id() { + AgentLiveSnapshotReason::JournalReplaced + } else { + AgentLiveSnapshotReason::CursorAhead + }, + )); + } + Ok(()) +} + +fn validate_next_delivery( + previous: &LiveEventCursor, + next: &LiveEventCursor, + through: &LiveEventCursor, +) -> Result<(), AgentLiveAttachError> { + validate_next_cursor(previous, next)?; + if next.journal_id() != through.journal_id() || next.sequence() > through.sequence() { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + Ok(()) +} + +fn validate_next_cursor( + previous: &LiveEventCursor, + next: &LiveEventCursor, +) -> Result<(), AgentLiveAttachError> { + let expected = previous + .sequence() + .checked_add(1) + .ok_or_else(|| snapshot_required(AgentLiveSnapshotReason::OrderingLost))?; + if previous.journal_id() != next.journal_id() || next.sequence() != expected { + return Err(snapshot_required(AgentLiveSnapshotReason::OrderingLost)); + } + Ok(()) +} + +fn cursor_to_wire(cursor: &LiveEventCursor) -> AgentLiveEventCursor { + AgentLiveEventCursor { + journal_id: cursor.journal_id().to_string(), + sequence: cursor.sequence(), + } +} + +fn wire_to_cursor(cursor: &AgentLiveEventCursor) -> Result { + LiveEventCursor::try_from_parts(cursor.journal_id.clone(), cursor.sequence).map_err(|_| { + AgentLiveAttachError::InvalidRequest { + message: "Agent live-event cursor is invalid", + } + }) +} + +fn map_receive_error(error: AgentLiveReceiveError) -> AgentLiveAttachError { + match error { + AgentLiveReceiveError::HeadReloadRequired(reason) => { + snapshot_required(map_head_reload_reason(reason)) + } + AgentLiveReceiveError::Closed => snapshot_required(AgentLiveSnapshotReason::OrderingLost), + } +} + +pub(crate) fn map_coordinator_error(error: AgentLiveCoordinatorError) -> AgentLiveAttachError { + match error { + AgentLiveCoordinatorError::HeadReloadRequired(reason) => { + snapshot_required(map_head_reload_reason(reason)) + } + // Reseed is a native authoritative-head workflow. The renderer must + // discard the old journal epoch and perform a synchronized head reload; + // it never receives or attempts to satisfy the reseed capability. + AgentLiveCoordinatorError::ReseedRequired(_) => { + snapshot_required(AgentLiveSnapshotReason::JournalReplaced) + } + AgentLiveCoordinatorError::Sealed(_) + | AgentLiveCoordinatorError::DataOwnerMismatch + | AgentLiveCoordinatorError::StableOperationMismatch + | AgentLiveCoordinatorError::ProjectionSchemaMismatch + | AgentLiveCoordinatorError::IngressRebindRequired + | AgentLiveCoordinatorError::Journal(LiveEventJournalError::OwnerGenerationMismatch) + | AgentLiveCoordinatorError::Journal(LiveEventJournalError::OwnerTransitionIncomplete) => { + AgentLiveAttachError::StaleLease + } + AgentLiveCoordinatorError::InvalidAccountScope + | AgentLiveCoordinatorError::InvalidExecutionTarget + | AgentLiveCoordinatorError::InvalidSession + | AgentLiveCoordinatorError::InvalidRun + | AgentLiveCoordinatorError::InvalidSubscriptionCapacity + | AgentLiveCoordinatorError::InvalidCommandCapacity => { + AgentLiveAttachError::InvalidRequest { + message: "Agent live attachment request is invalid", + } + } + AgentLiveCoordinatorError::SubscriberCapacityExceeded + | AgentLiveCoordinatorError::IngressRouteCapacityExceeded => { + AgentLiveAttachError::CapacityExceeded + } + AgentLiveCoordinatorError::Projection(_) => AgentLiveAttachError::ProjectionRejected, + AgentLiveCoordinatorError::StaleHistoryCommit + | AgentLiveCoordinatorError::IngressEpochExhausted + | AgentLiveCoordinatorError::Journal(_) + | AgentLiveCoordinatorError::WorkerUnavailable + | AgentLiveCoordinatorError::CoordinatorClosed => AgentLiveAttachError::Unavailable, + } +} + +fn map_head_reload_reason(reason: HeadReloadReason) -> AgentLiveSnapshotReason { + match reason { + HeadReloadReason::PausedSubscriberOverflow => { + AgentLiveSnapshotReason::PausedSubscriberOverflow + } + HeadReloadReason::SlowSubscriber => AgentLiveSnapshotReason::SlowSubscriber, + HeadReloadReason::JournalReplaced => AgentLiveSnapshotReason::JournalReplaced, + HeadReloadReason::ReseedRequired => AgentLiveSnapshotReason::JournalReplaced, + HeadReloadReason::RetentionGap => AgentLiveSnapshotReason::RetentionGap, + HeadReloadReason::CursorAhead => AgentLiveSnapshotReason::CursorAhead, + HeadReloadReason::OwnerChanged => AgentLiveSnapshotReason::OwnerChanged, + HeadReloadReason::OrderingLost => AgentLiveSnapshotReason::OrderingLost, + HeadReloadReason::JournalUnavailable => AgentLiveSnapshotReason::JournalUnavailable, + } +} + +fn snapshot_required(reason: AgentLiveSnapshotReason) -> AgentLiveAttachError { + AgentLiveAttachError::SnapshotRequired { reason } +} + +fn allocate_attach_id(state: &AgentLiveAttachState) -> Result { + for _ in 0..MAX_ATTACH_ID_ATTEMPTS { + let candidate = random_opaque_id()?; + if !state_id_in_use(state, &candidate) { + return Ok(candidate); + } + } + Err(AgentLiveAttachError::Unavailable) +} + +fn random_opaque_id() -> Result { + let mut bytes = [0_u8; ATTACH_ID_RANDOM_BYTES]; + fill_random(&mut bytes).map_err(|_| AgentLiveAttachError::Unavailable)?; + let mut encoded = String::with_capacity(ATTACH_ID_RANDOM_BYTES * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in bytes { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(encoded) +} + +fn validate_bounded_id( + value: &str, + max_bytes: usize, + message: &'static str, +) -> Result<(), AgentLiveAttachError> { + if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) { + return Err(AgentLiveAttachError::InvalidRequest { message }); + } + Ok(()) +} + +fn parse_canonical_host_epoch(value: &str) -> Result { + if value.is_empty() + || value.len() > 20 + || value.starts_with('0') + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(AgentLiveAttachError::InvalidRequest { + message: "Agent host epoch is invalid", + }); + } + let parsed = value + .parse::() + .map_err(|_| AgentLiveAttachError::InvalidRequest { + message: "Agent host epoch is invalid", + })?; + if parsed == 0 || parsed.to_string() != value { + return Err(AgentLiveAttachError::InvalidRequest { + message: "Agent host epoch is invalid", + }); + } + Ok(parsed) +} + +fn validate_attach_or_stream_id( + value: &str, + message: &'static str, +) -> Result<(), AgentLiveAttachError> { + if value.len() != ATTACH_ID_RANDOM_BYTES * 2 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(AgentLiveAttachError::InvalidRequest { message }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{AgentHistoryRecord, AgentTimelineItem}; + use crate::agent_live_coordinator::{ + MapleLiveEvent, MapleLiveItemType, MapleLiveMerge, MapleLiveTimelineItem, + MapleLiveUserFacingError, + }; + use crate::remote_transport::PairingIncarnation; + use serde_json::Value; + use std::{ + collections::VecDeque, + sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + }; + use tokio::sync::{mpsc, Notify}; + + fn endpoint(seed: u8) -> iroh::EndpointId { + iroh::SecretKey::from_bytes(&[seed; 32]).public() + } + + fn cursor(sequence: u64) -> LiveEventCursor { + LiveEventCursor::try_from_parts("11".repeat(16), sequence).unwrap() + } + + fn owner() -> AgentLiveLeaseOwner { + AgentLiveLeaseOwner { + opaque_account_scope: "account-hash".into(), + account_data_generation: 7, + target_id: "11111111-1111-4111-8111-111111111111".into(), + authorization: LocalAuthorizationContext::for_test(17, 9, [7; 32]), + controller_endpoint: endpoint(1), + pairing_fence: PairingFence::new(PairingIncarnation::new(3).unwrap()).unwrap(), + connection_stamp: ConnectionStamp::new(11, 12).unwrap(), + binding_lineage_epoch: 5, + peer_lineage_epoch: 6, + native_authority: None, + } + } + + #[test] + fn coordinator_error_mapping_fails_closed_for_ingress_authority_and_capacity() { + for error in [ + AgentLiveCoordinatorError::DataOwnerMismatch, + AgentLiveCoordinatorError::StableOperationMismatch, + AgentLiveCoordinatorError::ProjectionSchemaMismatch, + AgentLiveCoordinatorError::IngressRebindRequired, + ] { + assert_eq!( + map_coordinator_error(error), + AgentLiveAttachError::StaleLease + ); + } + assert_eq!( + map_coordinator_error(AgentLiveCoordinatorError::IngressRouteCapacityExceeded), + AgentLiveAttachError::CapacityExceeded + ); + assert_eq!( + map_coordinator_error(AgentLiveCoordinatorError::IngressEpochExhausted), + AgentLiveAttachError::Unavailable + ); + } + + fn request() -> AgentHistoryPageRequest { + AgentHistoryPageRequest { + session_id: "session-b".into(), + cursor: None, + limit: Some(25), + } + } + + fn history_page() -> AgentHistoryPage { + AgentHistoryPage { + records: vec![AgentHistoryRecord { + record_id: "record-1".into(), + role: "assistant".into(), + created_ms: 1, + items: vec![], + }], + next_cursor: Some("next".into()), + history_revision: "history-revision".into(), + live_items: None, + through_event_cursor: None, + } + } + + fn live_item(id: &str) -> MapleLiveTimelineItem { + MapleLiveTimelineItem { + id: id.into(), + item_type: MapleLiveItemType::Message, + role: None, + title: None, + text: Some(id.into()), + status: None, + created_ms: 1, + merge: MapleLiveMerge::Replace, + } + } + + fn projection(session_id: &str, item_ids: &[&str]) -> AgentLiveSessionProjection { + AgentLiveSessionProjection { + session_id: session_id.into(), + live_items: item_ids.iter().map(|id| live_item(id)).collect(), + } + } + + fn delivery(sequence: u64, session_id: &str) -> AgentLiveDelivery { + AgentLiveDelivery { + cursor: cursor(sequence), + session_id: session_id.into(), + run_id: Some("run".into()), + event: MapleLiveEvent::RunStarted { + event_id: format!("event-{sequence}"), + }, + } + } + + #[derive(Default)] + struct RecordingSender { + frames: Mutex>, + fail: AtomicBool, + sent: Notify, + } + + impl RecordingSender { + fn event_sequences(&self) -> Vec { + self.frames + .lock() + .unwrap() + .iter() + .filter_map(|frame| match frame { + AgentLiveChannelFrame::Event(event) => Some(event.event_sequence), + AgentLiveChannelFrame::SnapshotRequired(_) => None, + }) + .collect() + } + + async fn wait_for_event_count(&self, expected: usize) { + while self.event_sequences().len() < expected { + self.sent.notified().await; + } + } + } + + impl AgentLiveEventSender for RecordingSender { + fn send(&self, frame: AgentLiveChannelFrame) -> Result<(), AgentLiveAttachError> { + if self.fail.load(Ordering::SeqCst) { + return Err(AgentLiveAttachError::ChannelClosed); + } + self.frames.lock().unwrap().push(frame); + self.sent.notify_one(); + Ok(()) + } + } + + struct TestProjector; + + impl AgentLiveDeliveryProjector for TestProjector { + fn project_delivery( + &self, + _delivery: &AgentLiveDelivery, + ) -> Result { + Ok(AgentPresentedLiveEvent::RunStarted) + } + } + + struct QueueStream { + receiver: mpsc::UnboundedReceiver>, + unsubscribed: Arc, + } + + #[async_trait] + impl AgentLiveProviderStream for QueueStream { + async fn recv(&mut self) -> Result { + self.receiver + .recv() + .await + .unwrap_or(Err(AgentLiveReceiveError::Closed)) + } + + async fn unsubscribe(self: Box) -> Result<(), AgentLiveAttachError> { + self.unsubscribed.store(true, Ordering::SeqCst); + Ok(()) + } + } + + fn stream( + deliveries: impl IntoIterator>, + ) -> ( + Box, + mpsc::UnboundedSender>, + ) { + let (stream, sender, _) = tracked_stream(deliveries); + (stream, sender) + } + + fn tracked_stream( + deliveries: impl IntoIterator>, + ) -> ( + Box, + mpsc::UnboundedSender>, + Arc, + ) { + let (sender, receiver) = mpsc::unbounded_channel(); + for delivery in deliveries { + sender.send(delivery).unwrap(); + } + let unsubscribed = Arc::new(AtomicBool::new(false)); + ( + Box::new(QueueStream { + receiver, + unsubscribed: Arc::clone(&unsubscribed), + }), + sender, + unsubscribed, + ) + } + + struct FakePendingToken { + resume: Mutex>, + finalized: Arc, + cancelled: Arc, + } + + struct BlockingPendingToken { + resume: Mutex>, + started: Arc, + started_notify: Arc, + release: Arc, + cancelled: Arc, + } + + #[async_trait] + impl AgentLivePendingAttach for BlockingPendingToken { + async fn finalize( + self: Box, + ) -> Result { + self.started.store(true, Ordering::SeqCst); + self.started_notify.notify_waiters(); + self.release.notified().await; + self.resume + .lock() + .unwrap() + .take() + .ok_or(AgentLiveAttachError::Unavailable) + } + + async fn cancel(self: Box) -> Result<(), AgentLiveAttachError> { + self.cancelled.store(true, Ordering::SeqCst); + Ok(()) + } + } + + #[async_trait] + impl AgentLivePendingAttach for FakePendingToken { + async fn finalize( + self: Box, + ) -> Result { + self.finalized.store(true, Ordering::SeqCst); + self.resume + .lock() + .unwrap() + .take() + .ok_or(AgentLiveAttachError::Unavailable) + } + + async fn cancel(self: Box) -> Result<(), AgentLiveAttachError> { + self.cancelled.store(true, Ordering::SeqCst); + Ok(()) + } + } + + struct FakeProvider { + valid: AtomicBool, + validate_calls: AtomicUsize, + resume_calls: AtomicUsize, + page: Mutex, + heads: Mutex>, + resumes: Mutex>, + invalidate_after_finalize: Option>, + } + + impl FakeProvider { + fn new(heads: Vec) -> Self { + Self { + valid: AtomicBool::new(true), + validate_calls: AtomicUsize::new(0), + resume_calls: AtomicUsize::new(0), + page: Mutex::new(history_page()), + heads: Mutex::new(heads.into()), + resumes: Mutex::new(VecDeque::new()), + invalidate_after_finalize: None, + } + } + } + + #[async_trait] + impl AgentLiveAttachProvider for FakeProvider { + async fn validate_lease( + &self, + _owner: &AgentLiveLeaseOwner, + ) -> Result<(), AgentLiveAttachError> { + self.validate_calls.fetch_add(1, Ordering::SeqCst); + if self + .invalidate_after_finalize + .as_ref() + .is_some_and(|finalized| finalized.load(Ordering::SeqCst)) + { + self.valid.store(false, Ordering::SeqCst); + } + self.valid + .load(Ordering::SeqCst) + .then_some(()) + .ok_or(AgentLiveAttachError::StaleLease) + } + + async fn begin_account_head_attach( + &self, + _owner: &AgentLiveLeaseOwner, + _capacity: usize, + ) -> Result { + self.heads + .lock() + .unwrap() + .pop_front() + .ok_or(AgentLiveAttachError::Unavailable) + } + + async fn list_history_page( + &self, + _owner: &AgentLiveLeaseOwner, + _request: AgentHistoryPageRequest, + ) -> Result { + Ok(self.page.lock().unwrap().clone()) + } + + async fn begin_resume( + &self, + _owner: &AgentLiveLeaseOwner, + _cursor: LiveEventCursor, + _capacity: usize, + ) -> Result { + self.resume_calls.fetch_add(1, Ordering::SeqCst); + self.resumes + .lock() + .unwrap() + .pop_front() + .ok_or(AgentLiveAttachError::Unavailable) + } + } + + fn head( + through: u64, + live_sessions: Vec, + replay: Vec, + live_sender_out: Option< + &mut Option>>, + >, + finalized: Arc, + ) -> AgentLiveProviderHeadAttach { + let (stream, sender) = stream(replay.into_iter().map(Ok)); + if let Some(output) = live_sender_out { + *output = Some(sender); + } + AgentLiveProviderHeadAttach { + through_cursor: cursor(through.saturating_sub(1)), + live_sessions_complete: true, + live_sessions, + token: Box::new(FakePendingToken { + resume: Mutex::new(Some(AgentLiveProviderResume { + through_cursor: cursor(through), + stream, + })), + finalized, + cancelled: Arc::new(AtomicBool::new(false)), + }), + } + } + + fn tracked_head( + through: u64, + replay: Vec, + ) -> ( + AgentLiveProviderHeadAttach, + mpsc::UnboundedSender>, + Arc, + Arc, + ) { + let (stream, sender, unsubscribed) = tracked_stream(replay.into_iter().map(Ok)); + let cancelled = Arc::new(AtomicBool::new(false)); + ( + AgentLiveProviderHeadAttach { + through_cursor: cursor(through.saturating_sub(1)), + live_sessions_complete: true, + live_sessions: vec![], + token: Box::new(FakePendingToken { + resume: Mutex::new(Some(AgentLiveProviderResume { + through_cursor: cursor(through), + stream, + })), + finalized: Arc::new(AtomicBool::new(false)), + cancelled: Arc::clone(&cancelled), + }), + }, + sender, + cancelled, + unsubscribed, + ) + } + + fn manager(provider: Arc) -> AgentLiveAttachManager { + AgentLiveAttachManager::new(provider, Arc::new(TestProjector)) + } + + struct TestOwnerResolver; + + #[async_trait] + impl AgentLiveOwnerResolver for TestOwnerResolver { + async fn resolve_current_owner( + &self, + _user_id: &str, + ) -> Result { + Ok(owner()) + } + } + + #[tokio::test] + async fn begin_returns_literal_complete_account_snapshot_and_keeps_token_paused() { + let finalized = Arc::new(AtomicBool::new(false)); + let provider = Arc::new(FakeProvider::new(vec![head( + 1, + vec![ + projection("session-a", &["a"]), + projection("session-b", &["b"]), + ], + vec![delivery(1, "session-a")], + None, + finalized.clone(), + )])); + let manager = manager(provider); + let response = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + assert!(!finalized.load(Ordering::SeqCst)); + assert!(manager + .inner + .state + .lock() + .unwrap() + .pending + .contains_key(&response.attach_id)); + assert!(response.live_sessions_complete); + assert_eq!(response.live_session_count, 2); + assert_eq!( + response + .live_sessions + .iter() + .map(|session| session.session_id.as_str()) + .collect::>(), + ["session-a", "session-b"] + ); + assert_eq!(response.through_event_cursor.sequence, 0); + let encoded = serde_json::to_value(&response).unwrap(); + assert_eq!(encoded["liveSessionsComplete"], Value::Bool(true)); + assert_eq!(encoded["liveSessionCount"], Value::from(2)); + } + + #[tokio::test] + async fn begin_rejects_plain_page_that_smuggles_a_live_pair() { + let finalized = Arc::new(AtomicBool::new(false)); + let provider = Arc::new(FakeProvider::new(vec![head( + 0, + vec![], + vec![], + None, + finalized, + )])); + provider.page.lock().unwrap().live_items = Some(vec![]); + assert!(matches!( + manager(provider) + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await, + Err(AgentLiveAttachError::InvalidRequest { .. }) + )); + } + + #[tokio::test] + async fn synchronized_head_projects_persisted_tool_rows_through_closed_safe_boundary() { + let finalized = Arc::new(AtomicBool::new(false)); + let provider = Arc::new(FakeProvider::new(vec![head( + 0, + vec![], + vec![], + None, + finalized, + )])); + provider.page.lock().unwrap().records[0].items = vec![AgentTimelineItem { + id: "tool-record".into(), + item_type: "tool".into(), + role: Some("assistant".into()), + title: Some("curl https://secret.invalid?token=hunter2".into()), + text: Some("failed at /Users/alice/.env: API_KEY=hunter2".into()), + status: Some("failed".into()), + input: Some(serde_json::json!({"token": "hunter2"})), + output: Some(serde_json::json!({"path": "/Users/alice/.env"})), + created_ms: 1, + merge: "replace".into(), + }]; + + let response = manager(provider) + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + assert_eq!(response.page.records.len(), 1); + let item = &response.page.records[0].items[0]; + assert_eq!(item.title.as_deref(), Some(SAFE_REMOTE_TOOL_TITLE)); + assert_eq!(item.text.as_deref(), Some(SAFE_REMOTE_TOOL_FAILED)); + let encoded = serde_json::to_string(&response).unwrap(); + for forbidden in [ + "hunter2", + "/Users/alice/.env", + "secret.invalid", + "\"input\"", + "\"output\"", + ] { + assert!(!encoded.contains(forbidden), "leaked {forbidden}"); + } + } + + #[test] + fn synchronized_head_rejects_unsafe_timestamp_and_oversized_single_record() { + let mut unsafe_timestamp = history_page(); + unsafe_timestamp.records[0].created_ms = MAX_JAVASCRIPT_SAFE_INTEGER + 1; + assert_eq!( + project_safe_history_page(unsafe_timestamp, 25), + Err(AgentLiveAttachError::ProjectionRejected) + ); + + let mut oversized = history_page(); + oversized.records[0].items = (0..6) + .map(|index| AgentTimelineItem { + id: format!("message-{index}"), + item_type: "message".into(), + role: Some("assistant".into()), + title: None, + text: Some("x".repeat(192 * 1024)), + status: None, + input: None, + output: None, + created_ms: 1, + merge: "replace".into(), + }) + .collect(); + assert_eq!( + project_safe_history_page(oversized, 25), + Err(AgentLiveAttachError::HistoryRecordTooLarge) + ); + } + + #[tokio::test] + async fn activate_queues_strict_replay_before_return_and_continues_same_channel_live() { + let finalized = Arc::new(AtomicBool::new(false)); + let mut live_sender = None; + let provider = Arc::new(FakeProvider::new(vec![head( + 2, + vec![projection("session-a", &["a"])], + vec![delivery(2, "session-b")], + Some(&mut live_sender), + finalized.clone(), + )])); + let manager = manager(provider); + let sender = Arc::new(RecordingSender::default()); + let begun = manager + .begin(owner(), request(), sender.clone()) + .await + .unwrap(); + let activated = manager.activate(owner(), &begun.attach_id).await.unwrap(); + assert!(finalized.load(Ordering::SeqCst)); + assert_eq!(activated.through_event_cursor.sequence, 2); + assert_eq!(activated.live_stream_id, begun.attach_id); + assert_eq!(sender.event_sequences(), [2]); + live_sender + .unwrap() + .send(Ok(delivery(3, "session-a"))) + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), sender.wait_for_event_count(2)) + .await + .unwrap(); + assert_eq!(sender.event_sequences(), [2, 3]); + let frames = sender.frames.lock().unwrap(); + let AgentLiveChannelFrame::Event(first) = &frames[0] else { + panic!("expected ordered event") + }; + assert_eq!(first.target_id, owner().target_id); + assert_eq!(first.host_epoch, "11"); + assert_eq!(first.connection_generation, 12); + assert_eq!(first.event_epoch, "11".repeat(16)); + } + + #[tokio::test] + async fn active_stream_requires_exact_teardown_handle_and_cancel_is_idempotent() { + let finalized = Arc::new(AtomicBool::new(false)); + let mut live_sender = None; + let provider = Arc::new(FakeProvider::new(vec![head( + 0, + vec![], + vec![], + Some(&mut live_sender), + finalized, + )])); + let manager = manager(provider); + let channel = Arc::new(RecordingSender::default()); + let begun = manager + .begin(owner(), request(), channel.clone()) + .await + .unwrap(); + let active = manager.activate(owner(), &begun.attach_id).await.unwrap(); + let different_stream = "22".repeat(ATTACH_ID_RANDOM_BYTES); + assert_eq!( + manager.cancel_live_events(owner(), &different_stream).await, + Err(AgentLiveAttachError::StaleLease) + ); + assert_eq!(manager.inner.state.lock().unwrap().active.len(), 1); + manager + .cancel_live_events(owner(), &active.live_stream_id) + .await + .unwrap(); + tokio::task::yield_now().await; + manager + .cancel_live_events(owner(), &active.live_stream_id) + .await + .unwrap(); + assert!(manager.inner.state.lock().unwrap().active.is_empty()); + let _ = live_sender.unwrap().send(Ok(delivery(1, "session"))); + tokio::task::yield_now().await; + assert!(channel.event_sequences().is_empty()); + } + + #[tokio::test] + async fn tauri_lifecycle_pending_cancel_and_ttl_await_token_cancellation() { + let (cancel_head, _cancel_live, cancelled, _cancel_unsubscribed) = tracked_head(0, vec![]); + let (expiry_head, _expiry_live, expired, _expiry_unsubscribed) = tracked_head(0, vec![]); + let provider = Arc::new(FakeProvider::new(vec![cancel_head, expiry_head])); + let manager = AgentLiveAttachManager::with_config( + provider, + Arc::new(TestProjector), + AgentLiveAttachManagerConfig { + pending_ttl: Duration::from_millis(20), + ..Default::default() + }, + ) + .unwrap(); + + let pending = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + manager.cancel(owner(), &pending.attach_id).await.unwrap(); + assert!(cancelled.load(Ordering::SeqCst)); + + manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while !expired.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn tauri_lifecycle_active_cancel_awaits_stream_unsubscribe() { + let (head, _live, _cancelled, unsubscribed) = tracked_head(0, vec![]); + let manager = manager(Arc::new(FakeProvider::new(vec![head]))); + let pending = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + let active = manager.activate(owner(), &pending.attach_id).await.unwrap(); + manager + .cancel_live_events(owner(), &active.live_stream_id) + .await + .unwrap(); + assert!(unsubscribed.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn tauri_lifecycle_activating_slot_blocks_resume_and_cancel_awaits_cleanup() { + let (stream, _live, unsubscribed) = tracked_stream([]); + let started = Arc::new(AtomicBool::new(false)); + let started_notify = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let cancelled = Arc::new(AtomicBool::new(false)); + let head = AgentLiveProviderHeadAttach { + through_cursor: cursor(0), + live_sessions_complete: true, + live_sessions: vec![], + token: Box::new(BlockingPendingToken { + resume: Mutex::new(Some(AgentLiveProviderResume { + through_cursor: cursor(0), + stream, + })), + started: Arc::clone(&started), + started_notify: Arc::clone(&started_notify), + release: Arc::clone(&release), + cancelled, + }), + }; + let provider = Arc::new(FakeProvider::new(vec![head])); + let manager = manager(Arc::clone(&provider)); + let pending = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + let activation = tokio::spawn({ + let manager = manager.clone(); + let attach_id = pending.attach_id.clone(); + async move { manager.activate(owner(), &attach_id).await } + }); + loop { + let notified = started_notify.notified(); + if started.load(Ordering::SeqCst) { + break; + } + notified.await; + } + + assert_eq!( + manager + .resume( + owner(), + cursor_to_wire(&cursor(0)), + Arc::new(RecordingSender::default()), + ) + .await, + Err(AgentLiveAttachError::StaleLease) + ); + assert_eq!(provider.resume_calls.load(Ordering::SeqCst), 0); + + let cancellation = tokio::spawn({ + let manager = manager.clone(); + let attach_id = pending.attach_id.clone(); + async move { manager.cancel(owner(), &attach_id).await } + }); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let activating = manager.inner.state.lock().unwrap().activating.len(); + if activating == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(!cancellation.is_finished()); + release.notify_one(); + cancellation.await.unwrap().unwrap(); + assert_eq!( + activation.await.unwrap(), + Err(AgentLiveAttachError::AttachNotFound) + ); + assert!(unsubscribed.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn tauri_lifecycle_reconnect_uses_full_stamp_and_retires_old_after_replay() { + let (old_head, _old_live, _old_cancelled, old_unsubscribed) = tracked_head(0, vec![]); + let (new_head, _new_live, _new_cancelled, new_unsubscribed) = + tracked_head(1, vec![delivery(1, "session")]); + let manager = manager(Arc::new(FakeProvider::new(vec![old_head, new_head]))); + let first = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + manager.activate(owner(), &first.attach_id).await.unwrap(); + + let mut reconnected = owner(); + reconnected.connection_stamp = ConnectionStamp::new(11, 13).unwrap(); + assert_ne!(owner().stream_key(), reconnected.stream_key()); + assert_eq!( + owner().stream_lineage_key(), + reconnected.stream_lineage_key() + ); + let second = manager + .begin( + reconnected.clone(), + request(), + Arc::new(RecordingSender::default()), + ) + .await + .unwrap(); + let active = manager + .activate(reconnected.clone(), &second.attach_id) + .await + .unwrap(); + assert!(old_unsubscribed.load(Ordering::SeqCst)); + let state = manager.inner.state.lock().unwrap(); + assert_eq!(state.active.len(), 1); + assert!(state.active.contains_key(&reconnected.stream_key())); + drop(state); + manager + .cancel_live_events(reconnected, &active.live_stream_id) + .await + .unwrap(); + assert!(new_unsubscribed.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn tauri_lifecycle_runtime_install_is_one_shot_and_old_manager_stays_revocable() { + let (old_head, _old_live, _old_cancelled, old_unsubscribed) = tracked_head(0, vec![]); + let old_manager = manager(Arc::new(FakeProvider::new(vec![old_head]))); + let new_manager = manager(Arc::new(FakeProvider::new(vec![]))); + let state = AgentLiveTauriState::disabled(); + state + .install_verified_runtime(old_manager.clone(), Arc::new(TestOwnerResolver)) + .unwrap(); + assert_eq!( + state.install_verified_runtime(new_manager, Arc::new(TestOwnerResolver)), + Err(AgentLiveAttachError::Unavailable) + ); + + let pending = old_manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + old_manager + .activate(owner(), &pending.attach_id) + .await + .unwrap(); + state.revoke_exact_owner(&owner()).await.unwrap(); + assert!(old_unsubscribed.load(Ordering::SeqCst)); + assert!(old_manager.inner.state.lock().unwrap().active.is_empty()); + } + + #[tokio::test] + async fn equal_fence_and_stamp_controllers_cannot_supersede_or_cancel_each_other() { + let mut second_owner = owner(); + second_owner.controller_endpoint = endpoint(2); + second_owner.peer_lineage_epoch = 7; + // Numeric fence/stamp values intentionally collide; native endpoint + // identity and peer lineage still define different controller leases. + assert_eq!(second_owner.pairing_fence, owner().pairing_fence); + assert_eq!(second_owner.connection_stamp, owner().connection_stamp); + + let mut first_live = None; + let mut second_live = None; + let provider = Arc::new(FakeProvider::new(vec![ + head( + 0, + vec![], + vec![], + Some(&mut first_live), + Arc::new(AtomicBool::new(false)), + ), + head( + 0, + vec![], + vec![], + Some(&mut second_live), + Arc::new(AtomicBool::new(false)), + ), + ])); + let manager = manager(provider); + let first = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + let first_active = manager.activate(owner(), &first.attach_id).await.unwrap(); + let second = manager + .begin( + second_owner.clone(), + request(), + Arc::new(RecordingSender::default()), + ) + .await + .unwrap(); + let second_active = manager + .activate(second_owner.clone(), &second.attach_id) + .await + .unwrap(); + assert_eq!(manager.inner.state.lock().unwrap().active.len(), 2); + + assert_eq!( + manager + .cancel_live_events(second_owner.clone(), &first_active.live_stream_id) + .await, + Err(AgentLiveAttachError::StaleLease) + ); + manager.revoke_owner(&owner()).await; + assert_eq!(manager.inner.state.lock().unwrap().active.len(), 1); + manager + .cancel_live_events(second_owner, &second_active.live_stream_id) + .await + .unwrap(); + assert!(manager.inner.state.lock().unwrap().active.is_empty()); + drop((first_live, second_live)); + } + + #[tokio::test] + async fn activate_revalidates_after_finalize_and_fails_closed() { + let finalized = Arc::new(AtomicBool::new(false)); + let mut provider = FakeProvider::new(vec![head( + 1, + vec![], + vec![delivery(1, "session")], + None, + finalized.clone(), + )]); + provider.invalidate_after_finalize = Some(finalized); + let provider = Arc::new(provider); + let manager = manager(provider.clone()); + let begun = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + assert!(matches!( + manager.activate(owner(), &begun.attach_id).await, + Err(AgentLiveAttachError::StaleLease) + )); + assert!(provider.validate_calls.load(Ordering::SeqCst) >= 4); + } + + #[tokio::test] + async fn activate_revalidates_before_finalize_and_leaves_token_unconsumed_on_stale_owner() { + let finalized = Arc::new(AtomicBool::new(false)); + let provider = Arc::new(FakeProvider::new(vec![head( + 0, + vec![], + vec![], + None, + finalized.clone(), + )])); + let manager = manager(provider.clone()); + let begun = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + provider.valid.store(false, Ordering::SeqCst); + assert!(matches!( + manager.activate(owner(), &begun.attach_id).await, + Err(AgentLiveAttachError::StaleLease) + )); + assert!(!finalized.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn activating_new_stream_supersedes_old_only_after_new_replay_is_queued() { + let finalized_a = Arc::new(AtomicBool::new(false)); + let finalized_b = Arc::new(AtomicBool::new(false)); + let mut live_a = None; + let mut live_b = None; + let provider = Arc::new(FakeProvider::new(vec![ + head(0, vec![], vec![], Some(&mut live_a), finalized_a), + head( + 1, + vec![], + vec![delivery(1, "session-b")], + Some(&mut live_b), + finalized_b, + ), + ])); + let manager = manager(provider); + let old_sender = Arc::new(RecordingSender::default()); + let first = manager + .begin(owner(), request(), old_sender.clone()) + .await + .unwrap(); + manager.activate(owner(), &first.attach_id).await.unwrap(); + + let new_sender = Arc::new(RecordingSender::default()); + let second = manager + .begin(owner(), request(), new_sender.clone()) + .await + .unwrap(); + let second_active = manager.activate(owner(), &second.attach_id).await.unwrap(); + tokio::task::yield_now().await; + assert_eq!(new_sender.event_sequences(), [1]); + assert_eq!(second_active.live_stream_id, second.attach_id); + let _ = live_a.unwrap().send(Ok(delivery(1, "session-a"))); + tokio::task::yield_now().await; + live_b.unwrap().send(Ok(delivery(2, "session-b"))).unwrap(); + tokio::time::timeout(Duration::from_secs(1), new_sender.wait_for_event_count(2)) + .await + .unwrap(); + assert_eq!(new_sender.event_sequences(), [1, 2]); + assert!(old_sender.event_sequences().is_empty()); + } + + #[tokio::test] + async fn cancel_is_idempotent_wrong_owner_is_rejected_and_ttl_drops_token() { + let finalized = Arc::new(AtomicBool::new(false)); + let provider = Arc::new(FakeProvider::new(vec![ + head(0, vec![], vec![], None, finalized.clone()), + head(0, vec![], vec![], None, finalized), + ])); + let config = AgentLiveAttachManagerConfig { + pending_ttl: Duration::from_millis(20), + ..Default::default() + }; + let manager = + AgentLiveAttachManager::with_config(provider, Arc::new(TestProjector), config).unwrap(); + let begun = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + let mut wrong = owner(); + wrong.account_data_generation += 1; + let wrong_begin = manager + .begin(owner(), request(), Arc::new(RecordingSender::default())) + .await + .unwrap(); + assert_eq!( + manager.cancel(wrong, &wrong_begin.attach_id).await, + Err(AgentLiveAttachError::StaleLease) + ); + let already_gone = "00".repeat(ATTACH_ID_RANDOM_BYTES); + manager.cancel(owner(), &already_gone).await.unwrap(); + manager.cancel(owner(), &already_gone).await.unwrap(); + tokio::time::sleep(Duration::from_millis(40)).await; + assert!(matches!( + manager.activate(owner(), &begun.attach_id).await, + Err(AgentLiveAttachError::AttachNotFound) + )); + } + + #[test] + fn complete_snapshot_is_deterministic_and_enforces_account_bounds() { + assert!(validate_and_restore_snapshot( + true, + vec![projection("session-a", &[]), projection("session-b", &[])] + ) + .is_ok()); + assert!(matches!( + validate_and_restore_snapshot( + true, + vec![projection("session-b", &[]), projection("session-a", &[])] + ), + Err(AgentLiveAttachError::SnapshotRequired { .. }) + )); + let too_many_sessions = (0..=MAX_LIVE_SESSIONS) + .map(|index| projection(&format!("session-{index:03}"), &[])) + .collect(); + assert!(validate_and_restore_snapshot(true, too_many_sessions).is_err()); + let max_sessions = (0..MAX_LIVE_SESSIONS) + .map(|index| projection(&format!("session-{index:03}"), &[])) + .collect(); + assert_eq!( + validate_and_restore_snapshot(true, max_sessions) + .unwrap() + .len(), + MAX_LIVE_SESSIONS + ); + let item_ids = (0..=MAX_LIVE_ITEMS) + .map(|index| format!("item-{index}")) + .collect::>(); + let item_refs = item_ids.iter().map(String::as_str).collect::>(); + assert!( + validate_and_restore_snapshot(true, vec![projection("session", &item_refs)]).is_err() + ); + let max_item_refs = item_refs[..200].to_vec(); + assert_eq!( + validate_and_restore_snapshot(true, vec![projection("session", &max_item_refs)]) + .unwrap()[0] + .live_items + .len(), + 200 + ); + } + + #[tokio::test] + async fn resume_maps_typed_snapshot_reason_and_channel_failure_installs_no_active() { + let provider = Arc::new(FakeProvider::new(vec![])); + let (failed_stream, _) = stream([Err(AgentLiveReceiveError::HeadReloadRequired( + HeadReloadReason::RetentionGap, + ))]); + provider + .resumes + .lock() + .unwrap() + .push_back(AgentLiveProviderResume { + through_cursor: cursor(1), + stream: failed_stream, + }); + let manager = manager(provider.clone()); + assert!(matches!( + manager + .resume( + owner(), + cursor_to_wire(&cursor(0)), + Arc::new(RecordingSender::default()) + ) + .await, + Err(AgentLiveAttachError::SnapshotRequired { + reason: AgentLiveSnapshotReason::RetentionGap + }) + )); + + let (next_stream, _) = stream([Ok(delivery(1, "session"))]); + provider + .resumes + .lock() + .unwrap() + .push_back(AgentLiveProviderResume { + through_cursor: cursor(1), + stream: next_stream, + }); + let sender = Arc::new(RecordingSender::default()); + sender.fail.store(true, Ordering::SeqCst); + assert!(matches!( + manager + .resume(owner(), cursor_to_wire(&cursor(0)), sender) + .await, + Err(AgentLiveAttachError::ChannelClosed) + )); + assert!(manager.inner.state.lock().unwrap().active.is_empty()); + } + + #[test] + fn snapshot_wire_includes_explicit_absence_semantics_without_selected_duplication() { + let restored = validate_and_restore_snapshot( + true, + vec![ + projection("session-a", &["a"]), + projection("session-b", &["b"]), + ], + ) + .unwrap(); + assert_eq!(restored.len(), 2); + assert!(!restored + .iter() + .any(|session| session.session_id == "session-c")); + + let frame = AgentLiveChannelFrame::SnapshotRequired(AgentLiveSnapshotRequiredFrame { + live_event_version: AGENT_LIVE_PRESENTATION_VERSION, + event_type: "snapshotRequired", + target_id: owner().target_id, + host_epoch: "11".into(), + connection_generation: 12, + reason: AgentLiveSnapshotReason::PausedSubscriberOverflow, + last_event_cursor: cursor_to_wire(&cursor(4)), + }); + let encoded = serde_json::to_value(frame).unwrap(); + assert_eq!(encoded["liveEventVersion"], 1); + assert_eq!(encoded["eventType"], "snapshotRequired"); + assert_eq!(encoded["hostEpoch"], "11"); + assert_eq!(encoded["reason"], "paused_overflow"); + } + + #[test] + fn expected_lease_requires_canonical_decimal_epoch_and_exact_full_stamp() { + let current = owner(); + let exact = AgentExpectedLiveLease { + target_id: current.target_id.clone(), + host_epoch: "11".into(), + connection_generation: 12, + }; + exact.validate_against(¤t).unwrap(); + for invalid in ["", "0", "01", "+11", " 11", "11 ", "18446744073709551616"] { + let mut candidate = exact.clone(); + candidate.host_epoch = invalid.into(); + assert!(matches!( + candidate.validate_against(¤t), + Err(AgentLiveAttachError::InvalidRequest { .. }) + )); + } + let mut restarted = exact.clone(); + restarted.host_epoch = "12".into(); + restarted.connection_generation = 1; + assert_eq!( + restarted.validate_against(¤t), + Err(AgentLiveAttachError::StaleLease) + ); + } + + #[test] + fn managed_state_is_typed_unavailable_until_native_authority_is_installed() { + assert_eq!( + AgentLiveTauriState::disabled().enabled().err(), + Some(AgentLiveAttachError::Unavailable) + ); + } + + #[test] + fn host_restart_epoch_prevents_generation_aba_in_owner_and_wire() { + let old = owner(); + let mut restarted = old.clone(); + restarted.connection_stamp = ConnectionStamp::new(12, 1).unwrap(); + assert_ne!(old, restarted); + let old_expected = AgentExpectedLiveLease { + target_id: old.target_id.clone(), + host_epoch: old.connection_stamp.host_epoch().to_string(), + connection_generation: old.connection_stamp.generation(), + }; + assert_eq!( + old_expected.validate_against(&restarted), + Err(AgentLiveAttachError::StaleLease) + ); + let serialized = serde_json::to_value(AgentExpectedLiveLease { + target_id: restarted.target_id, + host_epoch: restarted.connection_stamp.host_epoch().to_string(), + connection_generation: restarted.connection_stamp.generation(), + }) + .unwrap(); + assert_eq!(serialized["hostEpoch"], "12"); + assert_eq!(serialized["connectionGeneration"], 1); + } + + #[test] + fn closed_live_wire_is_versioned_exhaustive_and_hides_commit_storage_fields() { + let projector = ClosedAgentLiveDeliveryProjector; + let events = vec![ + MapleLiveEvent::RunStarted { + event_id: "event-run-started".into(), + }, + MapleLiveEvent::TimelineUpsert { + event_id: "event-upsert".into(), + item: live_item("safe-item"), + }, + MapleLiveEvent::TimelineCleared { + event_id: "event-cleared".into(), + reason: MapleLiveClearReason::ExplicitReload, + }, + MapleLiveEvent::HistoryReplaced { + event_id: "event-replaced".into(), + }, + MapleLiveEvent::HistoryHeadCommitted { + event_id: "private-commit-id".into(), + history_revision: "private-storage-revision".into(), + through_event_cursor: cursor(0), + }, + MapleLiveEvent::SessionUpdated { + event_id: "event-session".into(), + session: MapleLiveSessionSummary { + id: "session".into(), + title: "Title".into(), + project_root: "/project".into(), + created_ms: 1, + updated_ms: 2, + page_sort_ms: 3, + message_count: 4, + model: None, + mode: "auto".into(), + }, + }, + MapleLiveEvent::RunFinished { + event_id: "event-finished".into(), + terminal: MapleLiveRunTerminal::Completed, + }, + MapleLiveEvent::SessionDeleted { + event_id: "event-deleted".into(), + }, + MapleLiveEvent::UserFacingError { + event_id: "event-error".into(), + error: MapleLiveUserFacingError { + id: "safe-error".into(), + kind: crate::agent_live_coordinator::MapleLiveUserFacingErrorKind::Error, + title: Some("Agent error".into()), + message: SAFE_REMOTE_AGENT_ERROR.into(), + created_ms: 5, + }, + }, + ]; + let expected_types = [ + "runStarted", + "timelineUpsert", + "timelineCleared", + "historyReplaced", + "cursorAdvanced", + "sessionUpdated", + "runFinished", + "sessionDeleted", + "userFacingError", + ]; + for (index, (event, expected_type)) in events.into_iter().zip(expected_types).enumerate() { + let run_id = match &event { + MapleLiveEvent::HistoryHeadCommitted { .. } + | MapleLiveEvent::SessionDeleted { .. } + | MapleLiveEvent::TimelineCleared { + reason: MapleLiveClearReason::ExplicitReload, + .. + } => None, + _ => Some("run".into()), + }; + let delivery = AgentLiveDelivery { + cursor: cursor(index as u64 + 1), + session_id: "session".into(), + run_id, + event, + }; + let projected = projector.project_delivery(&delivery).unwrap(); + let encoded = serde_json::to_value(AgentOrderedLiveEvent { + live_event_version: AGENT_LIVE_PRESENTATION_VERSION, + target_id: owner().target_id, + host_epoch: "11".into(), + connection_generation: 12, + event_epoch: delivery.cursor.journal_id().into(), + event_sequence: delivery.cursor.sequence(), + session_id: delivery.session_id, + run_id: delivery.run_id, + event: projected, + }) + .unwrap(); + assert_eq!(encoded["liveEventVersion"], 1); + assert_eq!(encoded["eventType"], expected_type); + let bytes = serde_json::to_string(&encoded).unwrap(); + assert!(!bytes.contains("private-storage-revision")); + assert!(!bytes.contains("private-commit-id")); + assert!(!bytes.contains("eventId")); + assert!(!bytes.contains("input")); + assert!(!bytes.contains("output")); + } + } + + #[test] + fn closed_wire_rejects_crate_internal_unredacted_tool_and_error_payloads() { + let projector = ClosedAgentLiveDeliveryProjector; + for (item_type, title, text, status) in [ + ( + MapleLiveItemType::Tool, + "run curl https://secret.invalid?token=hunter2", + "/Users/alice/.env API_KEY=hunter2", + "failed", + ), + ( + MapleLiveItemType::Error, + "provider parser failed", + "token=hunter2 at /Users/alice/private", + "failed", + ), + ] { + let delivery = AgentLiveDelivery { + cursor: cursor(1), + session_id: "session".into(), + run_id: Some("run".into()), + event: MapleLiveEvent::TimelineUpsert { + event_id: "event-redaction-guard".into(), + item: MapleLiveTimelineItem { + id: "unsafe-item".into(), + item_type, + role: None, + title: Some(title.into()), + text: Some(text.into()), + status: Some(status.into()), + created_ms: 1, + merge: MapleLiveMerge::Replace, + }, + }, + }; + assert_eq!( + projector.project_delivery(&delivery), + Err(AgentLiveAttachError::ProjectionRejected) + ); + } + } +} diff --git a/frontend/src-tauri/src/agent_remote_portable.rs b/frontend/src-tauri/src/agent_remote_portable.rs new file mode 100644 index 000000000..4f46c1efe --- /dev/null +++ b/frontend/src-tauri/src/agent_remote_portable.rs @@ -0,0 +1,5552 @@ +//! Fail-closed native composition for portable, persisted-only Agent access. +//! +//! Stored registry bytes are untrusted recovery material. They never construct +//! transport authority directly: only an injected verifier backed by a released +//! OpenSecret SDK may return the sealed verified registry consumed below. The +//! production composition remains disabled until that verifier, a secure store, +//! and a controller peer factory are installed in a later slice. +#![allow( + dead_code, + reason = "portable Agent composition is production-disabled until verified dependencies land" +)] + +use std::{ + any::Any, + collections::{HashMap, HashSet}, + future::Future, + net::SocketAddr, + pin::Pin, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, +}; + +use getrandom::fill as fill_random; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::Notify; + +#[cfg(any(mobile, test))] +#[path = "agent_remote_portable_tauri.rs"] +pub(crate) mod tauri; + +const STORED_STATE_MAGIC: &[u8; 8] = b"MAPRSTA1"; +const STORED_GUARD_MAGIC: &[u8; 8] = b"MAPRGRD1"; +const STORED_SCHEMA_VERSION: u16 = 1; +const STORED_STATE_KIND: u8 = 1; +const STORED_GUARD_KIND: u8 = 2; +const DIGEST_BYTES: usize = 32; +const STATE_FIXED_BYTES: usize = 8 + 2 + 1 + DIGEST_BYTES + 8 + 4 + DIGEST_BYTES + DIGEST_BYTES; +const GUARD_FIXED_BYTES: usize = 8 + 2 + 1 + DIGEST_BYTES + 8 + DIGEST_BYTES + DIGEST_BYTES; +const MAX_STORED_REGISTRY_BYTES: usize = 1024 * 1024; +const MAX_STORED_BODY_BYTES: usize = MAX_STORED_REGISTRY_BYTES - STATE_FIXED_BYTES; +const MAX_CURRENT_TARGETS: usize = 64; +const MAX_RETAINED_LINEAGE_TOMBSTONES: usize = 128; +const MAX_OPAQUE_EVIDENCE_BYTES: usize = 64 * 1024; +const MAX_OPAQUE_LINEAGE_BYTES: usize = 16 * 1024; +const MAX_TARGET_LABEL_BYTES: usize = 256; +const MAX_TARGET_LABEL_CHARS: usize = 80; +const MAX_RELAY_HINTS: usize = 4; +const MAX_DIRECT_ADDRESS_HINTS: usize = 16; +const MAX_RELAY_URL_BYTES: usize = 512; +const MAX_DIRECT_ADDRESS_BYTES: usize = 64; +const MAX_CURSOR_BYTES: usize = 512; +const MAX_ID_BYTES: usize = 128; +const MAX_PAGE_SIZE: u16 = 50; +const MAX_RUNTIME_ACTIVE_RUNS: u16 = 64; +const MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER: u64 = 9_007_199_254_740_991; +const MAX_JAVASCRIPT_SAFE_INTEGER: i64 = 9_007_199_254_740_991; +const MAX_BACKEND_COUNTER: u64 = i64::MAX as u64; +const MAX_HISTORY_ITEMS_PER_RECORD: usize = 200; +const MAX_TIMELINE_TEXT_BYTES: usize = 192 * 1024; +const MAX_PORTABLE_HISTORY_RECORD_BYTES: usize = 1_040_384; + +const PAIR_AUTHORIZATION_EVIDENCE_FORMAT: &str = "os.maple-pair-authorization.v1"; +const QUIESCENT_LINEAGE_FORMAT: &str = "cloud.opensecret.maple.transport-lineage.quiescent.v1"; +const UNCERTAIN_LINEAGE_FORMAT: &str = "cloud.opensecret.maple.transport-lineage.uncertain.v1"; + +const STORED_BODY_DIGEST_DOMAIN: &[u8] = b"cloud.opensecret.maple/portable-registry-body/v1\0"; +const STORED_STATE_CHECKSUM_DOMAIN: &[u8] = b"cloud.opensecret.maple/portable-registry-state/v1\0"; +const STORED_GUARD_CHECKSUM_DOMAIN: &[u8] = b"cloud.opensecret.maple/portable-registry-guard/v1\0"; + +pub(crate) type PortableFuture<'a, T> = Pin + Send + 'a>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentPortableRemoteError { + Unavailable, + UnsupportedStoredVersion, + CorruptStoredRegistry, + InvalidStoredRegistry, + StoredRegistryRollback, + StoredRegistryInterrupted, + StoredRegistryEquivocation, + StoredRegistryConflict, + DuplicateStoredTarget, + Unauthenticated, + AccountMismatch, + Revoked, + VerificationFailed, + UnknownTarget, + Busy, + Cancelled, + StaleLease, + InvalidRequest, + InvalidResponse, + PeerUnavailable, + CleanupFailed, + Internal, +} + +impl std::fmt::Display for AgentPortableRemoteError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::Unavailable => "portable Agent access is unavailable", + Self::UnsupportedStoredVersion => "stored portable Agent state is unsupported", + Self::CorruptStoredRegistry => "stored portable Agent state is corrupt", + Self::InvalidStoredRegistry => "stored portable Agent state is invalid", + Self::StoredRegistryRollback => "stored portable Agent state regressed", + Self::StoredRegistryInterrupted => "stored portable Agent state needs reconciliation", + Self::StoredRegistryEquivocation => "stored portable Agent state conflicts", + Self::StoredRegistryConflict => "portable Agent state changed concurrently", + Self::DuplicateStoredTarget => "stored portable Agent targets are duplicated", + Self::Unauthenticated => "portable Agent authentication is unavailable", + Self::AccountMismatch => "portable Agent account binding changed", + Self::Revoked => "portable Agent pairing is revoked", + Self::VerificationFailed => "portable Agent pairing could not be verified", + Self::UnknownTarget => "portable Agent target is unavailable", + Self::Busy => "portable Agent target is changing", + Self::Cancelled => "portable Agent operation was cancelled", + Self::StaleLease => "portable Agent target lease is stale", + Self::InvalidRequest => "portable Agent request is invalid", + Self::InvalidResponse => "portable Agent response is invalid", + Self::PeerUnavailable => "portable Agent peer is unavailable", + Self::CleanupFailed => "portable Agent cleanup did not complete", + Self::Internal => "portable Agent state is unavailable", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AgentPortableRemoteError {} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct PairedTargetStorageKeyDigest([u8; DIGEST_BYTES]); + +impl std::fmt::Debug for PairedTargetStorageKeyDigest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("PairedTargetStorageKeyDigest()") + } +} + +impl PairedTargetStorageKeyDigest { + fn validate(self) -> Result<(), AgentPortableRemoteError> { + if self.0.iter().all(|byte| *byte == 0) { + Err(AgentPortableRemoteError::InvalidStoredRegistry) + } else { + Ok(()) + } + } + + #[cfg(test)] + fn for_test(byte: u8) -> Self { + Self([byte; DIGEST_BYTES]) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct StoredOpaqueEvidenceV1 { + format: String, + bytes: Vec, + digest: [u8; DIGEST_BYTES], +} + +impl std::fmt::Debug for StoredOpaqueEvidenceV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredOpaqueEvidenceV1") + .field("format", &self.format) + .field("byte_count", &self.bytes.len()) + .field("digest", &"") + .finish() + } +} + +impl StoredOpaqueEvidenceV1 { + fn validate(&self, expected_format: Option<&str>) -> Result<(), AgentPortableRemoteError> { + if expected_format.is_some_and(|expected| self.format != expected) + || self.format.is_empty() + || self.format.len() > MAX_ID_BYTES + || !self.format.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'/') + }) + || self.bytes.is_empty() + || self.bytes.len() > MAX_OPAQUE_EVIDENCE_BYTES + || self.digest.iter().all(|byte| *byte == 0) + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + Ok(()) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct StoredConnectionLineageFloorV1 { + host_epoch: u64, + generation: u64, +} + +impl std::fmt::Debug for StoredConnectionLineageFloorV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("StoredConnectionLineageFloorV1()") + } +} + +impl StoredConnectionLineageFloorV1 { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_backend_counter(self.host_epoch)?; + validate_backend_counter(self.generation) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)] +pub(crate) enum StoredTransportLineageV1 { + Quiescent { + format: String, + lineage_revision: u64, + bytes: Vec, + digest: [u8; DIGEST_BYTES], + replay_floor: Option, + }, + Uncertain { + format: String, + lineage_revision: u64, + bytes: Vec, + digest: [u8; DIGEST_BYTES], + replay_floor: Option, + }, +} + +impl std::fmt::Debug for StoredTransportLineageV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Quiescent { + bytes, + replay_floor, + .. + } => formatter + .debug_struct("Quiescent") + .field("byte_count", &bytes.len()) + .field("replay_floor", replay_floor) + .finish(), + Self::Uncertain { bytes, .. } => formatter + .debug_struct("Uncertain") + .field("byte_count", &bytes.len()) + .finish(), + } + } +} + +impl StoredTransportLineageV1 { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + let (format, lineage_revision, bytes, digest) = match self { + Self::Quiescent { + format, + lineage_revision, + bytes, + digest, + replay_floor, + } => { + if let Some(floor) = replay_floor { + floor.validate()?; + } + if format != QUIESCENT_LINEAGE_FORMAT { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + (format, lineage_revision, bytes, digest) + } + Self::Uncertain { + format, + lineage_revision, + bytes, + digest, + replay_floor, + } => { + if let Some(floor) = replay_floor { + floor.validate()?; + } + if format != UNCERTAIN_LINEAGE_FORMAT { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + (format, lineage_revision, bytes, digest) + } + }; + if format.is_empty() + || validate_backend_counter(*lineage_revision).is_err() + || bytes.is_empty() + || bytes.len() > MAX_OPAQUE_LINEAGE_BYTES + || digest.iter().all(|byte| *byte == 0) + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + Ok(()) + } + + fn is_quiescent(&self) -> bool { + matches!(self, Self::Quiescent { .. }) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct StoredConnectionHintsV1 { + /// Untrusted endpoint hints. A peer factory must intersect these with its + /// independently configured relay and destination policy before dialing. + relay_urls: Vec, + direct_addresses: Vec, +} + +impl std::fmt::Debug for StoredConnectionHintsV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredConnectionHintsV1") + .field("relay_count", &self.relay_urls.len()) + .field("direct_address_count", &self.direct_addresses.len()) + .finish() + } +} + +impl StoredConnectionHintsV1 { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + if self.relay_urls.len() > MAX_RELAY_HINTS + || self.direct_addresses.len() > MAX_DIRECT_ADDRESS_HINTS + || self.relay_urls.len() + self.direct_addresses.len() == 0 + || !is_strictly_sorted_unique(&self.relay_urls) + || !is_strictly_sorted_unique(&self.direct_addresses) + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + for value in &self.relay_urls { + if value.len() > MAX_RELAY_URL_BYTES { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + let parsed = value + .parse::() + .map_err(|_| AgentPortableRemoteError::InvalidStoredRegistry)?; + if parsed.as_str() != value + || parsed.scheme() != "https" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + } + for value in &self.direct_addresses { + if value.len() > MAX_DIRECT_ADDRESS_BYTES { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + let parsed = value + .parse::() + .map_err(|_| AgentPortableRemoteError::InvalidStoredRegistry)?; + if parsed.to_string() != *value { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + } + Ok(()) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct StoredRevocationNamespaceV1 { + stream_id: String, + generation: u64, + applied_sequence: u64, + checkpoint_digest: [u8; DIGEST_BYTES], +} + +impl std::fmt::Debug for StoredRevocationNamespaceV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredRevocationNamespaceV1") + .field("generation", &self.generation) + .field("applied_sequence", &self.applied_sequence) + .field("checkpoint_digest", &"") + .finish_non_exhaustive() + } +} + +impl StoredRevocationNamespaceV1 { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_uuid("revocation stream", &self.stream_id)?; + validate_backend_counter(self.generation)?; + if self.applied_sequence > MAX_BACKEND_COUNTER + || self.checkpoint_digest.iter().all(|byte| *byte == 0) + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + Ok(()) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct StoredPairedTargetV1 { + pair_id: String, + pairing_revision: u64, + directory_revision: u64, + host_registration_id: String, + host_device_id: String, + host_installation_id: String, + host_endpoint_id: String, + host_endpoint_epoch: u64, + host_display_name: String, + pairing_incarnation: u64, + authorization: StoredOpaqueEvidenceV1, + revocation: StoredRevocationNamespaceV1, + connection_hints: StoredConnectionHintsV1, + transport_lineage: StoredTransportLineageV1, +} + +impl std::fmt::Debug for StoredPairedTargetV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredPairedTargetV1") + .field("pairing_revision", &self.pairing_revision) + .field("directory_revision", &self.directory_revision) + .field("host_endpoint_epoch", &self.host_endpoint_epoch) + .field("pairing_incarnation", &self.pairing_incarnation) + .field("authorization", &self.authorization) + .field( + "connection_hint_count", + &(self.connection_hints.relay_urls.len() + + self.connection_hints.direct_addresses.len()), + ) + .field("transport_lineage", &self.transport_lineage) + .finish_non_exhaustive() + } +} + +impl StoredPairedTargetV1 { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_uuid("pair", &self.pair_id)?; + validate_backend_counter(self.pairing_revision)?; + validate_backend_counter(self.directory_revision)?; + validate_uuid("host registration", &self.host_registration_id)?; + validate_uuid("host device", &self.host_device_id)?; + validate_uuid("host installation", &self.host_installation_id)?; + validate_endpoint_id(&self.host_endpoint_id)?; + validate_backend_counter(self.host_endpoint_epoch)?; + validate_display_label(&self.host_display_name)?; + validate_backend_counter(self.pairing_incarnation)?; + self.authorization + .validate(Some(PAIR_AUTHORIZATION_EVIDENCE_FORMAT))?; + self.revocation.validate()?; + self.connection_hints.validate()?; + self.transport_lineage.validate()?; + Ok(()) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct StoredLineageTombstoneV1 { + host_registration_id: String, + host_endpoint_id: String, + pair_id: String, + retired_pairing_incarnation: u64, + retired_authorization_revision: u64, + replay_floor: Option, +} + +impl std::fmt::Debug for StoredLineageTombstoneV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredLineageTombstoneV1") + .field( + "retired_pairing_incarnation", + &self.retired_pairing_incarnation, + ) + .field( + "retired_authorization_revision", + &self.retired_authorization_revision, + ) + .field("replay_floor", &self.replay_floor) + .finish_non_exhaustive() + } +} + +impl StoredLineageTombstoneV1 { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_uuid("retired host registration", &self.host_registration_id)?; + validate_endpoint_id(&self.host_endpoint_id)?; + validate_uuid("retired pair", &self.pair_id)?; + validate_backend_counter(self.retired_pairing_incarnation)?; + validate_backend_counter(self.retired_authorization_revision)?; + if let Some(floor) = &self.replay_floor { + floor.validate()?; + } + Ok(()) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct StoredPairedTargetRegistryV1 { + account_id: String, + project_id: String, + local_registration_id: String, + local_device_id: String, + local_installation_id: String, + controller_endpoint_id: String, + controller_endpoint_epoch: u64, + account_context_epoch: u64, + security_epoch: u64, + authorization_snapshot_revision: u64, + registration_evidence: StoredOpaqueEvidenceV1, + revocation_sync_evidence: StoredOpaqueEvidenceV1, + targets: Vec, + lineage_tombstones: Vec, +} + +impl std::fmt::Debug for StoredPairedTargetRegistryV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredPairedTargetRegistryV1") + .field("controller_endpoint_epoch", &self.controller_endpoint_epoch) + .field("account_context_epoch", &self.account_context_epoch) + .field("security_epoch", &self.security_epoch) + .field( + "authorization_snapshot_revision", + &self.authorization_snapshot_revision, + ) + .field("registration_evidence", &self.registration_evidence) + .field("revocation_sync_evidence", &self.revocation_sync_evidence) + .field("target_count", &self.targets.len()) + .field("lineage_tombstone_count", &self.lineage_tombstones.len()) + .finish_non_exhaustive() + } +} + +impl StoredPairedTargetRegistryV1 { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_uuid("account", &self.account_id)?; + validate_uuid("project", &self.project_id)?; + validate_uuid("local registration", &self.local_registration_id)?; + validate_uuid("local device", &self.local_device_id)?; + validate_uuid("local installation", &self.local_installation_id)?; + validate_endpoint_id(&self.controller_endpoint_id)?; + validate_backend_counter(self.controller_endpoint_epoch)?; + validate_backend_counter(self.account_context_epoch)?; + validate_backend_counter(self.security_epoch)?; + validate_backend_counter(self.authorization_snapshot_revision)?; + self.registration_evidence.validate(None)?; + self.revocation_sync_evidence.validate(None)?; + if self.targets.len() > MAX_CURRENT_TARGETS + || self.lineage_tombstones.len() > MAX_RETAINED_LINEAGE_TOMBSTONES + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + + let target_keys = self + .targets + .iter() + .map(|target| target.host_registration_id.as_str()) + .collect::>(); + if !is_strictly_sorted_unique(&target_keys) { + return Err(AgentPortableRemoteError::DuplicateStoredTarget); + } + let tombstone_keys = self + .lineage_tombstones + .iter() + .map(|tombstone| { + ( + tombstone.host_registration_id.as_str(), + tombstone.retired_pairing_incarnation, + ) + }) + .collect::>(); + if !is_strictly_sorted_unique(&tombstone_keys) { + return Err(AgentPortableRemoteError::DuplicateStoredTarget); + } + + let mut pair_ids = HashSet::with_capacity(self.targets.len()); + let mut host_endpoint_ids = HashSet::with_capacity(self.targets.len()); + for target in &self.targets { + target.validate()?; + if target.host_endpoint_id == self.controller_endpoint_id + || !pair_ids.insert(target.pair_id.as_str()) + || !host_endpoint_ids.insert(target.host_endpoint_id.as_str()) + { + return Err(AgentPortableRemoteError::StoredRegistryEquivocation); + } + let retired_floor = self + .lineage_tombstones + .iter() + .filter(|tombstone| tombstone.host_registration_id == target.host_registration_id) + .map(|tombstone| tombstone.retired_pairing_incarnation) + .max(); + if retired_floor.is_some_and(|floor| target.pairing_incarnation <= floor) { + return Err(AgentPortableRemoteError::StoredRegistryRollback); + } + } + for tombstone in &self.lineage_tombstones { + tombstone.validate()?; + if !pair_ids.insert(tombstone.pair_id.as_str()) { + return Err(AgentPortableRemoteError::StoredRegistryEquivocation); + } + } + Ok(()) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct StoredRegistryEnvelopeV1 { + storage_key_digest: PairedTargetStorageKeyDigest, + storage_revision: u64, + registry: StoredPairedTargetRegistryV1, + body_digest: [u8; DIGEST_BYTES], + record_checksum: [u8; DIGEST_BYTES], +} + +impl std::fmt::Debug for StoredRegistryEnvelopeV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredRegistryEnvelopeV1") + .field("storage_key_digest", &self.storage_key_digest) + .field("storage_revision", &self.storage_revision) + .field("registry", &self.registry) + .field("body_digest", &"") + .field("record_checksum", &"") + .finish() + } +} + +impl StoredRegistryEnvelopeV1 { + fn new( + storage_key_digest: PairedTargetStorageKeyDigest, + storage_revision: u64, + registry: StoredPairedTargetRegistryV1, + ) -> Result { + storage_key_digest.validate()?; + validate_backend_counter(storage_revision)?; + registry.validate()?; + let body = encode_registry_body(®istry)?; + let body_digest = digest_parts(STORED_BODY_DIGEST_DOMAIN, &[&body]); + let record_checksum = state_checksum( + storage_key_digest, + storage_revision, + body.len(), + &body, + body_digest, + ); + Ok(Self { + storage_key_digest, + storage_revision, + registry, + body_digest, + record_checksum, + }) + } + + fn encode(&self) -> Result, AgentPortableRemoteError> { + self.storage_key_digest.validate()?; + validate_backend_counter(self.storage_revision)?; + self.registry.validate()?; + let body = encode_registry_body(&self.registry)?; + let body_digest = digest_parts(STORED_BODY_DIGEST_DOMAIN, &[&body]); + let checksum = state_checksum( + self.storage_key_digest, + self.storage_revision, + body.len(), + &body, + body_digest, + ); + if body_digest != self.body_digest || checksum != self.record_checksum { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let mut encoded = Vec::with_capacity(STATE_FIXED_BYTES + body.len()); + encoded.extend_from_slice(STORED_STATE_MAGIC); + encoded.extend_from_slice(&STORED_SCHEMA_VERSION.to_be_bytes()); + encoded.push(STORED_STATE_KIND); + encoded.extend_from_slice(&self.storage_key_digest.0); + encoded.extend_from_slice(&self.storage_revision.to_be_bytes()); + encoded.extend_from_slice( + &u32::try_from(body.len()) + .map_err(|_| AgentPortableRemoteError::InvalidStoredRegistry)? + .to_be_bytes(), + ); + encoded.extend_from_slice(&body); + encoded.extend_from_slice(&body_digest); + encoded.extend_from_slice(&checksum); + Ok(encoded) + } + + fn decode(encoded: &[u8]) -> Result { + if encoded.len() < STATE_FIXED_BYTES || encoded.len() > MAX_STORED_REGISTRY_BYTES { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + if &encoded[..8] != STORED_STATE_MAGIC { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let version = read_u16(encoded, 8)?; + if version != STORED_SCHEMA_VERSION { + return Err(AgentPortableRemoteError::UnsupportedStoredVersion); + } + if encoded[10] != STORED_STATE_KIND { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let storage_key_digest = + PairedTargetStorageKeyDigest(read_array::(encoded, 11)?); + storage_key_digest.validate()?; + let storage_revision = read_u64(encoded, 43)?; + validate_backend_counter(storage_revision)?; + let body_len = usize::try_from(read_u32(encoded, 51)?) + .map_err(|_| AgentPortableRemoteError::CorruptStoredRegistry)?; + if body_len == 0 || body_len > MAX_STORED_BODY_BYTES { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let expected_len = STATE_FIXED_BYTES + .checked_add(body_len) + .ok_or(AgentPortableRemoteError::CorruptStoredRegistry)?; + if encoded.len() != expected_len { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let body_start = 55; + let body_end = body_start + body_len; + let body = &encoded[body_start..body_end]; + let stored_body_digest = read_array::(encoded, body_end)?; + let stored_checksum = read_array::(encoded, body_end + DIGEST_BYTES)?; + let expected_body_digest = digest_parts(STORED_BODY_DIGEST_DOMAIN, &[body]); + let expected_checksum = state_checksum( + storage_key_digest, + storage_revision, + body_len, + body, + expected_body_digest, + ); + if stored_body_digest != expected_body_digest || stored_checksum != expected_checksum { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let registry: StoredPairedTargetRegistryV1 = decode_json_exact(body)?; + registry.validate()?; + let canonical_body = encode_registry_body(®istry)?; + if canonical_body != body { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + Ok(Self { + storage_key_digest, + storage_revision, + registry, + body_digest: stored_body_digest, + record_checksum: stored_checksum, + }) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct StoredRegistryGuardV1 { + storage_key_digest: PairedTargetStorageKeyDigest, + committed_revision: u64, + committed_state_digest: [u8; DIGEST_BYTES], + checksum: [u8; DIGEST_BYTES], +} + +impl std::fmt::Debug for StoredRegistryGuardV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredRegistryGuardV1") + .field("storage_key_digest", &self.storage_key_digest) + .field("committed_revision", &self.committed_revision) + .field("committed_state_digest", &"") + .field("checksum", &"") + .finish() + } +} + +impl StoredRegistryGuardV1 { + fn initial( + storage_key_digest: PairedTargetStorageKeyDigest, + ) -> Result { + Self::new(storage_key_digest, 0, [0; DIGEST_BYTES]) + } + + fn committed(envelope: &StoredRegistryEnvelopeV1) -> Result { + Self::new( + envelope.storage_key_digest, + envelope.storage_revision, + envelope.record_checksum, + ) + } + + fn new( + storage_key_digest: PairedTargetStorageKeyDigest, + committed_revision: u64, + committed_state_digest: [u8; DIGEST_BYTES], + ) -> Result { + storage_key_digest.validate()?; + if (committed_revision == 0) != committed_state_digest.iter().all(|byte| *byte == 0) { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + if committed_revision > MAX_BACKEND_COUNTER { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + let checksum = guard_checksum( + storage_key_digest, + committed_revision, + committed_state_digest, + ); + Ok(Self { + storage_key_digest, + committed_revision, + committed_state_digest, + checksum, + }) + } + + fn encode(self) -> Result, AgentPortableRemoteError> { + self.storage_key_digest.validate()?; + if (self.committed_revision == 0) + != self.committed_state_digest.iter().all(|byte| *byte == 0) + || self.checksum + != guard_checksum( + self.storage_key_digest, + self.committed_revision, + self.committed_state_digest, + ) + { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let mut encoded = Vec::with_capacity(GUARD_FIXED_BYTES); + encoded.extend_from_slice(STORED_GUARD_MAGIC); + encoded.extend_from_slice(&STORED_SCHEMA_VERSION.to_be_bytes()); + encoded.push(STORED_GUARD_KIND); + encoded.extend_from_slice(&self.storage_key_digest.0); + encoded.extend_from_slice(&self.committed_revision.to_be_bytes()); + encoded.extend_from_slice(&self.committed_state_digest); + encoded.extend_from_slice(&self.checksum); + Ok(encoded) + } + + fn decode(encoded: &[u8]) -> Result { + if encoded.len() != GUARD_FIXED_BYTES || &encoded[..8] != STORED_GUARD_MAGIC { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let version = read_u16(encoded, 8)?; + if version != STORED_SCHEMA_VERSION { + return Err(AgentPortableRemoteError::UnsupportedStoredVersion); + } + if encoded[10] != STORED_GUARD_KIND { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + let storage_key_digest = PairedTargetStorageKeyDigest(read_array(encoded, 11)?); + storage_key_digest.validate()?; + let committed_revision = read_u64(encoded, 43)?; + let committed_state_digest = read_array(encoded, 51)?; + let checksum = read_array(encoded, 83)?; + let guard = Self::new( + storage_key_digest, + committed_revision, + committed_state_digest, + )?; + if guard.checksum != checksum { + return Err(AgentPortableRemoteError::CorruptStoredRegistry); + } + Ok(guard) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) struct StoredRegistryCommitToken { + storage_key_digest: PairedTargetStorageKeyDigest, + committed_revision: u64, + committed_state_digest: [u8; DIGEST_BYTES], +} + +impl std::fmt::Debug for StoredRegistryCommitToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredRegistryCommitToken") + .field("storage_key_digest", &self.storage_key_digest) + .field("committed_revision", &self.committed_revision) + .field("committed_state_digest", &"") + .finish() + } +} + +impl From for StoredRegistryCommitToken { + fn from(guard: StoredRegistryGuardV1) -> Self { + Self { + storage_key_digest: guard.storage_key_digest, + committed_revision: guard.committed_revision, + committed_state_digest: guard.committed_state_digest, + } + } +} + +#[derive(Clone)] +pub(crate) enum StoredRegistryLoad { + Empty { + token: StoredRegistryCommitToken, + }, + Committed { + envelope: Box, + token: StoredRegistryCommitToken, + }, +} + +impl std::fmt::Debug for StoredRegistryLoad { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty { token } => formatter + .debug_struct("Empty") + .field("token", token) + .finish(), + Self::Committed { envelope, token } => formatter + .debug_struct("Committed") + .field("storage_revision", &envelope.storage_revision) + .field("token", token) + .finish(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StoredRegistryCommitOutcome { + Committed, + AlreadyCommitted, +} + +/// Sealed authorization to persist one complete registry replacement. +/// +/// There is intentionally no production constructor in this slice. A future +/// released SDK verifier must own construction after authenticating a complete +/// control-plane snapshot and the installation-global account context. Raw +/// stored bytes can never mint this value. +pub(crate) struct VerifiedStoredRegistryReplacement { + envelope: StoredRegistryEnvelopeV1, +} + +impl std::fmt::Debug for VerifiedStoredRegistryReplacement { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VerifiedStoredRegistryReplacement") + .field("storage_revision", &self.envelope.storage_revision) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +impl VerifiedStoredRegistryReplacement { + fn for_test(envelope: StoredRegistryEnvelopeV1) -> Self { + Self { envelope } + } +} + +pub(crate) trait PairedTargetAuthorityStore: Send + Sync { + /// Loads the single installation-scoped active registry slot. The key is + /// installation-scoped; account changes replace this same slot by CAS. + fn load( + &self, + expected_key: PairedTargetStorageKeyDigest, + ) -> Result; + + /// Replaces the entire registry and its guard. A production implementation + /// must put both records behind its secure-store lock and preserve the exact + /// interrupted/equivocation classification used by the in-memory reference. + fn compare_and_replace( + &self, + expected: StoredRegistryCommitToken, + replacement: &VerifiedStoredRegistryReplacement, + ) -> Result; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(test), allow(dead_code))] +enum InMemoryStoreFault { + BeforeState, + AfterState, + AfterGuard, +} + +#[derive(Clone, Default)] +pub(crate) struct InMemoryPairedTargetAuthorityStore { + inner: Arc>, +} + +#[derive(Default)] +struct InMemoryPairedTargetAuthorityStoreState { + state_record: Option>, + guard_record: Option>, + next_fault: Option, +} + +impl std::fmt::Debug for InMemoryPairedTargetAuthorityStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("InMemoryPairedTargetAuthorityStore()") + } +} + +impl InMemoryPairedTargetAuthorityStore { + fn take_fault_if( + state: &mut InMemoryPairedTargetAuthorityStoreState, + expected: InMemoryStoreFault, + ) -> bool { + if state.next_fault == Some(expected) { + state.next_fault = None; + true + } else { + false + } + } + + #[cfg(test)] + fn inject_fault_once(&self, fault: InMemoryStoreFault) { + self.inner.lock().unwrap().next_fault = Some(fault); + } + + #[cfg(test)] + fn raw_snapshot(&self) -> (Option>, Option>) { + let state = self.inner.lock().unwrap(); + (state.state_record.clone(), state.guard_record.clone()) + } + + #[cfg(test)] + fn restore_raw_snapshot(&self, snapshot: (Option>, Option>)) { + let mut state = self.inner.lock().unwrap(); + state.state_record = snapshot.0; + state.guard_record = snapshot.1; + state.next_fault = None; + } + + #[cfg(test)] + fn mutate_state_record(&self, mutate: impl FnOnce(&mut Vec)) { + if let Some(record) = self.inner.lock().unwrap().state_record.as_mut() { + mutate(record); + } + } +} + +impl PairedTargetAuthorityStore for InMemoryPairedTargetAuthorityStore { + fn load( + &self, + expected_key: PairedTargetStorageKeyDigest, + ) -> Result { + expected_key.validate()?; + let state = self.inner.lock().unwrap(); + classify_stored_registry_slot( + state.state_record.as_deref(), + state.guard_record.as_deref(), + expected_key, + ) + } + + fn compare_and_replace( + &self, + expected: StoredRegistryCommitToken, + replacement: &VerifiedStoredRegistryReplacement, + ) -> Result { + let candidate = &replacement.envelope; + expected.storage_key_digest.validate()?; + if candidate.storage_key_digest != expected.storage_key_digest { + return Err(AgentPortableRemoteError::StoredRegistryConflict); + } + let candidate_state = candidate.encode()?; + let candidate_guard = StoredRegistryGuardV1::committed(candidate)?; + let candidate_guard_record = candidate_guard.encode()?; + + let mut state = self.inner.lock().unwrap(); + let decoded_state = state + .state_record + .as_deref() + .map(StoredRegistryEnvelopeV1::decode) + .transpose()?; + let decoded_guard = state + .guard_record + .as_deref() + .map(StoredRegistryGuardV1::decode) + .transpose()?; + + if decoded_state.as_ref() == Some(candidate) && decoded_guard == Some(candidate_guard) { + return Ok(StoredRegistryCommitOutcome::AlreadyCommitted); + } + + let next_revision = expected + .committed_revision + .checked_add(1) + .ok_or(AgentPortableRemoteError::StoredRegistryConflict)?; + if candidate.storage_revision != next_revision { + return Err(AgentPortableRemoteError::StoredRegistryConflict); + } + + let expected_guard = StoredRegistryGuardV1::new( + expected.storage_key_digest, + expected.committed_revision, + expected.committed_state_digest, + )?; + let current_guard = + decoded_guard.unwrap_or(StoredRegistryGuardV1::initial(expected.storage_key_digest)?); + if current_guard != expected_guard { + return Err(AgentPortableRemoteError::StoredRegistryConflict); + } + + let current = classify_stored_registry_slot( + state.state_record.as_deref(), + state.guard_record.as_deref(), + expected.storage_key_digest, + )?; + let current_token = match ¤t { + StoredRegistryLoad::Empty { token } | StoredRegistryLoad::Committed { token, .. } => { + *token + } + }; + if current_token != expected { + return Err(AgentPortableRemoteError::StoredRegistryConflict); + } + if let StoredRegistryLoad::Committed { envelope, .. } = current { + validate_stored_registry_transition(&envelope.registry, &candidate.registry)?; + } + + if Self::take_fault_if(&mut state, InMemoryStoreFault::BeforeState) { + return Err(AgentPortableRemoteError::StoredRegistryInterrupted); + } + state.state_record = Some(candidate_state); + if Self::take_fault_if(&mut state, InMemoryStoreFault::AfterState) { + return Err(AgentPortableRemoteError::StoredRegistryInterrupted); + } + state.guard_record = Some(candidate_guard_record); + if Self::take_fault_if(&mut state, InMemoryStoreFault::AfterGuard) { + return Err(AgentPortableRemoteError::StoredRegistryInterrupted); + } + Ok(StoredRegistryCommitOutcome::Committed) + } +} + +fn classify_stored_registry_slot( + state_record: Option<&[u8]>, + guard_record: Option<&[u8]>, + expected_key: PairedTargetStorageKeyDigest, +) -> Result { + let state = state_record + .map(StoredRegistryEnvelopeV1::decode) + .transpose()?; + let guard = guard_record + .map(StoredRegistryGuardV1::decode) + .transpose()? + .unwrap_or(StoredRegistryGuardV1::initial(expected_key)?); + if guard.storage_key_digest != expected_key + || state + .as_ref() + .is_some_and(|envelope| envelope.storage_key_digest != expected_key) + { + return Err(AgentPortableRemoteError::AccountMismatch); + } + let token = StoredRegistryCommitToken::from(guard); + match state { + None if guard.committed_revision == 0 => Ok(StoredRegistryLoad::Empty { token }), + None => Err(AgentPortableRemoteError::StoredRegistryRollback), + Some(envelope) if envelope.storage_revision < guard.committed_revision => { + Err(AgentPortableRemoteError::StoredRegistryRollback) + } + Some(envelope) if envelope.storage_revision > guard.committed_revision => { + if guard.committed_revision.checked_add(1) == Some(envelope.storage_revision) { + Err(AgentPortableRemoteError::StoredRegistryInterrupted) + } else { + Err(AgentPortableRemoteError::CorruptStoredRegistry) + } + } + Some(envelope) if envelope.record_checksum != guard.committed_state_digest => { + Err(AgentPortableRemoteError::StoredRegistryEquivocation) + } + Some(envelope) => Ok(StoredRegistryLoad::Committed { + envelope: Box::new(envelope), + token, + }), + } +} + +fn validate_stored_registry_transition( + previous: &StoredPairedTargetRegistryV1, + next: &StoredPairedTargetRegistryV1, +) -> Result<(), AgentPortableRemoteError> { + if next.account_context_epoch < previous.account_context_epoch { + return Err(AgentPortableRemoteError::StoredRegistryRollback); + } + if next.account_context_epoch > previous.account_context_epoch { + // Only a sealed complete replacement reaches this function. A newer + // installation-global context is the reset boundary even when a + // sign-out/re-auth cycle happens to reuse every scalar identity. + return Ok(()); + } + // Within one installation-global context, the complete authenticated + // authority snapshot is immutable. Storage metadata may be replayed at a + // later storage revision, but every semantic change requires a freshly + // sealed complete replacement with a strictly newer context epoch. This + // conservatively fences revocation, re-pair, tombstone, hint, and transport + // lineage transitions behind one native account-context boundary. + if next == previous { + Ok(()) + } else { + Err(AgentPortableRemoteError::StoredRegistryEquivocation) + } +} + +#[derive(Clone)] +pub(crate) struct PortableCancellation { + inner: Arc, +} + +struct PortableCancellationInner { + cancelled: AtomicBool, + notify: Notify, +} + +impl PortableCancellation { + fn new() -> Self { + Self { + inner: Arc::new(PortableCancellationInner { + cancelled: AtomicBool::new(false), + notify: Notify::new(), + }), + } + } + + fn cancel(&self) { + if !self.inner.cancelled.swap(true, Ordering::AcqRel) { + self.inner.notify.notify_waiters(); + } + } + + pub(crate) fn is_cancelled(&self) -> bool { + self.inner.cancelled.load(Ordering::Acquire) + } + + pub(crate) async fn cancelled(&self) { + loop { + let notified = self.inner.notify.notified(); + if self.is_cancelled() { + return; + } + notified.await; + } + } +} + +impl std::fmt::Debug for PortableCancellation { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PortableCancellation") + .field("cancelled", &self.is_cancelled()) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub(crate) struct PortableTargetHandle(String); + +impl std::fmt::Debug for PortableTargetHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("PortableTargetHandle()") + } +} + +impl PortableTargetHandle { + fn issue() -> Result { + Ok(Self(issue_opaque_identifier("target")?)) + } + + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_opaque_identifier(&self.0, "target") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortableTargetDescriptor { + handle: PortableTargetHandle, + label: String, +} + +impl PortableTargetDescriptor { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + self.handle.validate()?; + validate_display_label(&self.label).map_err(|_| AgentPortableRemoteError::InvalidResponse) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct PortableTargetLease { + target_id: String, + host_epoch: u64, + connection_generation: u64, +} + +impl std::fmt::Debug for PortableTargetLease { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PortableTargetLease") + .field("target_id", &"") + .field("host_epoch", &self.host_epoch) + .field("connection_generation", &self.connection_generation) + .finish() + } +} + +impl PortableTargetLease { + fn issue(seed: PortablePeerLeaseSeed) -> Result { + seed.validate()?; + Ok(Self { + target_id: issue_opaque_identifier("lease")?, + host_epoch: seed.host_epoch, + connection_generation: seed.connection_generation, + }) + } + + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_opaque_identifier(&self.target_id, "lease")?; + if self.host_epoch == 0 + || self.connection_generation == 0 + || self.connection_generation > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + { + return Err(AgentPortableRemoteError::StaleLease); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortablePageRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + cursor: Option, + limit: u16, +} + +impl PortablePageRequest { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_page_limit_and_cursor(self.limit, self.cursor.as_deref()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortableRecordsPageRequest { + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cursor: Option, + limit: u16, +} + +impl PortableRecordsPageRequest { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_safe_id(&self.session_id).map_err(|_| AgentPortableRemoteError::InvalidRequest)?; + validate_page_limit_and_cursor(self.limit, self.cursor.as_deref()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortableRuntimeStatus { + running: bool, + active_run_count: u16, +} + +impl PortableRuntimeStatus { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + if self.active_run_count > MAX_RUNTIME_ACTIVE_RUNS + || (!self.running && self.active_run_count != 0) + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortableSessionSummary { + id: String, + title: String, + created_ms: i64, + updated_ms: i64, + page_sort_ms: i64, + message_count: u64, +} + +impl PortableSessionSummary { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_safe_id(&self.id)?; + validate_safe_display_text(&self.title, 1_024, false)?; + for timestamp in [self.created_ms, self.updated_ms, self.page_sort_ms] { + if !(0..=MAX_JAVASCRIPT_SAFE_INTEGER).contains(×tamp) { + return Err(AgentPortableRemoteError::InvalidResponse); + } + } + if self.message_count > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER { + return Err(AgentPortableRemoteError::InvalidResponse); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortableSessionPage { + items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl PortableSessionPage { + fn validate_for(&self, request: &PortablePageRequest) -> Result<(), AgentPortableRemoteError> { + request.validate()?; + validate_page_shape( + self.items.len(), + request.limit, + request.cursor.as_deref(), + self.next_cursor.as_deref(), + )?; + let mut seen_ids = HashSet::with_capacity(self.items.len()); + for item in &self.items { + item.validate()?; + if !seen_ids.insert(item.id.as_str()) { + return Err(AgentPortableRemoteError::InvalidResponse); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortableTimelineItem { + id: String, + item_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, + created_ms: u64, + merge: String, +} + +impl PortableTimelineItem { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + const SAFE_TOOL_TITLE: &str = "Tool activity"; + const SAFE_TOOL_FAILED: &str = + "The tool failed. Open the host for additional diagnostic details."; + const SAFE_TOOL_CANCELLED: &str = "The tool was cancelled."; + const SAFE_PERMISSION_TITLE: &str = "Tool permission"; + const SAFE_AGENT_ERROR: &str = + "The Agent task failed. Open the host for additional diagnostic details."; + + validate_safe_id(&self.id)?; + if self.created_ms > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + || !matches!( + self.item_type.as_str(), + "message" | "thinking" | "tool" | "permission" | "system" | "error" + ) + || self + .role + .as_deref() + .is_some_and(|role| !matches!(role, "user" | "assistant" | "thought" | "system")) + || !matches!(self.merge.as_str(), "append" | "replace") + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + validate_optional_safe_display_text(self.title.as_deref(), 1_024)?; + validate_optional_safe_display_text(self.status.as_deref(), 64)?; + if self + .text + .as_deref() + .is_some_and(|text| text.len() > MAX_TIMELINE_TEXT_BYTES || text.contains('\0')) + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + match self.item_type.as_str() { + "tool" => { + let expected_text = match self.status.as_deref() { + None | Some("pending" | "running" | "completed") => None, + Some("failed" | "error") => Some(SAFE_TOOL_FAILED), + Some("cancelled") => Some(SAFE_TOOL_CANCELLED), + Some(_) => return Err(AgentPortableRemoteError::InvalidResponse), + }; + if self.role.as_deref() != Some("assistant") + || self.title.as_deref() != Some(SAFE_TOOL_TITLE) + || self.text.as_deref() != expected_text + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + } + "permission" => { + if self.role.as_deref() != Some("system") + || self.title.as_deref() != Some(SAFE_PERMISSION_TITLE) + || self.text.is_some() + || !matches!( + self.status.as_deref(), + Some("allow_once" | "deny_once" | "completed" | "cancelled") + ) + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + } + "error" => { + if self.role.as_deref() != Some("system") + || self.title.as_deref() != Some("Agent error") + || self.text.as_deref() != Some(SAFE_AGENT_ERROR) + || self.status.as_deref() != Some("failed") + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + } + _ => {} + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortableHistoryRecord { + record_id: String, + role: String, + created_ms: u64, + items: Vec, +} + +impl PortableHistoryRecord { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_safe_cursor(&self.record_id)?; + if self.role.is_empty() + || self.role.len() > MAX_ID_BYTES + || !self + .role + .bytes() + .all(|byte| byte.is_ascii_graphic() || byte == b' ') + || self.created_ms > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + || self.items.len() > MAX_HISTORY_ITEMS_PER_RECORD + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + for item in &self.items { + item.validate()?; + } + let mut presentation = Vec::new(); + ciborium::ser::into_writer(self, &mut presentation) + .map_err(|_| AgentPortableRemoteError::InvalidResponse)?; + if presentation.len() > MAX_PORTABLE_HISTORY_RECORD_BYTES { + return Err(AgentPortableRemoteError::InvalidResponse); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PortableHistoryPage { + items: Vec, + history_revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl PortableHistoryPage { + fn validate_for( + &self, + request: &PortableRecordsPageRequest, + ) -> Result<(), AgentPortableRemoteError> { + request.validate()?; + validate_safe_cursor(&self.history_revision)?; + validate_page_shape( + self.items.len(), + request.limit, + request.cursor.as_deref(), + self.next_cursor.as_deref(), + )?; + let mut seen_record_ids = HashSet::with_capacity(self.items.len()); + for item in &self.items { + item.validate()?; + if !seen_record_ids.insert(item.record_id.as_str()) { + return Err(AgentPortableRemoteError::InvalidResponse); + } + } + Ok(()) + } +} + +fn validate_page_limit_and_cursor( + limit: u16, + cursor: Option<&str>, +) -> Result<(), AgentPortableRemoteError> { + if !(1..=MAX_PAGE_SIZE).contains(&limit) { + return Err(AgentPortableRemoteError::InvalidRequest); + } + if let Some(cursor) = cursor { + validate_safe_cursor(cursor).map_err(|_| AgentPortableRemoteError::InvalidRequest)?; + } + Ok(()) +} + +fn validate_page_shape( + item_count: usize, + requested_limit: u16, + request_cursor: Option<&str>, + next_cursor: Option<&str>, +) -> Result<(), AgentPortableRemoteError> { + if item_count > usize::from(requested_limit) + || (item_count == 0 && next_cursor.is_some()) + || (next_cursor.is_some() && next_cursor == request_cursor) + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + if let Some(cursor) = next_cursor { + validate_safe_cursor(cursor).map_err(|_| AgentPortableRemoteError::InvalidResponse)?; + } + Ok(()) +} + +fn validate_safe_id(value: &str) -> Result<(), AgentPortableRemoteError> { + if value.len() > MAX_ID_BYTES { + return Err(AgentPortableRemoteError::InvalidResponse); + } + validate_safe_cursor(value) +} + +fn validate_safe_cursor(value: &str) -> Result<(), AgentPortableRemoteError> { + if value.is_empty() + || value.len() > MAX_CURSOR_BYTES + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + Ok(()) +} + +fn validate_safe_display_text( + value: &str, + max_bytes: usize, + allow_empty: bool, +) -> Result<(), AgentPortableRemoteError> { + if (!allow_empty && value.is_empty()) + || value.len() > max_bytes + || value.chars().any(is_unsafe_display_character) + { + return Err(AgentPortableRemoteError::InvalidResponse); + } + Ok(()) +} + +fn validate_optional_safe_display_text( + value: Option<&str>, + max_bytes: usize, +) -> Result<(), AgentPortableRemoteError> { + value.map_or(Ok(()), |value| { + validate_safe_display_text(value, max_bytes, true) + }) +} + +fn issue_opaque_identifier(prefix: &str) -> Result { + let mut random = [0u8; 24]; + fill_random(&mut random).map_err(|_| AgentPortableRemoteError::Internal)?; + let mut value = String::with_capacity(prefix.len() + 1 + random.len() * 2); + value.push_str(prefix); + value.push('_'); + for byte in random { + use std::fmt::Write as _; + write!(&mut value, "{byte:02x}").map_err(|_| AgentPortableRemoteError::Internal)?; + } + Ok(value) +} + +fn validate_opaque_identifier(value: &str, prefix: &str) -> Result<(), AgentPortableRemoteError> { + let expected_len = prefix.len() + 1 + 48; + if value.len() != expected_len + || !value.starts_with(prefix) + || value.as_bytes().get(prefix.len()) != Some(&b'_') + || !value[prefix.len() + 1..] + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + return Err(AgentPortableRemoteError::StaleLease); + } + Ok(()) +} + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct NativePortableCredentialClaims { + account_id: String, + project_id: String, + local_registration_id: String, + local_device_id: String, + local_installation_id: String, + controller_endpoint_id: String, + controller_endpoint_epoch: u64, + account_context_epoch: u64, + storage_key_digest: PairedTargetStorageKeyDigest, +} + +impl std::fmt::Debug for NativePortableCredentialClaims { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NativePortableCredentialClaims") + .field("controller_endpoint_epoch", &self.controller_endpoint_epoch) + .field("account_context_epoch", &self.account_context_epoch) + .field("storage_key_digest", &self.storage_key_digest) + .finish_non_exhaustive() + } +} + +impl NativePortableCredentialClaims { + fn validate(&self) -> Result<(), AgentPortableRemoteError> { + validate_uuid("credential account", &self.account_id) + .map_err(|_| AgentPortableRemoteError::Unauthenticated)?; + validate_uuid("credential project", &self.project_id) + .map_err(|_| AgentPortableRemoteError::Unauthenticated)?; + validate_uuid("credential registration", &self.local_registration_id) + .map_err(|_| AgentPortableRemoteError::Unauthenticated)?; + validate_uuid("credential device", &self.local_device_id) + .map_err(|_| AgentPortableRemoteError::Unauthenticated)?; + validate_uuid("credential installation", &self.local_installation_id) + .map_err(|_| AgentPortableRemoteError::Unauthenticated)?; + validate_endpoint_id(&self.controller_endpoint_id) + .map_err(|_| AgentPortableRemoteError::Unauthenticated)?; + validate_backend_counter(self.controller_endpoint_epoch) + .map_err(|_| AgentPortableRemoteError::Unauthenticated)?; + validate_backend_counter(self.account_context_epoch) + .map_err(|_| AgentPortableRemoteError::Unauthenticated)?; + self.storage_key_digest + .validate() + .map_err(|_| AgentPortableRemoteError::Unauthenticated) + } +} + +pub(crate) trait NativePortableCredentialLease: Send + Sync { + fn claims(&self) -> &NativePortableCredentialClaims; + + /// Re-checks the native session and installation-global account context. + /// Implementations must fail after sign-out or any A to B transition. The + /// native auth owner must also await `native_credentials_invalidated` + /// before publishing that transition so the cancellation passed into a + /// concurrent factory dial is fenced at its linearization point. + fn revalidate_current( + self: Arc, + ) -> PortableFuture<'static, Result<(), AgentPortableRemoteError>>; +} + +pub(crate) trait NativePortableCredentialProvider: Send + Sync { + fn current( + self: Arc, + ) -> PortableFuture< + 'static, + Result, AgentPortableRemoteError>, + >; +} + +pub(crate) trait NativePortablePairingVerifier: Send + Sync { + /// Verifies opaque SDK/backend evidence. Stored values remain untrusted and + /// cannot construct this sealed registry themselves. + fn verify_registry( + self: Arc, + credential: Arc, + stored: StoredPairedTargetRegistryV1, + ) -> PortableFuture< + 'static, + Result, + >; +} + +#[derive(Clone, PartialEq, Eq)] +struct VerifiedLineageTombstone { + host_registration_id: String, + host_endpoint_id: String, + pair_id: String, + retired_pairing_incarnation: u64, + retired_authorization_revision: u64, + replay_floor: Option, +} + +impl std::fmt::Debug for VerifiedLineageTombstone { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("VerifiedLineageTombstone()") + } +} + +/// Type-erased, native verifier-produced transport authority. +/// +/// Stored lineage bytes and digests never construct this value and are never +/// passed to a dialer. A future released verifier/peer-factory adapter may use +/// a private descendant module to wrap and downcast its transport-owned, +/// authenticated lineage codec handle. Keeping the constructor private makes +/// it impossible for sibling production code to promote stored scalars. +#[derive(Clone)] +pub(crate) struct VerifiedPortableTransportLineageAuthority { + inner: Arc, +} + +impl std::fmt::Debug for VerifiedPortableTransportLineageAuthority { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("VerifiedPortableTransportLineageAuthority()") + } +} + +impl VerifiedPortableTransportLineageAuthority { + fn new(authority: T) -> Self { + Self { + inner: Arc::new(authority), + } + } + + pub(crate) fn downcast_ref(&self) -> Option<&T> { + self.inner.downcast_ref::() + } +} + +#[cfg(test)] +#[derive(Debug)] +struct TestVerifiedPortableTransportLineageAuthority; + +#[derive(Clone)] +pub(crate) struct VerifiedAgentPortableTargetRegistry { + account_id: String, + project_id: String, + local_registration_id: String, + local_device_id: String, + local_installation_id: String, + controller_endpoint_id: String, + controller_endpoint_epoch: u64, + account_context_epoch: u64, + security_epoch: u64, + authorization_snapshot_revision: u64, + registration_evidence_digest: [u8; DIGEST_BYTES], + revocation_sync_evidence_digest: [u8; DIGEST_BYTES], + complete_snapshot: bool, + targets: Vec>, + lineage_tombstones: Vec, +} + +impl std::fmt::Debug for VerifiedAgentPortableTargetRegistry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VerifiedAgentPortableTargetRegistry") + .field("controller_endpoint_epoch", &self.controller_endpoint_epoch) + .field("account_context_epoch", &self.account_context_epoch) + .field("security_epoch", &self.security_epoch) + .field( + "authorization_snapshot_revision", + &self.authorization_snapshot_revision, + ) + .field("complete_snapshot", &self.complete_snapshot) + .field("target_count", &self.targets.len()) + .field("lineage_tombstone_count", &self.lineage_tombstones.len()) + .finish_non_exhaustive() + } +} + +#[derive(Clone)] +pub(crate) struct VerifiedAgentPortableTarget { + pair_id: String, + pairing_revision: u64, + directory_revision: u64, + host_registration_id: String, + host_device_id: String, + host_installation_id: String, + host_endpoint_id: String, + host_endpoint_epoch: u64, + host_display_name: String, + pairing_incarnation: u64, + authorization_evidence_digest: [u8; DIGEST_BYTES], + revocation_stream_id: String, + revocation_generation: u64, + revocation_applied_sequence: u64, + revocation_checkpoint_digest: [u8; DIGEST_BYTES], + connection_hints: StoredConnectionHintsV1, + lineage_format: String, + lineage_revision: u64, + lineage_digest: [u8; DIGEST_BYTES], + replay_floor: Option, + transport_lineage_authority: VerifiedPortableTransportLineageAuthority, + revoked: bool, +} + +impl std::fmt::Debug for VerifiedAgentPortableTarget { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VerifiedAgentPortableTarget") + .field("pairing_revision", &self.pairing_revision) + .field("directory_revision", &self.directory_revision) + .field("host_endpoint_epoch", &self.host_endpoint_epoch) + .field("pairing_incarnation", &self.pairing_incarnation) + .field("revocation_generation", &self.revocation_generation) + .field( + "revocation_applied_sequence", + &self.revocation_applied_sequence, + ) + .field("revoked", &self.revoked) + .finish_non_exhaustive() + } +} + +impl VerifiedAgentPortableTarget { + pub(crate) fn host_endpoint_id(&self) -> &str { + &self.host_endpoint_id + } + + pub(crate) fn host_endpoint_epoch(&self) -> u64 { + self.host_endpoint_epoch + } + + pub(crate) fn connection_hints(&self) -> &StoredConnectionHintsV1 { + &self.connection_hints + } + + pub(crate) fn replay_floor(&self) -> Option<&StoredConnectionLineageFloorV1> { + self.replay_floor.as_ref() + } + + /// Returns only the verifier-produced opaque native authority. Stored + /// lineage bytes are intentionally absent from the peer-factory boundary. + pub(crate) fn transport_lineage_authority(&self) -> &VerifiedPortableTransportLineageAuthority { + &self.transport_lineage_authority + } +} + +#[cfg(test)] +impl VerifiedAgentPortableTargetRegistry { + fn for_test(stored: &StoredPairedTargetRegistryV1) -> Self { + let targets = stored + .targets + .iter() + .map(|target| { + let (lineage_format, lineage_revision, lineage_digest, replay_floor) = + match &target.transport_lineage { + StoredTransportLineageV1::Quiescent { + format, + lineage_revision, + bytes: _, + digest, + replay_floor, + } => ( + format.clone(), + *lineage_revision, + *digest, + replay_floor.clone(), + ), + StoredTransportLineageV1::Uncertain { + format, + lineage_revision, + bytes: _, + digest, + replay_floor, + } => ( + format.clone(), + *lineage_revision, + *digest, + replay_floor.clone(), + ), + }; + Arc::new(VerifiedAgentPortableTarget { + pair_id: target.pair_id.clone(), + pairing_revision: target.pairing_revision, + directory_revision: target.directory_revision, + host_registration_id: target.host_registration_id.clone(), + host_device_id: target.host_device_id.clone(), + host_installation_id: target.host_installation_id.clone(), + host_endpoint_id: target.host_endpoint_id.clone(), + host_endpoint_epoch: target.host_endpoint_epoch, + host_display_name: target.host_display_name.clone(), + pairing_incarnation: target.pairing_incarnation, + authorization_evidence_digest: target.authorization.digest, + revocation_stream_id: target.revocation.stream_id.clone(), + revocation_generation: target.revocation.generation, + revocation_applied_sequence: target.revocation.applied_sequence, + revocation_checkpoint_digest: target.revocation.checkpoint_digest, + connection_hints: target.connection_hints.clone(), + lineage_format, + lineage_revision, + lineage_digest, + replay_floor, + transport_lineage_authority: VerifiedPortableTransportLineageAuthority::new( + TestVerifiedPortableTransportLineageAuthority, + ), + revoked: false, + }) + }) + .collect(); + let lineage_tombstones = stored + .lineage_tombstones + .iter() + .map(|tombstone| VerifiedLineageTombstone { + host_registration_id: tombstone.host_registration_id.clone(), + host_endpoint_id: tombstone.host_endpoint_id.clone(), + pair_id: tombstone.pair_id.clone(), + retired_pairing_incarnation: tombstone.retired_pairing_incarnation, + retired_authorization_revision: tombstone.retired_authorization_revision, + replay_floor: tombstone.replay_floor.clone(), + }) + .collect(); + Self { + account_id: stored.account_id.clone(), + project_id: stored.project_id.clone(), + local_registration_id: stored.local_registration_id.clone(), + local_device_id: stored.local_device_id.clone(), + local_installation_id: stored.local_installation_id.clone(), + controller_endpoint_id: stored.controller_endpoint_id.clone(), + controller_endpoint_epoch: stored.controller_endpoint_epoch, + account_context_epoch: stored.account_context_epoch, + security_epoch: stored.security_epoch, + authorization_snapshot_revision: stored.authorization_snapshot_revision, + registration_evidence_digest: stored.registration_evidence.digest, + revocation_sync_evidence_digest: stored.revocation_sync_evidence.digest, + complete_snapshot: true, + targets, + lineage_tombstones, + } + } +} + +fn validate_credential_against_registry( + credential: &NativePortableCredentialClaims, + envelope: &StoredRegistryEnvelopeV1, +) -> Result<(), AgentPortableRemoteError> { + credential.validate()?; + let registry = &envelope.registry; + if credential.storage_key_digest != envelope.storage_key_digest + || credential.account_id != registry.account_id + || credential.project_id != registry.project_id + || credential.local_registration_id != registry.local_registration_id + || credential.local_device_id != registry.local_device_id + || credential.local_installation_id != registry.local_installation_id + || credential.controller_endpoint_id != registry.controller_endpoint_id + || credential.controller_endpoint_epoch != registry.controller_endpoint_epoch + || credential.account_context_epoch != registry.account_context_epoch + { + return Err(AgentPortableRemoteError::AccountMismatch); + } + Ok(()) +} + +fn validate_verified_registry( + credential: &NativePortableCredentialClaims, + stored: &StoredPairedTargetRegistryV1, + verified: &VerifiedAgentPortableTargetRegistry, +) -> Result<(), AgentPortableRemoteError> { + if !verified.complete_snapshot + || verified.account_id != credential.account_id + || verified.project_id != credential.project_id + || verified.local_registration_id != credential.local_registration_id + || verified.local_device_id != credential.local_device_id + || verified.local_installation_id != credential.local_installation_id + || verified.controller_endpoint_id != credential.controller_endpoint_id + || verified.controller_endpoint_epoch != credential.controller_endpoint_epoch + || verified.account_context_epoch != credential.account_context_epoch + || verified.security_epoch != stored.security_epoch + || verified.authorization_snapshot_revision != stored.authorization_snapshot_revision + || verified.registration_evidence_digest != stored.registration_evidence.digest + || verified.revocation_sync_evidence_digest != stored.revocation_sync_evidence.digest + || verified.targets.len() != stored.targets.len() + || verified.lineage_tombstones.len() != stored.lineage_tombstones.len() + { + return Err(AgentPortableRemoteError::VerificationFailed); + } + for (stored_target, verified_target) in stored.targets.iter().zip(&verified.targets) { + validate_verified_target(stored_target, verified_target)?; + } + for (stored_tombstone, verified_tombstone) in stored + .lineage_tombstones + .iter() + .zip(&verified.lineage_tombstones) + { + if verified_tombstone.host_registration_id != stored_tombstone.host_registration_id + || verified_tombstone.host_endpoint_id != stored_tombstone.host_endpoint_id + || verified_tombstone.pair_id != stored_tombstone.pair_id + || verified_tombstone.retired_pairing_incarnation + != stored_tombstone.retired_pairing_incarnation + || verified_tombstone.retired_authorization_revision + != stored_tombstone.retired_authorization_revision + || verified_tombstone.replay_floor != stored_tombstone.replay_floor + { + return Err(AgentPortableRemoteError::VerificationFailed); + } + } + Ok(()) +} + +fn validate_verified_target( + stored: &StoredPairedTargetV1, + verified: &VerifiedAgentPortableTarget, +) -> Result<(), AgentPortableRemoteError> { + let ( + stored_lineage_format, + stored_lineage_revision, + stored_lineage_digest, + stored_replay_floor, + ) = match &stored.transport_lineage { + StoredTransportLineageV1::Quiescent { + format, + lineage_revision, + bytes: _, + digest, + replay_floor, + } => ( + format.as_str(), + *lineage_revision, + *digest, + replay_floor.as_ref(), + ), + StoredTransportLineageV1::Uncertain { + format, + lineage_revision, + bytes: _, + digest, + replay_floor, + } => ( + format.as_str(), + *lineage_revision, + *digest, + replay_floor.as_ref(), + ), + }; + if verified.revoked { + return Err(AgentPortableRemoteError::Revoked); + } + if verified.pair_id != stored.pair_id + || verified.pairing_revision != stored.pairing_revision + || verified.directory_revision != stored.directory_revision + || verified.host_registration_id != stored.host_registration_id + || verified.host_device_id != stored.host_device_id + || verified.host_installation_id != stored.host_installation_id + || verified.host_endpoint_id != stored.host_endpoint_id + || verified.host_endpoint_epoch != stored.host_endpoint_epoch + || verified.host_display_name != stored.host_display_name + || verified.pairing_incarnation != stored.pairing_incarnation + || verified.authorization_evidence_digest != stored.authorization.digest + || verified.revocation_stream_id != stored.revocation.stream_id + || verified.revocation_generation != stored.revocation.generation + || verified.revocation_applied_sequence != stored.revocation.applied_sequence + || verified.revocation_checkpoint_digest != stored.revocation.checkpoint_digest + || verified.connection_hints != stored.connection_hints + || verified.lineage_format != stored_lineage_format + || verified.lineage_revision != stored_lineage_revision + || verified.lineage_digest != stored_lineage_digest + || verified.replay_floor.as_ref() != stored_replay_floor + { + return Err(AgentPortableRemoteError::VerificationFailed); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PortablePeerLeaseSeed { + host_epoch: u64, + connection_generation: u64, +} + +impl PortablePeerLeaseSeed { + pub(crate) fn new( + host_epoch: u64, + connection_generation: u64, + ) -> Result { + let seed = Self { + host_epoch, + connection_generation, + }; + seed.validate()?; + Ok(seed) + } + + fn validate(self) -> Result<(), AgentPortableRemoteError> { + if self.host_epoch == 0 + || self.connection_generation == 0 + || self.connection_generation > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + { + return Err(AgentPortableRemoteError::PeerUnavailable); + } + Ok(()) + } +} + +pub(crate) trait AgentPortablePeer: Send + Sync { + fn lease_seed(&self) -> PortablePeerLeaseSeed; + + /// Synchronously prevents creation of new native peer operations. + fn fence(&self); + + /// Every operation below must promptly observe `cancellation`, release its + /// per-operation native resources, and complete with `Cancelled`. Cleanup + /// deliberately waits for those operation acknowledgements before calling + /// `dispose`, so an adapter must not defer cancellation until disposal. + fn runtime_status( + self: Arc, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result>; + + fn sessions_page( + self: Arc, + request: PortablePageRequest, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result>; + + fn records_page( + self: Arc, + request: PortableRecordsPageRequest, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result>; + + fn network_changed( + self: Arc, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result<(), AgentPortableRemoteError>>; + + /// Completes only after the peer acknowledges cancellation and native + /// transport resources are disposed. + fn dispose(self: Arc) -> PortableFuture<'static, Result<(), AgentPortableRemoteError>>; +} + +pub(crate) trait PortablePeerFactory: Send + Sync { + /// `target.connection_hints()` is untrusted routing input. Implementations + /// must intersect it with their independently configured relay and network + /// destination policy before any dial. The concrete native adapter must + /// also recognize the sealed transport-lineage authority before dialing; + /// a downcast/type mismatch fails closed. Before its first network action, + /// the factory must atomically bind and check `cancellation`; cancellation + /// then synchronously prohibits every new dial or peer operation at the + /// native linearization point. `Err` is terminal only after the factory has + /// acknowledged cleanup of every partially acquired native endpoint or + /// connection. Once `Ok(peer)` is returned, the controller owns all + /// fencing and asynchronous disposal. + fn connect( + self: Arc, + target: Arc, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result, AgentPortableRemoteError>>; +} + +#[derive(Debug)] +struct DisabledNativePortableComposition; + +impl NativePortableCredentialProvider for DisabledNativePortableComposition { + fn current( + self: Arc, + ) -> PortableFuture< + 'static, + Result, AgentPortableRemoteError>, + > { + Box::pin(async { Err(AgentPortableRemoteError::Unavailable) }) + } +} + +impl NativePortablePairingVerifier for DisabledNativePortableComposition { + fn verify_registry( + self: Arc, + _credential: Arc, + _stored: StoredPairedTargetRegistryV1, + ) -> PortableFuture< + 'static, + Result, + > { + Box::pin(async { Err(AgentPortableRemoteError::Unavailable) }) + } +} + +impl PortablePeerFactory for DisabledNativePortableComposition { + fn connect( + self: Arc, + _target: Arc, + _cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result, AgentPortableRemoteError>> { + Box::pin(async { Err(AgentPortableRemoteError::Unavailable) }) + } +} + +struct PortableCompletion { + value: Mutex>, + notify: Notify, +} + +impl PortableCompletion { + fn pending() -> Arc { + Arc::new(Self { + value: Mutex::new(None), + notify: Notify::new(), + }) + } + + fn completed(value: T) -> Arc { + Arc::new(Self { + value: Mutex::new(Some(value)), + notify: Notify::new(), + }) + } + + fn complete(&self, value: T) { + let mut slot = self.value.lock().unwrap(); + if slot.is_none() { + *slot = Some(value); + drop(slot); + self.notify.notify_waiters(); + } + } + + async fn wait(&self) -> T { + loop { + let notified = self.notify.notified(); + if let Some(value) = self.value.lock().unwrap().clone() { + return value; + } + notified.await; + } + } +} + +impl std::fmt::Debug for PortableCompletion { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PortableCompletion") + .field("completed", &self.value.lock().unwrap().is_some()) + .finish() + } +} + +type PortableTransition = ( + u64, + Arc>>, +); + +#[derive(Clone)] +pub(crate) struct AgentPortableRemoteController { + inner: Arc, +} + +impl std::fmt::Debug for AgentPortableRemoteController { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("AgentPortableRemoteController()") + } +} + +struct AgentPortableRemoteControllerInner { + store: Arc, + credentials: Arc, + verifier: Arc, + peer_factory: Arc, + next_request_id: AtomicU64, + runtime: Mutex>, + state: Mutex, +} + +struct AgentPortableRemoteControllerState { + fence_epoch: u64, + prepared: Option, + pending: Option, + active: Option, + requests: HashMap, + cleanup_barrier: Arc>>, +} + +struct PreparedPortableAccount { + credential: Arc, + storage_token: StoredRegistryCommitToken, + verified_registry: Arc, + targets: HashMap>, + descriptors: Vec, +} + +impl std::fmt::Debug for PreparedPortableAccount { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PreparedPortableAccount") + .field("verified_registry", &self.verified_registry) + .field("target_count", &self.targets.len()) + .finish_non_exhaustive() + } +} + +struct PendingPortableConnection { + id: u64, + fence_epoch: u64, + cancellation: PortableCancellation, + ready: Arc>>, + accepted: Arc>, + worker_done: Arc>>, + acquired: Option, +} + +struct AcquiredPortableConnection { + handle: PortableTargetHandle, + lease: PortableTargetLease, + peer: Arc, +} + +struct ActivePortableConnection { + fence_epoch: u64, + handle: PortableTargetHandle, + lease: PortableTargetLease, + peer: Arc, +} + +struct PortableRequestOwner { + cancellation: PortableCancellation, + completion: Arc>, +} + +struct PrepareWaiterGuard { + cancellation: PortableCancellation, + armed: bool, +} + +impl PrepareWaiterGuard { + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for PrepareWaiterGuard { + fn drop(&mut self) { + if self.armed { + self.cancellation.cancel(); + } + } +} + +struct PortableRetirement { + previous_barrier: Arc>>, + pending: Option, + active: Option, + requests: Vec, + completion: Arc>>, +} + +struct PortableRequestContext { + request_id: u64, + fence_epoch: u64, + lease: PortableTargetLease, + peer: Arc, + credential: Arc, + storage_key_digest: PairedTargetStorageKeyDigest, + storage_token: StoredRegistryCommitToken, + cancellation: PortableCancellation, + owner_completion: Arc>, +} + +impl AgentPortableRemoteControllerState { + fn new() -> Self { + Self { + fence_epoch: 0, + prepared: None, + pending: None, + active: None, + requests: HashMap::new(), + cleanup_barrier: PortableCompletion::completed(Ok(())), + } + } +} + +impl AgentPortableRemoteController { + /// The only production constructor in this slice. No platform storage, + /// credential, verifier, peer, or renderer command is silently installed. + pub(crate) fn disabled() -> Self { + let disabled = Arc::new(DisabledNativePortableComposition); + Self { + inner: Arc::new(AgentPortableRemoteControllerInner { + store: Arc::new(InMemoryPairedTargetAuthorityStore::default()), + credentials: disabled.clone(), + verifier: disabled.clone(), + peer_factory: disabled, + next_request_id: AtomicU64::new(1), + runtime: Mutex::new(None), + state: Mutex::new(AgentPortableRemoteControllerState::new()), + }), + } + } + + #[cfg(test)] + fn with_dependencies( + store: Arc, + credentials: Arc, + verifier: Arc, + peer_factory: Arc, + ) -> Self { + Self { + inner: Arc::new(AgentPortableRemoteControllerInner { + store, + credentials, + verifier, + peer_factory, + next_request_id: AtomicU64::new(1), + runtime: Mutex::new(None), + state: Mutex::new(AgentPortableRemoteControllerState::new()), + }), + } + } + + /// Replaces the native account view. A newer refresh, sign-out, or dispose + /// fences this acquisition; stale results never publish renderer handles. + pub(crate) async fn refresh_targets_for_account( + &self, + expected_account_id: &str, + ) -> Result, AgentPortableRemoteError> { + validate_uuid("expected account", expected_account_id) + .map_err(|_| AgentPortableRemoteError::InvalidRequest)?; + self.refresh_targets_inner(Some(expected_account_id)).await + } + + #[cfg(test)] + async fn refresh_targets( + &self, + ) -> Result, AgentPortableRemoteError> { + self.refresh_targets_inner(None).await + } + + async fn refresh_targets_inner( + &self, + expected_account_id: Option<&str>, + ) -> Result, AgentPortableRemoteError> { + let (fence_epoch, barrier) = self.begin_transition(true)?; + barrier.wait().await?; + self.require_current_epoch(fence_epoch)?; + + let credential = self.inner.credentials.clone().current().await?; + let credential_claims = credential.claims().clone(); + credential_claims.validate()?; + if expected_account_id.is_some_and(|expected| credential_claims.account_id != expected) { + return Err(AgentPortableRemoteError::AccountMismatch); + } + self.require_current_epoch(fence_epoch)?; + let (stored, stored_token) = match self + .inner + .store + .load(credential_claims.storage_key_digest)? + { + StoredRegistryLoad::Empty { .. } => { + return Err(AgentPortableRemoteError::Unavailable); + } + StoredRegistryLoad::Committed { envelope, token } => (*envelope, token), + }; + validate_credential_against_registry(&credential_claims, &stored)?; + credential.clone().revalidate_current().await?; + self.require_current_epoch(fence_epoch)?; + let verified = self + .inner + .verifier + .clone() + .verify_registry(credential.clone(), stored.registry.clone()) + .await?; + credential.clone().revalidate_current().await?; + self.require_current_epoch(fence_epoch)?; + validate_credential_against_registry(&credential_claims, &stored)?; + validate_verified_registry(&credential_claims, &stored.registry, &verified)?; + require_store_current( + self.inner.store.as_ref(), + credential_claims.storage_key_digest, + stored_token, + )?; + + let mut targets = HashMap::with_capacity(verified.targets.len()); + let mut descriptors = Vec::with_capacity(verified.targets.len()); + for (stored_target, verified_target) in + stored.registry.targets.iter().zip(&verified.targets) + { + if !stored_target.transport_lineage.is_quiescent() { + continue; + } + let handle = PortableTargetHandle::issue()?; + let descriptor = PortableTargetDescriptor { + handle: handle.clone(), + label: verified_target.host_display_name.clone(), + }; + descriptor.validate()?; + targets.insert(handle, verified_target.clone()); + descriptors.push(descriptor); + } + let verified_registry = Arc::new(verified); + let mut state = self.inner.state.lock().unwrap(); + if state.fence_epoch != fence_epoch || state.pending.is_some() || state.active.is_some() { + return Err(AgentPortableRemoteError::Cancelled); + } + state.prepared = Some(PreparedPortableAccount { + credential, + storage_token: stored_token, + verified_registry, + targets, + descriptors: descriptors.clone(), + }); + Ok(descriptors) + } + + /// Prepares exactly one allowlisted target. The detached worker owns any + /// acquired peer until it publishes it or completes acknowledged disposal. + pub(crate) async fn prepare_target( + &self, + handle: &PortableTargetHandle, + ) -> Result { + handle.validate()?; + { + let state = self.inner.state.lock().unwrap(); + let prepared = state + .prepared + .as_ref() + .ok_or(AgentPortableRemoteError::Unauthenticated)?; + if !prepared.targets.contains_key(handle) { + return Err(AgentPortableRemoteError::UnknownTarget); + } + } + let (fence_epoch, barrier) = self.begin_transition(false)?; + barrier.wait().await?; + + let (target, credential, storage_token) = { + let state = self.inner.state.lock().unwrap(); + if state.fence_epoch != fence_epoch { + return Err(AgentPortableRemoteError::Cancelled); + } + let prepared = state + .prepared + .as_ref() + .ok_or(AgentPortableRemoteError::Unauthenticated)?; + let target = prepared + .targets + .get(handle) + .cloned() + .ok_or(AgentPortableRemoteError::UnknownTarget)?; + (target, prepared.credential.clone(), prepared.storage_token) + }; + credential.clone().revalidate_current().await?; + self.require_current_epoch(fence_epoch)?; + require_store_current( + self.inner.store.as_ref(), + credential.claims().storage_key_digest, + storage_token, + )?; + + let id = self.issue_request_id()?; + let runtime = self.record_runtime()?; + let cancellation = PortableCancellation::new(); + let ready = PortableCompletion::pending(); + let accepted = PortableCompletion::pending(); + let worker_done = PortableCompletion::pending(); + { + let mut state = self.inner.state.lock().unwrap(); + if state.fence_epoch != fence_epoch || state.prepared.is_none() { + return Err(AgentPortableRemoteError::Cancelled); + } + state.pending = Some(PendingPortableConnection { + id, + fence_epoch, + cancellation: cancellation.clone(), + ready: ready.clone(), + accepted: accepted.clone(), + worker_done: worker_done.clone(), + acquired: None, + }); + } + let mut waiter_guard = PrepareWaiterGuard { + cancellation: cancellation.clone(), + armed: true, + }; + let inner = Arc::downgrade(&self.inner); + let peer_factory = self.inner.peer_factory.clone(); + let store = self.inner.store.clone(); + let selected_handle = handle.clone(); + let worker_ready = ready.clone(); + let worker_accepted = accepted.clone(); + let worker_done_owner = worker_done.clone(); + let worker_cancellation = cancellation.clone(); + let _worker = runtime.spawn(async move { + run_portable_connect(PortableConnectTask { + inner, + peer_factory, + store, + id, + fence_epoch, + handle: selected_handle, + target, + credential, + storage_token, + cancellation: worker_cancellation, + ready: worker_ready, + accepted: worker_accepted, + worker_done: worker_done_owner, + }) + .await; + }); + let lease = match ready.wait().await { + Ok(lease) => lease, + Err(error) => { + let cleanup = worker_done.wait().await; + waiter_guard.disarm(); + return cleanup.and(Err(error)); + } + }; + let accepted_peer = { + let mut state = self.inner.state.lock().unwrap(); + let pending_matches = state + .pending + .as_ref() + .is_some_and(|pending| pending.id == id && pending.fence_epoch == fence_epoch); + if state.fence_epoch != fence_epoch || !pending_matches || state.active.is_some() { + None + } else { + let mut pending = state.pending.take().expect("matched pending connection"); + pending.acquired.take().map(|acquired| { + debug_assert_eq!(acquired.lease, lease); + state.active = Some(ActivePortableConnection { + fence_epoch, + handle: acquired.handle, + lease: acquired.lease.clone(), + peer: acquired.peer, + }); + acquired.lease + }) + } + }; + if let Some(lease) = accepted_peer { + accepted.complete(true); + waiter_guard.disarm(); + Ok(lease) + } else { + cancellation.cancel(); + let cleanup = worker_done.wait().await; + waiter_guard.disarm(); + cleanup.and(Err(AgentPortableRemoteError::Cancelled)) + } + } + + pub(crate) async fn runtime_status( + &self, + lease: &PortableTargetLease, + ) -> Result { + let runtime = self.record_runtime()?; + let context = self.begin_request(lease)?; + let mut waiter_guard = PrepareWaiterGuard { + cancellation: context.cancellation.clone(), + armed: true, + }; + let result = PortableCompletion::pending(); + let inner = Arc::downgrade(&self.inner); + let result_owner = result.clone(); + let _worker = runtime.spawn(async move { + let operation = async { + revalidate_request_authority(&inner, &context).await?; + if context.cancellation.is_cancelled() { + return Err(AgentPortableRemoteError::Cancelled); + } + let response = context + .peer + .clone() + .runtime_status(context.cancellation.clone()) + .await?; + response.validate()?; + revalidate_request_authority(&inner, &context).await?; + Ok(response) + } + .await; + retire_failed_request(&inner, &context, operation.as_ref().err()); + finish_portable_request(&inner, &context); + result_owner.complete(operation); + }); + let outcome = result.wait().await; + waiter_guard.disarm(); + outcome + } + + pub(crate) async fn sessions_page( + &self, + lease: &PortableTargetLease, + request: PortablePageRequest, + ) -> Result { + request.validate()?; + let runtime = self.record_runtime()?; + let context = self.begin_request(lease)?; + let mut waiter_guard = PrepareWaiterGuard { + cancellation: context.cancellation.clone(), + armed: true, + }; + let result = PortableCompletion::pending(); + let inner = Arc::downgrade(&self.inner); + let result_owner = result.clone(); + let _worker = runtime.spawn(async move { + let operation = async { + revalidate_request_authority(&inner, &context).await?; + if context.cancellation.is_cancelled() { + return Err(AgentPortableRemoteError::Cancelled); + } + let response = context + .peer + .clone() + .sessions_page(request.clone(), context.cancellation.clone()) + .await?; + response.validate_for(&request)?; + revalidate_request_authority(&inner, &context).await?; + Ok(response) + } + .await; + retire_failed_request(&inner, &context, operation.as_ref().err()); + finish_portable_request(&inner, &context); + result_owner.complete(operation); + }); + let outcome = result.wait().await; + waiter_guard.disarm(); + outcome + } + + pub(crate) async fn records_page( + &self, + lease: &PortableTargetLease, + request: PortableRecordsPageRequest, + ) -> Result { + request.validate()?; + let runtime = self.record_runtime()?; + let context = self.begin_request(lease)?; + let mut waiter_guard = PrepareWaiterGuard { + cancellation: context.cancellation.clone(), + armed: true, + }; + let result = PortableCompletion::pending(); + let inner = Arc::downgrade(&self.inner); + let result_owner = result.clone(); + let _worker = runtime.spawn(async move { + let operation = async { + revalidate_request_authority(&inner, &context).await?; + if context.cancellation.is_cancelled() { + return Err(AgentPortableRemoteError::Cancelled); + } + let response = context + .peer + .clone() + .records_page(request.clone(), context.cancellation.clone()) + .await?; + response.validate_for(&request)?; + revalidate_request_authority(&inner, &context).await?; + Ok(response) + } + .await; + retire_failed_request(&inner, &context, operation.as_ref().err()); + finish_portable_request(&inner, &context); + result_owner.complete(operation); + }); + let outcome = result.wait().await; + waiter_guard.disarm(); + outcome + } + + pub(crate) async fn network_changed( + &self, + lease: &PortableTargetLease, + ) -> Result<(), AgentPortableRemoteError> { + let runtime = self.record_runtime()?; + let context = self.begin_request(lease)?; + let mut waiter_guard = PrepareWaiterGuard { + cancellation: context.cancellation.clone(), + armed: true, + }; + let result = PortableCompletion::pending(); + let inner = Arc::downgrade(&self.inner); + let result_owner = result.clone(); + let _worker = runtime.spawn(async move { + let operation = async { + revalidate_request_authority(&inner, &context).await?; + if context.cancellation.is_cancelled() { + return Err(AgentPortableRemoteError::Cancelled); + } + context + .peer + .clone() + .network_changed(context.cancellation.clone()) + .await?; + revalidate_request_authority(&inner, &context).await + } + .await; + retire_failed_request(&inner, &context, operation.as_ref().err()); + finish_portable_request(&inner, &context); + result_owner.complete(operation); + }); + let outcome = result.wait().await; + waiter_guard.disarm(); + outcome + } + + /// Native-only account-context fence. The native authentication owner must + /// call this and await its cleanup acknowledgement before publishing + /// sign-out, credential revocation, or an A-to-B account transition. It is + /// intentionally not registered as a renderer command. + pub(crate) async fn native_credentials_invalidated( + &self, + ) -> Result<(), AgentPortableRemoteError> { + let (_, barrier) = self.begin_transition(true)?; + barrier.wait().await + } + + /// Fences all handles and requests immediately, then waits for the detached + /// native cleanup owner to receive peer disposal acknowledgements. + pub(crate) async fn dispose(&self) -> Result<(), AgentPortableRemoteError> { + let (_, barrier) = self.begin_transition(true)?; + barrier.wait().await + } + + fn begin_transition( + &self, + clear_account: bool, + ) -> Result { + let runtime = self.record_runtime()?; + let (fence_epoch, retirement) = { + let mut state = self.inner.state.lock().unwrap(); + state.fence_epoch = state + .fence_epoch + .checked_add(1) + .ok_or(AgentPortableRemoteError::Internal)?; + let fence_epoch = state.fence_epoch; + let completion = PortableCompletion::pending(); + let previous_barrier = + std::mem::replace(&mut state.cleanup_barrier, completion.clone()); + let pending = state.pending.take(); + let active = state.active.take(); + let requests = state.requests.drain().map(|(_, owner)| owner).collect(); + if clear_account { + state.prepared = None; + } + ( + fence_epoch, + PortableRetirement { + previous_barrier, + pending, + active, + requests, + completion, + }, + ) + }; + let barrier = retirement.completion.clone(); + start_portable_retirement(retirement, &runtime); + Ok((fence_epoch, barrier)) + } + + fn require_current_epoch(&self, fence_epoch: u64) -> Result<(), AgentPortableRemoteError> { + if self.inner.state.lock().unwrap().fence_epoch == fence_epoch { + Ok(()) + } else { + Err(AgentPortableRemoteError::Cancelled) + } + } + + fn issue_request_id(&self) -> Result { + let id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + if id == 0 || id == u64::MAX { + Err(AgentPortableRemoteError::Internal) + } else { + Ok(id) + } + } + + fn record_runtime(&self) -> Result { + let runtime = tokio::runtime::Handle::try_current() + .map_err(|_| AgentPortableRemoteError::Internal)?; + *self.inner.runtime.lock().unwrap() = Some(runtime.clone()); + Ok(runtime) + } + + fn begin_request( + &self, + lease: &PortableTargetLease, + ) -> Result { + lease.validate()?; + let request_id = self.issue_request_id()?; + let cancellation = PortableCancellation::new(); + let owner_completion = PortableCompletion::pending(); + let mut state = self.inner.state.lock().unwrap(); + let active = state + .active + .as_ref() + .ok_or(AgentPortableRemoteError::PeerUnavailable)?; + if &active.lease != lease || active.fence_epoch != state.fence_epoch { + return Err(AgentPortableRemoteError::StaleLease); + } + let prepared = state + .prepared + .as_ref() + .ok_or(AgentPortableRemoteError::Unauthenticated)?; + let context = PortableRequestContext { + request_id, + fence_epoch: state.fence_epoch, + lease: lease.clone(), + peer: active.peer.clone(), + credential: prepared.credential.clone(), + storage_key_digest: prepared.credential.claims().storage_key_digest, + storage_token: prepared.storage_token, + cancellation: cancellation.clone(), + owner_completion: owner_completion.clone(), + }; + state.requests.insert( + request_id, + PortableRequestOwner { + cancellation, + completion: owner_completion, + }, + ); + Ok(context) + } +} + +fn start_portable_retirement(retirement: PortableRetirement, runtime: &tokio::runtime::Handle) { + if let Some(active) = &retirement.active { + active.peer.fence(); + } + if let Some(pending) = &retirement.pending { + pending.cancellation.cancel(); + if let Some(acquired) = &pending.acquired { + acquired.peer.fence(); + } + } + for request in &retirement.requests { + request.cancellation.cancel(); + } + let _worker = runtime.spawn(async move { + let mut cleanup_failed = retirement.previous_barrier.wait().await.is_err(); + if let Some(pending) = retirement.pending { + if matches!( + pending.worker_done.wait().await, + Err(AgentPortableRemoteError::CleanupFailed) + ) { + cleanup_failed = true; + } + if let Some(acquired) = pending.acquired { + if acquired.peer.dispose().await.is_err() { + cleanup_failed = true; + } + } + } + for request in retirement.requests { + request.completion.wait().await; + } + if let Some(active) = retirement.active { + if active.peer.dispose().await.is_err() { + cleanup_failed = true; + } + } + retirement.completion.complete(if cleanup_failed { + Err(AgentPortableRemoteError::CleanupFailed) + } else { + Ok(()) + }); + }); +} + +impl Drop for AgentPortableRemoteControllerInner { + fn drop(&mut self) { + let state = match self.state.get_mut() { + Ok(state) => state, + Err(poisoned) => poisoned.into_inner(), + }; + let completion = PortableCompletion::pending(); + let retirement = PortableRetirement { + previous_barrier: state.cleanup_barrier.clone(), + pending: state.pending.take(), + active: state.active.take(), + requests: state.requests.drain().map(|(_, owner)| owner).collect(), + completion, + }; + state.prepared = None; + let runtime = match self.runtime.get_mut() { + Ok(runtime) => runtime.take(), + Err(poisoned) => poisoned.into_inner().take(), + }; + if let Some(runtime) = runtime { + start_portable_retirement(retirement, &runtime); + } else { + if let Some(active) = retirement.active { + active.peer.fence(); + } + if let Some(pending) = retirement.pending { + pending.cancellation.cancel(); + } + for request in retirement.requests { + request.cancellation.cancel(); + } + } + } +} + +struct PortableConnectTask { + inner: std::sync::Weak, + peer_factory: Arc, + store: Arc, + id: u64, + fence_epoch: u64, + handle: PortableTargetHandle, + target: Arc, + credential: Arc, + storage_token: StoredRegistryCommitToken, + cancellation: PortableCancellation, + ready: Arc>>, + accepted: Arc>, + worker_done: Arc>>, +} + +async fn run_portable_connect(task: PortableConnectTask) { + let PortableConnectTask { + inner, + peer_factory, + store, + id, + fence_epoch, + handle, + target, + credential, + storage_token, + cancellation, + ready, + accepted, + worker_done, + } = task; + if let Err(error) = credential.clone().revalidate_current().await { + remove_matching_pending(&inner, id, fence_epoch); + ready.complete(Err(error)); + worker_done.complete(Ok(())); + return; + } + if let Err(error) = require_store_current( + store.as_ref(), + credential.claims().storage_key_digest, + storage_token, + ) { + remove_matching_pending(&inner, id, fence_epoch); + ready.complete(Err(error)); + worker_done.complete(Ok(())); + return; + } + { + let Some(owner) = inner.upgrade() else { + ready.complete(Err(AgentPortableRemoteError::Cancelled)); + worker_done.complete(Ok(())); + return; + }; + let state = owner.state.lock().unwrap(); + let current = state.fence_epoch == fence_epoch + && state + .pending + .as_ref() + .is_some_and(|pending| pending.id == id && pending.fence_epoch == fence_epoch) + && !cancellation.is_cancelled(); + if !current { + drop(state); + remove_matching_pending(&inner, id, fence_epoch); + ready.complete(Err(AgentPortableRemoteError::Cancelled)); + worker_done.complete(Ok(())); + return; + } + } + let result = peer_factory.connect(target, cancellation.clone()).await; + match result { + Err(error) => { + remove_matching_pending(&inner, id, fence_epoch); + ready.complete(Err(if cancellation.is_cancelled() { + AgentPortableRemoteError::Cancelled + } else { + error + })); + worker_done.complete(Ok(())); + } + Ok(peer) => { + let lease = match PortableTargetLease::issue(peer.lease_seed()) { + Ok(lease) => lease, + Err(error) => { + finish_unaccepted_peer(peer, error, ready, worker_done).await; + remove_matching_pending(&inner, id, fence_epoch); + return; + } + }; + let authority_current = credential + .clone() + .revalidate_current() + .await + .and_then(|()| { + require_store_current( + store.as_ref(), + credential.claims().storage_key_digest, + storage_token, + ) + }); + if let Err(error) = authority_current { + finish_unaccepted_peer(peer, error, ready, worker_done).await; + remove_matching_pending(&inner, id, fence_epoch); + return; + } + let staged = { + let Some(owner) = inner.upgrade() else { + finish_unaccepted_peer( + peer, + AgentPortableRemoteError::Cancelled, + ready, + worker_done, + ) + .await; + return; + }; + let mut state = owner.state.lock().unwrap(); + let pending_matches = state + .pending + .as_ref() + .is_some_and(|pending| pending.id == id && pending.fence_epoch == fence_epoch); + if state.fence_epoch == fence_epoch + && pending_matches + && !cancellation.is_cancelled() + && state.active.is_none() + && state.prepared.is_some() + { + state.pending.as_mut().expect("matched pending").acquired = + Some(AcquiredPortableConnection { + handle, + lease: lease.clone(), + peer: peer.clone(), + }); + true + } else { + false + } + }; + if !staged { + finish_unaccepted_peer( + peer, + AgentPortableRemoteError::Cancelled, + ready, + worker_done, + ) + .await; + remove_matching_pending(&inner, id, fence_epoch); + return; + } + ready.complete(Ok(lease)); + tokio::select! { + accepted = accepted.wait() => { + if accepted { + worker_done.complete(Ok(())); + return; + } + cancellation.cancel(); + } + () = cancellation.cancelled() => {} + } + let acquired = { + inner.upgrade().and_then(|owner| { + let mut state = owner.state.lock().unwrap(); + state + .pending + .as_mut() + .filter(|pending| pending.id == id && pending.fence_epoch == fence_epoch) + .and_then(|pending| pending.acquired.take()) + }) + }; + if let Some(acquired) = acquired { + acquired.peer.fence(); + worker_done.complete(if acquired.peer.dispose().await.is_err() { + Err(AgentPortableRemoteError::CleanupFailed) + } else { + Ok(()) + }); + } else { + worker_done.complete(Ok(())); + } + remove_matching_pending(&inner, id, fence_epoch); + } + } +} + +async fn finish_unaccepted_peer( + peer: Arc, + error: AgentPortableRemoteError, + ready: Arc>>, + worker_done: Arc>>, +) { + peer.fence(); + let cleanup = peer.dispose().await; + ready.complete(Err(if cleanup.is_err() { + AgentPortableRemoteError::CleanupFailed + } else { + error + })); + worker_done.complete(if cleanup.is_err() { + Err(AgentPortableRemoteError::CleanupFailed) + } else { + Ok(()) + }); +} + +fn remove_matching_pending( + inner: &std::sync::Weak, + id: u64, + fence_epoch: u64, +) { + let Some(inner) = inner.upgrade() else { + return; + }; + let mut state = inner.state.lock().unwrap(); + if state + .pending + .as_ref() + .is_some_and(|pending| pending.id == id && pending.fence_epoch == fence_epoch) + { + state.pending = None; + } +} + +fn require_request_current( + inner: &std::sync::Weak, + context: &PortableRequestContext, +) -> Result<(), AgentPortableRemoteError> { + if context.cancellation.is_cancelled() { + return Err(AgentPortableRemoteError::Cancelled); + } + let inner = inner.upgrade().ok_or(AgentPortableRemoteError::Cancelled)?; + require_store_current( + inner.store.as_ref(), + context.storage_key_digest, + context.storage_token, + )?; + let state = inner.state.lock().unwrap(); + let active = state + .active + .as_ref() + .ok_or(AgentPortableRemoteError::StaleLease)?; + if state.fence_epoch != context.fence_epoch + || active.fence_epoch != context.fence_epoch + || active.lease != context.lease + || !state.requests.contains_key(&context.request_id) + { + return Err(AgentPortableRemoteError::StaleLease); + } + Ok(()) +} + +async fn revalidate_request_authority( + inner: &std::sync::Weak, + context: &PortableRequestContext, +) -> Result<(), AgentPortableRemoteError> { + context.credential.clone().revalidate_current().await?; + require_request_current(inner, context) +} + +fn retire_failed_request( + inner: &std::sync::Weak, + context: &PortableRequestContext, + error: Option<&AgentPortableRemoteError>, +) { + let Some(error) = error else { + return; + }; + if !matches!( + error, + AgentPortableRemoteError::Unavailable + | AgentPortableRemoteError::CorruptStoredRegistry + | AgentPortableRemoteError::UnsupportedStoredVersion + | AgentPortableRemoteError::InvalidStoredRegistry + | AgentPortableRemoteError::StoredRegistryRollback + | AgentPortableRemoteError::StoredRegistryInterrupted + | AgentPortableRemoteError::StoredRegistryEquivocation + | AgentPortableRemoteError::StoredRegistryConflict + | AgentPortableRemoteError::DuplicateStoredTarget + | AgentPortableRemoteError::Unauthenticated + | AgentPortableRemoteError::AccountMismatch + | AgentPortableRemoteError::Revoked + | AgentPortableRemoteError::VerificationFailed + | AgentPortableRemoteError::StaleLease + | AgentPortableRemoteError::InvalidResponse + | AgentPortableRemoteError::PeerUnavailable + ) { + return; + } + let Some(inner) = inner.upgrade() else { + return; + }; + let runtime = inner.runtime.lock().unwrap().clone(); + let Some(runtime) = runtime else { + context.peer.fence(); + context.cancellation.cancel(); + return; + }; + let retirement = { + let mut state = inner.state.lock().unwrap(); + let active_matches = state.active.as_ref().is_some_and(|active| { + active.fence_epoch == context.fence_epoch && active.lease == context.lease + }); + if !active_matches { + return; + } + let Some(next_epoch) = state.fence_epoch.checked_add(1) else { + context.peer.fence(); + context.cancellation.cancel(); + return; + }; + state.fence_epoch = next_epoch; + let completion = PortableCompletion::pending(); + let previous_barrier = std::mem::replace(&mut state.cleanup_barrier, completion.clone()); + let retirement = PortableRetirement { + previous_barrier, + pending: state.pending.take(), + active: state.active.take(), + requests: state.requests.drain().map(|(_, owner)| owner).collect(), + completion, + }; + state.prepared = None; + retirement + }; + start_portable_retirement(retirement, &runtime); +} + +fn require_store_current( + store: &dyn PairedTargetAuthorityStore, + storage_key_digest: PairedTargetStorageKeyDigest, + expected: StoredRegistryCommitToken, +) -> Result<(), AgentPortableRemoteError> { + match store.load(storage_key_digest)? { + StoredRegistryLoad::Committed { token, .. } if token == expected => Ok(()), + StoredRegistryLoad::Empty { .. } | StoredRegistryLoad::Committed { .. } => { + Err(AgentPortableRemoteError::StaleLease) + } + } +} + +fn finish_portable_request( + inner: &std::sync::Weak, + context: &PortableRequestContext, +) { + if let Some(inner) = inner.upgrade() { + inner + .state + .lock() + .unwrap() + .requests + .remove(&context.request_id); + } + context.owner_completion.complete(()); +} + +fn encode_registry_body( + registry: &StoredPairedTargetRegistryV1, +) -> Result, AgentPortableRemoteError> { + let body = serde_json::to_vec(registry) + .map_err(|_| AgentPortableRemoteError::InvalidStoredRegistry)?; + if body.is_empty() || body.len() > MAX_STORED_BODY_BYTES { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + Ok(body) +} + +fn decode_json_exact Deserialize<'de>>( + bytes: &[u8], +) -> Result { + serde_json::from_slice(bytes).map_err(|_| AgentPortableRemoteError::CorruptStoredRegistry) +} + +fn state_checksum( + storage_key_digest: PairedTargetStorageKeyDigest, + storage_revision: u64, + body_len: usize, + body: &[u8], + body_digest: [u8; DIGEST_BYTES], +) -> [u8; DIGEST_BYTES] { + let version = STORED_SCHEMA_VERSION.to_be_bytes(); + let revision = storage_revision.to_be_bytes(); + let body_len = u32::try_from(body_len).unwrap_or(u32::MAX).to_be_bytes(); + digest_parts( + STORED_STATE_CHECKSUM_DOMAIN, + &[ + STORED_STATE_MAGIC, + &version, + &[STORED_STATE_KIND], + &storage_key_digest.0, + &revision, + &body_len, + body, + &body_digest, + ], + ) +} + +fn guard_checksum( + storage_key_digest: PairedTargetStorageKeyDigest, + committed_revision: u64, + committed_state_digest: [u8; DIGEST_BYTES], +) -> [u8; DIGEST_BYTES] { + let version = STORED_SCHEMA_VERSION.to_be_bytes(); + let revision = committed_revision.to_be_bytes(); + digest_parts( + STORED_GUARD_CHECKSUM_DOMAIN, + &[ + STORED_GUARD_MAGIC, + &version, + &[STORED_GUARD_KIND], + &storage_key_digest.0, + &revision, + &committed_state_digest, + ], + ) +} + +fn digest_parts(domain: &[u8], parts: &[&[u8]]) -> [u8; DIGEST_BYTES] { + let mut hasher = Sha256::new(); + hasher.update(domain); + for part in parts { + hasher.update(part); + } + hasher.finalize().into() +} + +fn validate_uuid(_field: &str, value: &str) -> Result<(), AgentPortableRemoteError> { + if value.len() != 36 + || value == "00000000-0000-0000-0000-000000000000" + || value.bytes().enumerate().any(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte != b'-' + } else { + !matches!(byte, b'0'..=b'9' | b'a'..=b'f') + } + }) + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + Ok(()) +} + +fn validate_endpoint_id(value: &str) -> Result<(), AgentPortableRemoteError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + || value.bytes().all(|byte| byte == b'0') + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + Ok(()) +} + +fn validate_backend_counter(value: u64) -> Result<(), AgentPortableRemoteError> { + if value == 0 || value > MAX_BACKEND_COUNTER { + Err(AgentPortableRemoteError::InvalidStoredRegistry) + } else { + Ok(()) + } +} + +fn validate_display_label(value: &str) -> Result<(), AgentPortableRemoteError> { + let trimmed = value.trim(); + if trimmed != value + || value.is_empty() + || value.len() > MAX_TARGET_LABEL_BYTES + || value.chars().count() > MAX_TARGET_LABEL_CHARS + || value.chars().any(is_unsafe_display_character) + { + return Err(AgentPortableRemoteError::InvalidStoredRegistry); + } + Ok(()) +} + +fn is_unsafe_display_character(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) +} + +fn is_strictly_sorted_unique(values: &[T]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn read_u16(bytes: &[u8], offset: usize) -> Result { + Ok(u16::from_be_bytes(read_array(bytes, offset)?)) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + Ok(u32::from_be_bytes(read_array(bytes, offset)?)) +} + +fn read_u64(bytes: &[u8], offset: usize) -> Result { + Ok(u64::from_be_bytes(read_array(bytes, offset)?)) +} + +fn read_array( + bytes: &[u8], + offset: usize, +) -> Result<[u8; N], AgentPortableRemoteError> { + let end = offset + .checked_add(N) + .ok_or(AgentPortableRemoteError::CorruptStoredRegistry)?; + bytes + .get(offset..end) + .ok_or(AgentPortableRemoteError::CorruptStoredRegistry)? + .try_into() + .map_err(|_| AgentPortableRemoteError::CorruptStoredRegistry) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uuid(seed: u64) -> String { + format!("10000000-0000-4000-8000-{seed:012x}") + } + + fn endpoint(byte: char) -> String { + std::iter::repeat_n(byte, 64).collect() + } + + fn evidence(format: &str, seed: u8) -> StoredOpaqueEvidenceV1 { + StoredOpaqueEvidenceV1 { + format: format.to_string(), + bytes: vec![seed, seed.wrapping_add(1)], + digest: [seed; DIGEST_BYTES], + } + } + + fn stored_target(seed: u64) -> StoredPairedTargetV1 { + let digest_seed = u8::try_from(seed).unwrap_or(1).max(1); + StoredPairedTargetV1 { + pair_id: uuid(100 + seed), + pairing_revision: 5, + directory_revision: 7, + host_registration_id: uuid(200 + seed), + host_device_id: uuid(300 + seed), + host_installation_id: uuid(400 + seed), + host_endpoint_id: endpoint(if seed.is_multiple_of(2) { 'c' } else { 'b' }), + host_endpoint_epoch: 3, + host_display_name: format!("Maple host {seed}"), + pairing_incarnation: 2, + authorization: evidence(PAIR_AUTHORIZATION_EVIDENCE_FORMAT, digest_seed), + revocation: StoredRevocationNamespaceV1 { + stream_id: uuid(500 + seed), + generation: 1, + applied_sequence: 0, + checkpoint_digest: [digest_seed.wrapping_add(1); DIGEST_BYTES], + }, + connection_hints: StoredConnectionHintsV1 { + relay_urls: vec!["https://relay.example.com/".to_string()], + direct_addresses: vec!["127.0.0.1:443".to_string()], + }, + transport_lineage: StoredTransportLineageV1::Quiescent { + format: QUIESCENT_LINEAGE_FORMAT.to_string(), + lineage_revision: 1, + bytes: vec![digest_seed, 9], + digest: [digest_seed.wrapping_add(2); DIGEST_BYTES], + replay_floor: Some(StoredConnectionLineageFloorV1 { + host_epoch: 2, + generation: 4, + }), + }, + } + } + + fn stored_registry( + account_seed: u64, + account_context_epoch: u64, + ) -> StoredPairedTargetRegistryV1 { + StoredPairedTargetRegistryV1 { + account_id: uuid(account_seed), + project_id: uuid(account_seed + 1), + local_registration_id: uuid(10), + local_device_id: uuid(11), + local_installation_id: uuid(12), + controller_endpoint_id: endpoint('a'), + controller_endpoint_epoch: 2, + account_context_epoch, + security_epoch: 4, + authorization_snapshot_revision: 9, + registration_evidence: evidence("sdk.device-registration.v1", 21), + revocation_sync_evidence: evidence("sdk.revocation-sync.v1", 22), + targets: vec![stored_target(1)], + lineage_tombstones: Vec::new(), + } + } + + fn claims_for( + registry: &StoredPairedTargetRegistryV1, + storage_key_digest: PairedTargetStorageKeyDigest, + ) -> NativePortableCredentialClaims { + NativePortableCredentialClaims { + account_id: registry.account_id.clone(), + project_id: registry.project_id.clone(), + local_registration_id: registry.local_registration_id.clone(), + local_device_id: registry.local_device_id.clone(), + local_installation_id: registry.local_installation_id.clone(), + controller_endpoint_id: registry.controller_endpoint_id.clone(), + controller_endpoint_epoch: registry.controller_endpoint_epoch, + account_context_epoch: registry.account_context_epoch, + storage_key_digest, + } + } + + fn commit_candidate( + store: &InMemoryPairedTargetAuthorityStore, + key: PairedTargetStorageKeyDigest, + revision: u64, + registry: StoredPairedTargetRegistryV1, + ) -> StoredRegistryEnvelopeV1 { + let token = match store.load(key).expect("load current slot") { + StoredRegistryLoad::Empty { token } | StoredRegistryLoad::Committed { token, .. } => { + token + } + }; + let candidate = + StoredRegistryEnvelopeV1::new(key, revision, registry).expect("valid candidate"); + let replacement = VerifiedStoredRegistryReplacement::for_test(candidate.clone()); + assert!(matches!( + store.compare_and_replace(token, &replacement), + Ok(StoredRegistryCommitOutcome::Committed) + )); + candidate + } + + fn raw_state_with_body( + key: PairedTargetStorageKeyDigest, + revision: u64, + body: &[u8], + ) -> Vec { + let body_digest = digest_parts(STORED_BODY_DIGEST_DOMAIN, &[body]); + let checksum = state_checksum(key, revision, body.len(), body, body_digest); + let mut encoded = Vec::with_capacity(STATE_FIXED_BYTES + body.len()); + encoded.extend_from_slice(STORED_STATE_MAGIC); + encoded.extend_from_slice(&STORED_SCHEMA_VERSION.to_be_bytes()); + encoded.push(STORED_STATE_KIND); + encoded.extend_from_slice(&key.0); + encoded.extend_from_slice(&revision.to_be_bytes()); + encoded.extend_from_slice(&u32::try_from(body.len()).unwrap().to_be_bytes()); + encoded.extend_from_slice(body); + encoded.extend_from_slice(&body_digest); + encoded.extend_from_slice(&checksum); + encoded + } + + fn serialized_keys(value: &impl Serialize) -> Vec { + let mut keys = serde_json::to_value(value) + .unwrap() + .as_object() + .unwrap() + .keys() + .cloned() + .collect::>(); + keys.sort(); + keys + } + + #[derive(Default)] + struct TestGate { + open: AtomicBool, + notify: Notify, + } + + impl TestGate { + fn open(&self) { + self.open.store(true, Ordering::Release); + self.notify.notify_waiters(); + } + + async fn wait(&self) { + loop { + let notified = self.notify.notified(); + if self.open.load(Ordering::Acquire) { + return; + } + notified.await; + } + } + } + + async fn wait_for_counter(counter: &AtomicU64, expected: u64) { + for _ in 0..10_000 { + if counter.load(Ordering::Acquire) >= expected { + return; + } + tokio::task::yield_now().await; + } + panic!("counter did not reach {expected}"); + } + + struct TestCredential { + claims: NativePortableCredentialClaims, + current: AtomicBool, + revalidation_calls: AtomicU64, + } + + impl TestCredential { + fn new(claims: NativePortableCredentialClaims) -> Self { + Self { + claims, + current: AtomicBool::new(true), + revalidation_calls: AtomicU64::new(0), + } + } + } + + impl NativePortableCredentialLease for TestCredential { + fn claims(&self) -> &NativePortableCredentialClaims { + &self.claims + } + + fn revalidate_current( + self: Arc, + ) -> PortableFuture<'static, Result<(), AgentPortableRemoteError>> { + Box::pin(async move { + self.revalidation_calls.fetch_add(1, Ordering::AcqRel); + if self.current.load(Ordering::Acquire) { + Ok(()) + } else { + Err(AgentPortableRemoteError::Unauthenticated) + } + }) + } + } + + struct TestCredentialProvider { + credential: Mutex>>, + calls: AtomicU64, + } + + impl NativePortableCredentialProvider for TestCredentialProvider { + fn current( + self: Arc, + ) -> PortableFuture< + 'static, + Result, AgentPortableRemoteError>, + > { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::AcqRel); + self.credential + .lock() + .unwrap() + .clone() + .map(|credential| credential as Arc) + .ok_or(AgentPortableRemoteError::Unauthenticated) + }) + } + } + + #[derive(Clone, Copy)] + enum TestVerifierMode { + Valid, + Mismatch, + Revoked, + Fail, + } + + struct TestVerifier { + mode: TestVerifierMode, + calls: AtomicU64, + } + + impl NativePortablePairingVerifier for TestVerifier { + fn verify_registry( + self: Arc, + _credential: Arc, + stored: StoredPairedTargetRegistryV1, + ) -> PortableFuture< + 'static, + Result, + > { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::AcqRel); + if matches!(self.mode, TestVerifierMode::Fail) { + return Err(AgentPortableRemoteError::VerificationFailed); + } + let mut verified = VerifiedAgentPortableTargetRegistry::for_test(&stored); + match self.mode { + TestVerifierMode::Mismatch => { + Arc::make_mut(&mut verified.targets[0]).host_endpoint_epoch += 1; + } + TestVerifierMode::Revoked => { + Arc::make_mut(&mut verified.targets[0]).revoked = true; + } + TestVerifierMode::Valid | TestVerifierMode::Fail => {} + } + Ok(verified) + }) + } + } + + #[derive(Clone, Copy)] + enum TestConnectMode { + Immediate, + WaitForGate, + IgnoreCancellationUntilGate, + FailAfterAcknowledgedCleanup, + } + + struct TestPeer { + lease_seed: PortablePeerLeaseSeed, + status_gate: Option>, + dispose_gate: Option>, + fenced: AtomicBool, + disposed: AtomicBool, + status_calls: AtomicU64, + status_cancellations: AtomicU64, + sessions_calls: AtomicU64, + records_calls: AtomicU64, + network_calls: AtomicU64, + dispose_calls: AtomicU64, + } + + impl TestPeer { + fn new( + generation: u64, + status_gate: Option>, + dispose_gate: Option>, + ) -> Self { + Self { + lease_seed: PortablePeerLeaseSeed::new(7, generation).unwrap(), + status_gate, + dispose_gate, + fenced: AtomicBool::new(false), + disposed: AtomicBool::new(false), + status_calls: AtomicU64::new(0), + status_cancellations: AtomicU64::new(0), + sessions_calls: AtomicU64::new(0), + records_calls: AtomicU64::new(0), + network_calls: AtomicU64::new(0), + dispose_calls: AtomicU64::new(0), + } + } + } + + impl AgentPortablePeer for TestPeer { + fn lease_seed(&self) -> PortablePeerLeaseSeed { + self.lease_seed + } + + fn fence(&self) { + self.fenced.store(true, Ordering::Release); + } + + fn runtime_status( + self: Arc, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result> + { + Box::pin(async move { + self.status_calls.fetch_add(1, Ordering::AcqRel); + if let Some(gate) = &self.status_gate { + tokio::select! { + () = gate.wait() => {} + () = cancellation.cancelled() => { + self.status_cancellations.fetch_add(1, Ordering::AcqRel); + return Err(AgentPortableRemoteError::Cancelled); + } + } + } + if cancellation.is_cancelled() { + return Err(AgentPortableRemoteError::Cancelled); + } + Ok(PortableRuntimeStatus { + running: true, + active_run_count: 1, + }) + }) + } + + fn sessions_page( + self: Arc, + request: PortablePageRequest, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result> + { + Box::pin(async move { + self.sessions_calls.fetch_add(1, Ordering::AcqRel); + if cancellation.is_cancelled() { + return Err(AgentPortableRemoteError::Cancelled); + } + Ok(PortableSessionPage { + items: vec![PortableSessionSummary { + id: "session-1".to_string(), + title: "Portable session".to_string(), + created_ms: 1, + updated_ms: 2, + page_sort_ms: 2, + message_count: 3, + }] + .into_iter() + .take(usize::from(request.limit)) + .collect(), + next_cursor: Some("sessions:next".to_string()), + }) + }) + } + + fn records_page( + self: Arc, + _request: PortableRecordsPageRequest, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result> + { + Box::pin(async move { + self.records_calls.fetch_add(1, Ordering::AcqRel); + if cancellation.is_cancelled() { + return Err(AgentPortableRemoteError::Cancelled); + } + Ok(PortableHistoryPage { + items: vec![PortableHistoryRecord { + record_id: "record:1".to_string(), + role: "user".to_string(), + created_ms: 3, + items: vec![PortableTimelineItem { + id: "item-1".to_string(), + item_type: "message".to_string(), + role: Some("user".to_string()), + title: Some("Message".to_string()), + text: Some("hello".to_string()), + status: Some("completed".to_string()), + created_ms: 3, + merge: "append".to_string(), + }], + }], + history_revision: "history-revision:1".to_string(), + next_cursor: Some("records:next".to_string()), + }) + }) + } + + fn network_changed( + self: Arc, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result<(), AgentPortableRemoteError>> { + Box::pin(async move { + self.network_calls.fetch_add(1, Ordering::AcqRel); + if cancellation.is_cancelled() { + Err(AgentPortableRemoteError::Cancelled) + } else { + Ok(()) + } + }) + } + + fn dispose( + self: Arc, + ) -> PortableFuture<'static, Result<(), AgentPortableRemoteError>> { + Box::pin(async move { + self.dispose_calls.fetch_add(1, Ordering::AcqRel); + if let Some(gate) = &self.dispose_gate { + gate.wait().await; + } + self.disposed.store(true, Ordering::Release); + Ok(()) + }) + } + } + + struct TestPeerFactory { + mode: TestConnectMode, + connect_gate: Arc, + status_gate: Option>, + dispose_gate: Option>, + connect_calls: AtomicU64, + peers: Mutex>>, + } + + impl TestPeerFactory { + fn immediate() -> Self { + let connect_gate = Arc::new(TestGate::default()); + connect_gate.open(); + Self { + mode: TestConnectMode::Immediate, + connect_gate, + status_gate: None, + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + } + } + + fn peers(&self) -> Vec> { + self.peers.lock().unwrap().clone() + } + } + + impl PortablePeerFactory for TestPeerFactory { + fn connect( + self: Arc, + target: Arc, + cancellation: PortableCancellation, + ) -> PortableFuture<'static, Result, AgentPortableRemoteError>> + { + Box::pin(async move { + // Exercise the sealed-only route surface. A production factory + // must additionally intersect these hints with native policy. + assert!(!target.host_endpoint_id().is_empty()); + assert!(target.host_endpoint_epoch() > 0); + assert!(!target.connection_hints().relay_urls.is_empty()); + assert!(target.replay_floor().is_some()); + if target + .transport_lineage_authority() + .downcast_ref::() + .is_none() + { + return Err(AgentPortableRemoteError::VerificationFailed); + } + let generation = self.connect_calls.fetch_add(1, Ordering::AcqRel) + 1; + match self.mode { + TestConnectMode::Immediate => {} + TestConnectMode::WaitForGate => { + tokio::select! { + () = self.connect_gate.wait() => {} + () = cancellation.cancelled() => { + return Err(AgentPortableRemoteError::Cancelled); + } + } + } + TestConnectMode::IgnoreCancellationUntilGate => { + self.connect_gate.wait().await; + } + TestConnectMode::FailAfterAcknowledgedCleanup => {} + } + let peer = Arc::new(TestPeer::new( + generation, + self.status_gate.clone(), + self.dispose_gate.clone(), + )); + self.peers.lock().unwrap().push(peer.clone()); + if matches!(self.mode, TestConnectMode::FailAfterAcknowledgedCleanup) { + peer.fence(); + peer.clone().dispose().await?; + return Err(AgentPortableRemoteError::PeerUnavailable); + } + Ok(peer as Arc) + }) + } + } + + async fn wait_for_peer(factory: &TestPeerFactory, index: usize) -> Arc { + for _ in 0..10_000 { + if let Some(peer) = factory.peers().get(index).cloned() { + return peer; + } + tokio::task::yield_now().await; + } + panic!("peer {index} was not acquired"); + } + + struct ControllerFixture { + controller: AgentPortableRemoteController, + store: Arc, + credential: Arc, + provider: Arc, + verifier: Arc, + factory: Arc, + registry: StoredPairedTargetRegistryV1, + key: PairedTargetStorageKeyDigest, + } + + fn controller_fixture( + verifier_mode: TestVerifierMode, + factory: TestPeerFactory, + ) -> ControllerFixture { + let store = Arc::new(InMemoryPairedTargetAuthorityStore::default()); + let key = PairedTargetStorageKeyDigest::for_test(31); + let registry = stored_registry(1, 1); + commit_candidate(&store, key, 1, registry.clone()); + let credential = Arc::new(TestCredential::new(claims_for(®istry, key))); + let provider = Arc::new(TestCredentialProvider { + credential: Mutex::new(Some(credential.clone())), + calls: AtomicU64::new(0), + }); + let verifier = Arc::new(TestVerifier { + mode: verifier_mode, + calls: AtomicU64::new(0), + }); + let factory = Arc::new(factory); + let controller = AgentPortableRemoteController::with_dependencies( + store.clone(), + provider.clone(), + verifier.clone(), + factory.clone(), + ); + ControllerFixture { + controller, + store, + credential, + provider, + verifier, + factory, + registry, + key, + } + } + + #[test] + fn stored_state_rejects_corruption_version_and_noncanonical_json() { + let key = PairedTargetStorageKeyDigest::for_test(1); + let registry = stored_registry(1, 1); + let envelope = StoredRegistryEnvelopeV1::new(key, 1, registry.clone()).unwrap(); + let encoded = envelope.encode().unwrap(); + assert_eq!( + StoredRegistryEnvelopeV1::decode(&encoded).unwrap(), + envelope + ); + + let mut corrupt = encoded.clone(); + *corrupt.last_mut().unwrap() ^= 1; + assert_eq!( + StoredRegistryEnvelopeV1::decode(&corrupt).unwrap_err(), + AgentPortableRemoteError::CorruptStoredRegistry + ); + + let mut unsupported = encoded; + unsupported[8..10].copy_from_slice(&2u16.to_be_bytes()); + assert_eq!( + StoredRegistryEnvelopeV1::decode(&unsupported).unwrap_err(), + AgentPortableRemoteError::UnsupportedStoredVersion + ); + + let canonical = encode_registry_body(®istry).unwrap(); + let noncanonical = serde_json::to_vec_pretty(®istry).unwrap(); + assert_ne!(noncanonical, canonical); + let encoded = raw_state_with_body(key, 1, &noncanonical); + assert_eq!( + StoredRegistryEnvelopeV1::decode(&encoded).unwrap_err(), + AgentPortableRemoteError::CorruptStoredRegistry + ); + + let canonical_text = std::str::from_utf8(&canonical).unwrap(); + let duplicate_field = format!( + "{{\"accountId\":\"{}\",{}", + registry.account_id, + &canonical_text[1..] + ); + let encoded = raw_state_with_body(key, 1, duplicate_field.as_bytes()); + assert_eq!( + StoredRegistryEnvelopeV1::decode(&encoded).unwrap_err(), + AgentPortableRemoteError::CorruptStoredRegistry + ); + + let unknown_field = format!("{{\"unknownAuthority\":true,{}", &canonical_text[1..]); + let encoded = raw_state_with_body(key, 1, unknown_field.as_bytes()); + assert_eq!( + StoredRegistryEnvelopeV1::decode(&encoded).unwrap_err(), + AgentPortableRemoteError::CorruptStoredRegistry + ); + + let deeply_nested = format!("{}0{}", "[".repeat(256), "]".repeat(256)); + assert_eq!( + decode_json_exact::(deeply_nested.as_bytes()).unwrap_err(), + AgentPortableRemoteError::CorruptStoredRegistry + ); + + let oversized = vec![b' '; MAX_STORED_BODY_BYTES + 1]; + assert_eq!( + StoredRegistryEnvelopeV1::decode(&raw_state_with_body(key, 1, &oversized)).unwrap_err(), + AgentPortableRemoteError::CorruptStoredRegistry + ); + } + + #[test] + fn stored_registry_rejects_duplicate_and_equivocating_targets() { + let key = PairedTargetStorageKeyDigest::for_test(2); + let mut duplicate = stored_registry(1, 1); + duplicate.targets.push(duplicate.targets[0].clone()); + assert_eq!( + StoredRegistryEnvelopeV1::new(key, 1, duplicate).unwrap_err(), + AgentPortableRemoteError::DuplicateStoredTarget + ); + + let mut equivocation = stored_registry(1, 1); + let mut second = equivocation.targets[0].clone(); + second.host_registration_id = uuid(202); + second.host_device_id = uuid(302); + second.host_installation_id = uuid(402); + second.host_endpoint_id = endpoint('c'); + equivocation.targets.push(second); + assert_eq!( + StoredRegistryEnvelopeV1::new(key, 1, equivocation).unwrap_err(), + AgentPortableRemoteError::StoredRegistryEquivocation + ); + } + + #[test] + fn stored_registry_enforces_labels_routes_evidence_and_count_bounds() { + let key = PairedTargetStorageKeyDigest::for_test(34); + + let mut boundary_label = stored_registry(1, 1); + boundary_label.targets[0].host_display_name = "🦀".repeat(64); + StoredRegistryEnvelopeV1::new(key, 1, boundary_label).unwrap(); + + let mut oversized_label = stored_registry(1, 1); + oversized_label.targets[0].host_display_name = "🦀".repeat(65); + assert_eq!( + StoredRegistryEnvelopeV1::new(key, 1, oversized_label).unwrap_err(), + AgentPortableRemoteError::InvalidStoredRegistry + ); + + let mut too_many_relays = stored_registry(1, 1); + too_many_relays.targets[0].connection_hints.relay_urls = (0..=MAX_RELAY_HINTS) + .map(|index| format!("https://relay-{index}.example.com/")) + .collect(); + assert_eq!( + StoredRegistryEnvelopeV1::new(key, 1, too_many_relays).unwrap_err(), + AgentPortableRemoteError::InvalidStoredRegistry + ); + + let mut oversized_evidence = stored_registry(1, 1); + oversized_evidence.registration_evidence.bytes = vec![1; MAX_OPAQUE_EVIDENCE_BYTES + 1]; + assert_eq!( + StoredRegistryEnvelopeV1::new(key, 1, oversized_evidence).unwrap_err(), + AgentPortableRemoteError::InvalidStoredRegistry + ); + + let mut too_many_targets = stored_registry(1, 1); + too_many_targets.targets = (1..=(MAX_CURRENT_TARGETS + 1)) + .map(|seed| stored_target(u64::try_from(seed).unwrap())) + .collect(); + assert_eq!( + StoredRegistryEnvelopeV1::new(key, 1, too_many_targets).unwrap_err(), + AgentPortableRemoteError::InvalidStoredRegistry + ); + } + + #[test] + fn stored_and_verified_debug_output_redacts_authority_and_routes() { + let key = PairedTargetStorageKeyDigest::for_test(35); + let registry = stored_registry(1, 1); + let target = ®istry.targets[0]; + let tombstone = StoredLineageTombstoneV1 { + host_registration_id: uuid(900), + host_endpoint_id: endpoint('d'), + pair_id: uuid(901), + retired_pairing_incarnation: 1, + retired_authorization_revision: 1, + replay_floor: Some(StoredConnectionLineageFloorV1 { + host_epoch: 1, + generation: 1, + }), + }; + let envelope = StoredRegistryEnvelopeV1::new(key, 1, registry.clone()).unwrap(); + let guard = StoredRegistryGuardV1::committed(&envelope).unwrap(); + let token = StoredRegistryCommitToken::from(guard); + let verified = VerifiedAgentPortableTargetRegistry::for_test(®istry); + let replacement = VerifiedStoredRegistryReplacement::for_test(envelope.clone()); + let empty_token = StoredRegistryCommitToken::from( + StoredRegistryGuardV1::initial(key).expect("valid initial guard"), + ); + let empty_load = StoredRegistryLoad::Empty { token: empty_token }; + let committed_load = StoredRegistryLoad::Committed { + envelope: Box::new(envelope.clone()), + token, + }; + let uncertain_floor = StoredConnectionLineageFloorV1 { + host_epoch: 8_700_000_000_000_001, + generation: 8_700_000_000_000_002, + }; + let uncertain_lineage = StoredTransportLineageV1::Uncertain { + format: UNCERTAIN_LINEAGE_FORMAT.to_string(), + lineage_revision: 8_700_000_000_000_003, + bytes: vec![241, 202, 187, 172, 157], + digest: [231; DIGEST_BYTES], + replay_floor: Some(uncertain_floor.clone()), + }; + let output = format!( + "{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}", + target.authorization, + target.transport_lineage, + uncertain_lineage, + target.connection_hints, + target.revocation, + target, + tombstone, + registry, + envelope, + guard, + token, + verified, + verified.targets[0], + verified.targets[0].transport_lineage_authority(), + replacement, + empty_load, + committed_load, + ); + let (lineage_bytes, lineage_digest) = match &target.transport_lineage { + StoredTransportLineageV1::Quiescent { bytes, digest, .. } + | StoredTransportLineageV1::Uncertain { bytes, digest, .. } => (bytes, digest), + }; + for secret in [ + registry.account_id.clone(), + registry.project_id.clone(), + registry.local_registration_id.clone(), + registry.local_device_id.clone(), + registry.local_installation_id.clone(), + registry.controller_endpoint_id.clone(), + target.pair_id.clone(), + target.host_registration_id.clone(), + target.host_device_id.clone(), + target.host_installation_id.clone(), + target.host_endpoint_id.clone(), + target.host_display_name.clone(), + target.revocation.stream_id.clone(), + target.connection_hints.relay_urls[0].clone(), + target.connection_hints.direct_addresses[0].clone(), + tombstone.host_registration_id.clone(), + tombstone.host_endpoint_id.clone(), + tombstone.pair_id.clone(), + format!("{:?}", registry.registration_evidence.bytes), + format!("{:?}", registry.registration_evidence.digest), + format!("{:?}", registry.revocation_sync_evidence.bytes), + format!("{:?}", registry.revocation_sync_evidence.digest), + format!("{:?}", target.authorization.bytes), + format!("{:?}", target.authorization.digest), + format!("{:?}", target.revocation.checkpoint_digest), + format!("{lineage_bytes:?}"), + format!("{lineage_digest:?}"), + format!("{:?}", [241, 202, 187, 172, 157]), + format!("{:?}", [231; DIGEST_BYTES]), + uncertain_floor.host_epoch.to_string(), + uncertain_floor.generation.to_string(), + format!("{:?}", envelope.body_digest), + format!("{:?}", envelope.record_checksum), + format!("{:?}", guard.committed_state_digest), + format!("{:?}", guard.checksum), + format!("{:?}", key.0), + ] { + assert!(!output.contains(secret.as_str())); + } + for covered in [ + "Uncertain", + "VerifiedPortableTransportLineageAuthority()", + "VerifiedStoredRegistryReplacement", + "Empty", + "Committed", + ] { + assert!(output.contains(covered)); + } + } + + #[test] + fn guard_checksum_wire_preimage_matches_golden_vector() { + assert_eq!( + guard_checksum( + PairedTargetStorageKeyDigest([0x11; DIGEST_BYTES]), + 0x0102_0304_0506_0708, + [0x22; DIGEST_BYTES], + ), + [ + 36, 200, 160, 198, 164, 160, 158, 72, 197, 224, 72, 142, 228, 127, 246, 30, 97, + 235, 231, 26, 224, 214, 159, 2, 243, 240, 129, 76, 239, 69, 193, 122, + ] + ); + } + + #[test] + fn guard_detects_equal_revision_equivocation_and_state_rollback() { + let key = PairedTargetStorageKeyDigest::for_test(3); + let store = InMemoryPairedTargetAuthorityStore::default(); + let first = StoredRegistryEnvelopeV1::new(key, 1, stored_registry(1, 1)).unwrap(); + let mut conflicting_registry = stored_registry(1, 1); + conflicting_registry.targets[0].host_display_name = "Other host".to_string(); + conflicting_registry.targets[0].directory_revision += 1; + let conflicting = StoredRegistryEnvelopeV1::new(key, 1, conflicting_registry).unwrap(); + store.restore_raw_snapshot(( + Some(first.encode().unwrap()), + Some( + StoredRegistryGuardV1::committed(&conflicting) + .unwrap() + .encode() + .unwrap(), + ), + )); + assert_eq!( + store.load(key).unwrap_err(), + AgentPortableRemoteError::StoredRegistryEquivocation + ); + + let newer = StoredRegistryEnvelopeV1::new(key, 2, stored_registry(1, 1)).unwrap(); + store.restore_raw_snapshot(( + Some(first.encode().unwrap()), + Some( + StoredRegistryGuardV1::committed(&newer) + .unwrap() + .encode() + .unwrap(), + ), + )); + assert_eq!( + store.load(key).unwrap_err(), + AgentPortableRemoteError::StoredRegistryRollback + ); + + let gap = StoredRegistryEnvelopeV1::new(key, 3, stored_registry(1, 1)).unwrap(); + store.restore_raw_snapshot(( + Some(gap.encode().unwrap()), + Some( + StoredRegistryGuardV1::committed(&first) + .unwrap() + .encode() + .unwrap(), + ), + )); + assert_eq!( + store.load(key).unwrap_err(), + AgentPortableRemoteError::CorruptStoredRegistry + ); + } + + #[test] + fn same_context_allows_only_exact_registry_replay() { + let key = PairedTargetStorageKeyDigest::for_test(33); + let store = InMemoryPairedTargetAuthorityStore::default(); + let original = stored_registry(1, 1); + commit_candidate(&store, key, 1, original.clone()); + commit_candidate(&store, key, 2, original.clone()); + + let token = match store.load(key).unwrap() { + StoredRegistryLoad::Committed { token, .. } => token, + StoredRegistryLoad::Empty { .. } => panic!("slot should be committed"), + }; + let mut changed = original.clone(); + changed.targets[0].revocation.applied_sequence += 1; + changed.targets[0].revocation.checkpoint_digest = [91; DIGEST_BYTES]; + let changed = StoredRegistryEnvelopeV1::new(key, 3, changed).unwrap(); + assert_eq!( + store + .compare_and_replace(token, &VerifiedStoredRegistryReplacement::for_test(changed),) + .unwrap_err(), + AgentPortableRemoteError::StoredRegistryEquivocation + ); + + let mut replacement = original; + replacement.account_context_epoch = 2; + replacement.targets[0].revocation.applied_sequence += 1; + replacement.targets[0].revocation.checkpoint_digest = [92; DIGEST_BYTES]; + let replacement = StoredRegistryEnvelopeV1::new(key, 3, replacement).unwrap(); + assert_eq!( + store + .compare_and_replace( + token, + &VerifiedStoredRegistryReplacement::for_test(replacement), + ) + .unwrap(), + StoredRegistryCommitOutcome::Committed + ); + } + + #[test] + fn in_memory_store_recovers_interrupted_commit_and_exact_post_guard_replay() { + let key = PairedTargetStorageKeyDigest::for_test(4); + let store = InMemoryPairedTargetAuthorityStore::default(); + let initial_token = match store.load(key).unwrap() { + StoredRegistryLoad::Empty { token } => token, + StoredRegistryLoad::Committed { .. } => panic!("slot should be empty"), + }; + let candidate = StoredRegistryEnvelopeV1::new(key, 1, stored_registry(1, 1)).unwrap(); + let replacement = VerifiedStoredRegistryReplacement::for_test(candidate.clone()); + let empty_snapshot = store.raw_snapshot(); + + store.inject_fault_once(InMemoryStoreFault::BeforeState); + assert_eq!( + store + .compare_and_replace(initial_token, &replacement) + .unwrap_err(), + AgentPortableRemoteError::StoredRegistryInterrupted + ); + assert!(matches!( + store.load(key).unwrap(), + StoredRegistryLoad::Empty { .. } + )); + + store.inject_fault_once(InMemoryStoreFault::AfterState); + assert_eq!( + store + .compare_and_replace(initial_token, &replacement) + .unwrap_err(), + AgentPortableRemoteError::StoredRegistryInterrupted + ); + assert_eq!( + store.load(key).unwrap_err(), + AgentPortableRemoteError::StoredRegistryInterrupted + ); + assert_eq!( + store + .compare_and_replace(initial_token, &replacement) + .unwrap_err(), + AgentPortableRemoteError::StoredRegistryInterrupted + ); + store.restore_raw_snapshot(empty_snapshot); + assert_eq!( + store + .compare_and_replace(initial_token, &replacement) + .unwrap(), + StoredRegistryCommitOutcome::Committed + ); + + let committed_token = match store.load(key).unwrap() { + StoredRegistryLoad::Committed { token, .. } => token, + StoredRegistryLoad::Empty { .. } => panic!("slot should be committed"), + }; + let next_registry = stored_registry(1, 1); + let next = StoredRegistryEnvelopeV1::new(key, 2, next_registry).unwrap(); + let next_replacement = VerifiedStoredRegistryReplacement::for_test(next.clone()); + store.inject_fault_once(InMemoryStoreFault::AfterGuard); + assert_eq!( + store + .compare_and_replace(committed_token, &next_replacement) + .unwrap_err(), + AgentPortableRemoteError::StoredRegistryInterrupted + ); + let reopened_token = match store.load(key).unwrap() { + StoredRegistryLoad::Committed { envelope, token } => { + assert_eq!(envelope.as_ref(), &next); + token + } + StoredRegistryLoad::Empty { .. } => panic!("slot should be committed"), + }; + assert_eq!( + store + .compare_and_replace(reopened_token, &next_replacement) + .unwrap(), + StoredRegistryCommitOutcome::AlreadyCommitted + ); + } + + #[test] + fn installation_slot_orders_account_a_to_b_to_a_without_epoch_reuse() { + let key = PairedTargetStorageKeyDigest::for_test(5); + let store = InMemoryPairedTargetAuthorityStore::default(); + commit_candidate(&store, key, 1, stored_registry(1, 1)); + commit_candidate(&store, key, 2, stored_registry(20, 2)); + + let token = match store.load(key).unwrap() { + StoredRegistryLoad::Committed { token, .. } => token, + StoredRegistryLoad::Empty { .. } => panic!("slot should be committed"), + }; + let stale_return = StoredRegistryEnvelopeV1::new(key, 3, stored_registry(1, 1)).unwrap(); + let stale_replacement = VerifiedStoredRegistryReplacement::for_test(stale_return); + assert_eq!( + store + .compare_and_replace(token, &stale_replacement) + .unwrap_err(), + AgentPortableRemoteError::StoredRegistryRollback + ); + let mut fresh_registry = stored_registry(1, 3); + fresh_registry.security_epoch += 1; + fresh_registry.authorization_snapshot_revision += 1; + fresh_registry.registration_evidence = evidence("sdk.device-registration.v2", 41); + fresh_registry.revocation_sync_evidence = evidence("sdk.revocation-sync.v2", 42); + fresh_registry.targets[0].pairing_revision += 1; + fresh_registry.targets[0].authorization = evidence(PAIR_AUTHORIZATION_EVIDENCE_FORMAT, 43); + let fresh_return = StoredRegistryEnvelopeV1::new(key, 3, fresh_registry).unwrap(); + let fresh_replacement = VerifiedStoredRegistryReplacement::for_test(fresh_return); + assert_eq!( + store + .compare_and_replace(token, &fresh_replacement) + .unwrap(), + StoredRegistryCommitOutcome::Committed + ); + } + + #[tokio::test] + async fn transport_uncertainty_is_retained_but_never_issued_as_a_target() { + let key = PairedTargetStorageKeyDigest::for_test(6); + let mut registry = stored_registry(1, 1); + registry.targets[0].transport_lineage = StoredTransportLineageV1::Uncertain { + format: UNCERTAIN_LINEAGE_FORMAT.to_string(), + lineage_revision: 2, + bytes: vec![1, 2, 3], + digest: [9; DIGEST_BYTES], + replay_floor: Some(StoredConnectionLineageFloorV1 { + host_epoch: 2, + generation: 4, + }), + }; + let envelope = StoredRegistryEnvelopeV1::new(key, 1, registry.clone()).unwrap(); + assert_eq!( + StoredRegistryEnvelopeV1::decode(&envelope.encode().unwrap()) + .unwrap() + .registry, + registry + ); + assert!(!registry.targets[0].transport_lineage.is_quiescent()); + + let store = Arc::new(InMemoryPairedTargetAuthorityStore::default()); + commit_candidate(&store, key, 1, registry.clone()); + let credential = Arc::new(TestCredential::new(claims_for(®istry, key))); + let provider = Arc::new(TestCredentialProvider { + credential: Mutex::new(Some(credential)), + calls: AtomicU64::new(0), + }); + let verifier = Arc::new(TestVerifier { + mode: TestVerifierMode::Valid, + calls: AtomicU64::new(0), + }); + let factory = Arc::new(TestPeerFactory::immediate()); + let controller = AgentPortableRemoteController::with_dependencies( + store, + provider, + verifier, + factory.clone(), + ); + assert!(controller.refresh_targets().await.unwrap().is_empty()); + assert_eq!(factory.connect_calls.load(Ordering::Acquire), 0); + controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn disabled_composition_is_unavailable_and_registers_no_capability() { + let controller = AgentPortableRemoteController::disabled(); + assert_eq!( + controller.refresh_targets().await.unwrap_err(), + AgentPortableRemoteError::Unavailable + ); + controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn expected_account_is_rejected_before_verification_or_factory_activity() { + let fixture = controller_fixture(TestVerifierMode::Valid, TestPeerFactory::immediate()); + assert_eq!( + fixture + .controller + .refresh_targets_for_account("not-a-canonical-account-id") + .await + .unwrap_err(), + AgentPortableRemoteError::InvalidRequest + ); + assert_eq!(fixture.provider.calls.load(Ordering::Acquire), 0); + assert_eq!(fixture.verifier.calls.load(Ordering::Acquire), 0); + assert_eq!(fixture.factory.connect_calls.load(Ordering::Acquire), 0); + + assert_eq!( + fixture + .controller + .refresh_targets_for_account(&uuid(999)) + .await + .unwrap_err(), + AgentPortableRemoteError::AccountMismatch + ); + assert_eq!(fixture.provider.calls.load(Ordering::Acquire), 1); + assert_eq!(fixture.verifier.calls.load(Ordering::Acquire), 0); + assert_eq!(fixture.factory.connect_calls.load(Ordering::Acquire), 0); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn credentials_and_verification_fail_before_factory_connect() { + let store = Arc::new(InMemoryPairedTargetAuthorityStore::default()); + let key = PairedTargetStorageKeyDigest::for_test(41); + let registry = stored_registry(1, 1); + commit_candidate(&store, key, 1, registry.clone()); + let no_credentials = Arc::new(TestCredentialProvider { + credential: Mutex::new(None), + calls: AtomicU64::new(0), + }); + let verifier = Arc::new(TestVerifier { + mode: TestVerifierMode::Valid, + calls: AtomicU64::new(0), + }); + let factory = Arc::new(TestPeerFactory::immediate()); + let controller = AgentPortableRemoteController::with_dependencies( + store.clone(), + no_credentials, + verifier.clone(), + factory.clone(), + ); + assert_eq!( + controller.refresh_targets().await.unwrap_err(), + AgentPortableRemoteError::Unauthenticated + ); + assert_eq!(verifier.calls.load(Ordering::Acquire), 0); + assert_eq!(factory.connect_calls.load(Ordering::Acquire), 0); + + let mut wrong_claims = claims_for(®istry, key); + wrong_claims.account_id = uuid(99); + let wrong_credential = Arc::new(TestCredential::new(wrong_claims)); + let wrong_provider = Arc::new(TestCredentialProvider { + credential: Mutex::new(Some(wrong_credential)), + calls: AtomicU64::new(0), + }); + let controller = AgentPortableRemoteController::with_dependencies( + store, + wrong_provider, + verifier.clone(), + factory.clone(), + ); + assert_eq!( + controller.refresh_targets().await.unwrap_err(), + AgentPortableRemoteError::AccountMismatch + ); + assert_eq!(verifier.calls.load(Ordering::Acquire), 0); + assert_eq!(factory.connect_calls.load(Ordering::Acquire), 0); + + for (mode, expected) in [ + ( + TestVerifierMode::Mismatch, + AgentPortableRemoteError::VerificationFailed, + ), + (TestVerifierMode::Revoked, AgentPortableRemoteError::Revoked), + ( + TestVerifierMode::Fail, + AgentPortableRemoteError::VerificationFailed, + ), + ] { + let fixture = controller_fixture(mode, TestPeerFactory::immediate()); + assert_eq!( + fixture.controller.refresh_targets().await.unwrap_err(), + expected + ); + assert_eq!(fixture.factory.connect_calls.load(Ordering::Acquire), 0); + } + } + + #[tokio::test] + async fn exact_read_allowlist_returns_only_sanitized_portable_shapes() { + let fixture = controller_fixture(TestVerifierMode::Valid, TestPeerFactory::immediate()); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + assert_eq!(descriptors.len(), 1); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + + let status = fixture.controller.runtime_status(&lease).await.unwrap(); + assert_eq!( + status, + PortableRuntimeStatus { + running: true, + active_run_count: 1, + } + ); + let sessions = fixture + .controller + .sessions_page( + &lease, + PortablePageRequest { + cursor: None, + limit: 10, + }, + ) + .await + .unwrap(); + assert_eq!(sessions.items.len(), 1); + assert_eq!(sessions.items[0].message_count, 3); + let records = fixture + .controller + .records_page( + &lease, + PortableRecordsPageRequest { + session_id: sessions.items[0].id.clone(), + cursor: None, + limit: 10, + }, + ) + .await + .unwrap(); + assert_eq!(records.items.len(), 1); + fixture.controller.network_changed(&lease).await.unwrap(); + + assert_eq!( + serialized_keys(&descriptors[0]), + ["handle", "label"].map(str::to_string) + ); + assert_eq!( + serialized_keys(&status), + ["activeRunCount", "running"].map(str::to_string) + ); + assert_eq!( + serialized_keys(&sessions), + ["items", "nextCursor"].map(str::to_string) + ); + assert_eq!( + serialized_keys(&sessions.items[0]), + [ + "createdMs", + "id", + "messageCount", + "pageSortMs", + "title", + "updatedMs", + ] + .map(str::to_string) + ); + assert_eq!( + serialized_keys(&records), + ["historyRevision", "items", "nextCursor"].map(str::to_string) + ); + assert_eq!( + serialized_keys(&records.items[0]), + ["createdMs", "items", "recordId", "role"].map(str::to_string) + ); + assert_eq!( + serialized_keys(&records.items[0].items[0]), + [ + "createdMs", + "id", + "itemType", + "merge", + "role", + "status", + "text", + "title", + ] + .map(str::to_string) + ); + // The native lease deliberately has no Serde implementation. Only the + // mobile Tauri child projects it into a string-epoch wire lease. + let serialized = + serde_json::to_string(&(&descriptors[0], &status, &sessions, &records)).unwrap(); + for forbidden in [ + "accountId", + "projectId", + "projectRoot", + "endpointId", + "model", + "mode", + "runId", + "toolArguments", + "toolOutput", + "permissionDetails", + ] { + assert!(!serialized.contains(forbidden)); + } + + let peer = fixture.factory.peers()[0].clone(); + assert_eq!(peer.status_calls.load(Ordering::Acquire), 1); + assert_eq!(peer.sessions_calls.load(Ordering::Acquire), 1); + assert_eq!(peer.records_calls.load(Ordering::Acquire), 1); + assert_eq!(peer.network_calls.load(Ordering::Acquire), 1); + fixture.controller.dispose().await.unwrap(); + assert!(peer.fenced.load(Ordering::Acquire)); + assert!(peer.disposed.load(Ordering::Acquire)); + } + + #[test] + fn lease_epochs_accept_full_positive_u64_while_generation_stays_js_safe() { + let seed = PortablePeerLeaseSeed::new(u64::MAX, 1).unwrap(); + let lease = PortableTargetLease { + target_id: format!("lease_{}", "1".repeat(48)), + host_epoch: seed.host_epoch, + connection_generation: seed.connection_generation, + }; + assert_eq!(lease.host_epoch, u64::MAX); + lease.validate().unwrap(); + + assert_eq!( + PortablePeerLeaseSeed::new(0, 1).unwrap_err(), + AgentPortableRemoteError::PeerUnavailable + ); + assert_eq!( + PortablePeerLeaseSeed::new(1, MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + 1).unwrap_err(), + AgentPortableRemoteError::PeerUnavailable + ); + } + + #[tokio::test] + async fn invalid_pages_and_js_unsafe_responses_fail_closed() { + let fixture = controller_fixture(TestVerifierMode::Valid, TestPeerFactory::immediate()); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + assert_eq!( + fixture + .controller + .sessions_page( + &lease, + PortablePageRequest { + cursor: None, + limit: MAX_PAGE_SIZE + 1, + }, + ) + .await + .unwrap_err(), + AgentPortableRemoteError::InvalidRequest + ); + assert_eq!(peer.sessions_calls.load(Ordering::Acquire), 0); + + let unsafe_session = PortableSessionSummary { + id: "session-1".to_string(), + title: "title".to_string(), + created_ms: 1, + updated_ms: 1, + page_sort_ms: 1, + message_count: MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + 1, + }; + assert_eq!( + unsafe_session.validate().unwrap_err(), + AgentPortableRemoteError::InvalidResponse + ); + let unsafe_permission = PortableTimelineItem { + id: "item-1".to_string(), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some("Tool permission".to_string()), + text: Some("host detail".to_string()), + status: Some("allow_once".to_string()), + created_ms: 1, + merge: "append".to_string(), + }; + assert_eq!( + unsafe_permission.validate().unwrap_err(), + AgentPortableRemoteError::InvalidResponse + ); + fixture.controller.dispose().await.unwrap(); + } + + #[test] + fn session_page_validation_rejects_duplicate_session_ids() { + let request = PortablePageRequest { + cursor: None, + limit: 2, + }; + let session = PortableSessionSummary { + id: "session-1".to_string(), + title: "Portable session".to_string(), + created_ms: 1, + updated_ms: 2, + page_sort_ms: 2, + message_count: 3, + }; + assert!(PortableSessionPage { + items: vec![session.clone()], + next_cursor: None, + } + .validate_for(&request) + .is_ok()); + assert_eq!( + PortableSessionPage { + items: vec![session.clone(), session], + next_cursor: None, + } + .validate_for(&request) + .unwrap_err(), + AgentPortableRemoteError::InvalidResponse + ); + } + + #[test] + fn history_page_validation_rejects_duplicate_record_ids() { + let request = PortableRecordsPageRequest { + session_id: "session-1".to_string(), + cursor: None, + limit: 2, + }; + let record = PortableHistoryRecord { + record_id: "record:1".to_string(), + role: "user".to_string(), + created_ms: 3, + items: vec![PortableTimelineItem { + id: "item-1".to_string(), + item_type: "message".to_string(), + role: Some("user".to_string()), + title: None, + text: Some("hello".to_string()), + status: None, + created_ms: 3, + merge: "append".to_string(), + }], + }; + assert!(PortableHistoryPage { + items: vec![record.clone()], + history_revision: "history:1".to_string(), + next_cursor: None, + } + .validate_for(&request) + .is_ok()); + assert_eq!( + PortableHistoryPage { + items: vec![record.clone(), record], + history_revision: "history:1".to_string(), + next_cursor: None, + } + .validate_for(&request) + .unwrap_err(), + AgentPortableRemoteError::InvalidResponse + ); + } + + #[tokio::test] + async fn concurrent_dispose_cancels_connect_and_disposes_stale_acquisition() { + let connect_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::IgnoreCancellationUntilGate, + connect_gate: connect_gate.clone(), + status_gate: None, + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let controller = fixture.controller.clone(); + let handle = descriptors[0].handle.clone(); + let prepare = tokio::spawn(async move { controller.prepare_target(&handle).await }); + wait_for_counter(&fixture.factory.connect_calls, 1).await; + + let controller = fixture.controller.clone(); + let dispose = tokio::spawn(async move { controller.dispose().await }); + connect_gate.open(); + assert_eq!( + prepare.await.unwrap().unwrap_err(), + AgentPortableRemoteError::Cancelled + ); + dispose.await.unwrap().unwrap(); + let peer = fixture.factory.peers()[0].clone(); + assert!(peer.fenced.load(Ordering::Acquire)); + assert!(peer.disposed.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn credential_flip_during_dial_rejects_and_disposes_acquired_peer() { + let connect_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::IgnoreCancellationUntilGate, + connect_gate: connect_gate.clone(), + status_gate: None, + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let controller = fixture.controller.clone(); + let handle = descriptors[0].handle.clone(); + let prepare = tokio::spawn(async move { controller.prepare_target(&handle).await }); + wait_for_counter(&fixture.factory.connect_calls, 1).await; + fixture.credential.current.store(false, Ordering::Release); + connect_gate.open(); + assert_eq!( + prepare.await.unwrap().unwrap_err(), + AgentPortableRemoteError::Unauthenticated + ); + let peer = wait_for_peer(&fixture.factory, 0).await; + wait_for_counter(&peer.dispose_calls, 1).await; + assert!(peer.fenced.load(Ordering::Acquire)); + assert!(peer.disposed.load(Ordering::Acquire)); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn dropped_prepare_waiter_does_not_abort_native_cleanup_owner() { + let connect_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::IgnoreCancellationUntilGate, + connect_gate: connect_gate.clone(), + status_gate: None, + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let controller = fixture.controller.clone(); + let handle = descriptors[0].handle.clone(); + let prepare = tokio::spawn(async move { controller.prepare_target(&handle).await }); + wait_for_counter(&fixture.factory.connect_calls, 1).await; + prepare.abort(); + connect_gate.open(); + let peer = wait_for_peer(&fixture.factory, 0).await; + wait_for_counter(&peer.dispose_calls, 1).await; + assert!(peer.disposed.load(Ordering::Acquire)); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn dropped_prepare_cleanup_blocks_the_next_connect_until_dispose_ack() { + let connect_gate = Arc::new(TestGate::default()); + let dispose_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::IgnoreCancellationUntilGate, + connect_gate: connect_gate.clone(), + status_gate: None, + dispose_gate: Some(dispose_gate.clone()), + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let controller = fixture.controller.clone(); + let handle = descriptors[0].handle.clone(); + let first = tokio::spawn(async move { controller.prepare_target(&handle).await }); + wait_for_counter(&fixture.factory.connect_calls, 1).await; + first.abort(); + connect_gate.open(); + let first_peer = wait_for_peer(&fixture.factory, 0).await; + wait_for_counter(&first_peer.dispose_calls, 1).await; + + let controller = fixture.controller.clone(); + let handle = descriptors[0].handle.clone(); + let second = tokio::spawn(async move { controller.prepare_target(&handle).await }); + for _ in 0..100 { + tokio::task::yield_now().await; + } + assert_eq!(fixture.factory.connect_calls.load(Ordering::Acquire), 1); + assert!(!second.is_finished()); + + dispose_gate.open(); + let second_lease = second.await.unwrap().unwrap(); + assert_eq!(fixture.factory.connect_calls.load(Ordering::Acquire), 2); + fixture + .controller + .runtime_status(&second_lease) + .await + .unwrap(); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn factory_error_is_returned_only_after_partial_acquisition_cleanup_ack() { + let factory = TestPeerFactory { + mode: TestConnectMode::FailAfterAcknowledgedCleanup, + connect_gate: Arc::new(TestGate::default()), + status_gate: None, + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + assert_eq!( + fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap_err(), + AgentPortableRemoteError::PeerUnavailable + ); + let peer = fixture.factory.peers()[0].clone(); + assert!(peer.fenced.load(Ordering::Acquire)); + assert!(peer.disposed.load(Ordering::Acquire)); + assert_eq!(peer.dispose_calls.load(Ordering::Acquire), 1); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn dispose_cancels_inflight_request_before_peer_teardown() { + let status_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::Immediate, + connect_gate: Arc::new(TestGate::default()), + status_gate: Some(status_gate), + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + let controller = fixture.controller.clone(); + let status_lease = lease.clone(); + let status = tokio::spawn(async move { controller.runtime_status(&status_lease).await }); + wait_for_counter(&peer.status_calls, 1).await; + fixture.controller.dispose().await.unwrap(); + assert_eq!( + status.await.unwrap().unwrap_err(), + AgentPortableRemoteError::Cancelled + ); + assert!(peer.disposed.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn credential_flip_during_rpc_rejects_response_and_retires_peer() { + let status_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::Immediate, + connect_gate: Arc::new(TestGate::default()), + status_gate: Some(status_gate.clone()), + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + let controller = fixture.controller.clone(); + let status_lease = lease.clone(); + let status = tokio::spawn(async move { controller.runtime_status(&status_lease).await }); + wait_for_counter(&peer.status_calls, 1).await; + fixture.credential.current.store(false, Ordering::Release); + status_gate.open(); + assert_eq!( + status.await.unwrap().unwrap_err(), + AgentPortableRemoteError::Unauthenticated + ); + wait_for_counter(&peer.dispose_calls, 1).await; + assert!(peer.fenced.load(Ordering::Acquire)); + assert!(peer.disposed.load(Ordering::Acquire)); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn registry_replacement_during_rpc_rejects_response_and_retires_peer() { + let status_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::Immediate, + connect_gate: Arc::new(TestGate::default()), + status_gate: Some(status_gate.clone()), + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + let controller = fixture.controller.clone(); + let status_lease = lease.clone(); + let status = tokio::spawn(async move { controller.runtime_status(&status_lease).await }); + wait_for_counter(&peer.status_calls, 1).await; + let mut replacement = fixture.registry.clone(); + replacement.account_context_epoch += 1; + commit_candidate(&fixture.store, fixture.key, 2, replacement); + status_gate.open(); + assert_eq!( + status.await.unwrap().unwrap_err(), + AgentPortableRemoteError::StaleLease + ); + wait_for_counter(&peer.dispose_calls, 1).await; + assert!(peer.fenced.load(Ordering::Acquire)); + assert!(peer.disposed.load(Ordering::Acquire)); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn dropped_read_waiter_cancels_peer_operation_without_dropping_controller() { + let status_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::Immediate, + connect_gate: Arc::new(TestGate::default()), + status_gate: Some(status_gate.clone()), + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + let controller = fixture.controller.clone(); + let status_lease = lease.clone(); + let status = tokio::spawn(async move { controller.runtime_status(&status_lease).await }); + wait_for_counter(&peer.status_calls, 1).await; + status.abort(); + wait_for_counter(&peer.status_cancellations, 1).await; + + status_gate.open(); + fixture.controller.runtime_status(&lease).await.unwrap(); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn last_controller_drop_during_request_still_drains_native_peer() { + let status_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::Immediate, + connect_gate: Arc::new(TestGate::default()), + status_gate: Some(status_gate), + dispose_gate: None, + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + let controller = fixture.controller.clone(); + let request = tokio::spawn(async move { controller.runtime_status(&lease).await }); + wait_for_counter(&peer.status_calls, 1).await; + + drop(fixture); + request.abort(); + let _ = request.await; + wait_for_counter(&peer.status_cancellations, 1).await; + wait_for_counter(&peer.dispose_calls, 1).await; + assert!(peer.fenced.load(Ordering::Acquire)); + assert!(peer.disposed.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn dispose_waits_for_native_acknowledgement() { + let dispose_gate = Arc::new(TestGate::default()); + let factory = TestPeerFactory { + mode: TestConnectMode::Immediate, + connect_gate: Arc::new(TestGate::default()), + status_gate: None, + dispose_gate: Some(dispose_gate.clone()), + connect_calls: AtomicU64::new(0), + peers: Mutex::new(Vec::new()), + }; + let fixture = controller_fixture(TestVerifierMode::Valid, factory); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + let controller = fixture.controller.clone(); + let dispose = tokio::spawn(async move { controller.dispose().await }); + wait_for_counter(&peer.dispose_calls, 1).await; + assert!(!dispose.is_finished()); + dispose_gate.open(); + dispose.await.unwrap().unwrap(); + assert!(peer.disposed.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn target_switch_fences_old_lease_before_new_connect() { + let fixture = controller_fixture(TestVerifierMode::Valid, TestPeerFactory::immediate()); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let first_lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let second_lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + assert_ne!(first_lease, second_lease); + let peers = fixture.factory.peers(); + assert_eq!(peers.len(), 2); + assert!(peers[0].fenced.load(Ordering::Acquire)); + assert!(peers[0].disposed.load(Ordering::Acquire)); + assert_eq!( + fixture + .controller + .runtime_status(&first_lease) + .await + .unwrap_err(), + AgentPortableRemoteError::StaleLease + ); + fixture + .controller + .runtime_status(&second_lease) + .await + .unwrap(); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn account_switch_replaces_handles_and_fences_old_peer() { + let fixture = controller_fixture(TestVerifierMode::Valid, TestPeerFactory::immediate()); + let first_descriptors = fixture.controller.refresh_targets().await.unwrap(); + let first_lease = fixture + .controller + .prepare_target(&first_descriptors[0].handle) + .await + .unwrap(); + let first_peer = fixture.factory.peers()[0].clone(); + + let next_registry = stored_registry(20, 2); + commit_candidate(&fixture.store, fixture.key, 2, next_registry.clone()); + let next_credential = + Arc::new(TestCredential::new(claims_for(&next_registry, fixture.key))); + *fixture.provider.credential.lock().unwrap() = Some(next_credential); + let next_descriptors = fixture.controller.refresh_targets().await.unwrap(); + assert_ne!(first_descriptors[0].handle, next_descriptors[0].handle); + assert!(first_peer.fenced.load(Ordering::Acquire)); + assert!(first_peer.disposed.load(Ordering::Acquire)); + assert!(matches!( + fixture.controller.runtime_status(&first_lease).await, + Err(AgentPortableRemoteError::PeerUnavailable) + | Err(AgentPortableRemoteError::StaleLease) + )); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn native_signout_hook_fences_idle_peer_before_acknowledgement() { + let fixture = controller_fixture(TestVerifierMode::Valid, TestPeerFactory::immediate()); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + fixture.credential.current.store(false, Ordering::Release); + fixture + .controller + .native_credentials_invalidated() + .await + .unwrap(); + assert!(peer.fenced.load(Ordering::Acquire)); + assert!(peer.disposed.load(Ordering::Acquire)); + assert_eq!( + fixture.controller.runtime_status(&lease).await.unwrap_err(), + AgentPortableRemoteError::PeerUnavailable + ); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn revoked_credential_blocks_request_before_peer_call() { + let fixture = controller_fixture(TestVerifierMode::Valid, TestPeerFactory::immediate()); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + fixture.credential.current.store(false, Ordering::Release); + assert_eq!( + fixture.controller.runtime_status(&lease).await.unwrap_err(), + AgentPortableRemoteError::Unauthenticated + ); + assert_eq!(peer.status_calls.load(Ordering::Acquire), 0); + fixture.controller.dispose().await.unwrap(); + } + + #[tokio::test] + async fn newer_authority_snapshot_blocks_request_before_peer_call() { + let fixture = controller_fixture(TestVerifierMode::Valid, TestPeerFactory::immediate()); + let descriptors = fixture.controller.refresh_targets().await.unwrap(); + let lease = fixture + .controller + .prepare_target(&descriptors[0].handle) + .await + .unwrap(); + let peer = fixture.factory.peers()[0].clone(); + let mut next_registry = fixture.registry.clone(); + next_registry.account_context_epoch += 1; + next_registry.authorization_snapshot_revision += 1; + commit_candidate(&fixture.store, fixture.key, 2, next_registry); + assert_eq!( + fixture.controller.runtime_status(&lease).await.unwrap_err(), + AgentPortableRemoteError::StaleLease + ); + assert_eq!(peer.status_calls.load(Ordering::Acquire), 0); + fixture.controller.dispose().await.unwrap(); + } +} diff --git a/frontend/src-tauri/src/agent_remote_portable_tauri.rs b/frontend/src-tauri/src/agent_remote_portable_tauri.rs new file mode 100644 index 000000000..d47062842 --- /dev/null +++ b/frontend/src-tauri/src/agent_remote_portable_tauri.rs @@ -0,0 +1,2061 @@ +//! Mobile-only, fail-closed Tauri boundary for portable Agent reads. +//! +//! This module deliberately has no production dependency installer. The app +//! manages [`AgentPortableTauriState::disabled`] until a native authentication +//! owner, released verifier, secure store, and peer factory land together. +#![allow( + dead_code, + reason = "native lifecycle hooks remain unwired while portable production composition is disabled" +)] + +use std::{ + collections::HashMap, + io::{self, Write}, + sync::{Arc, Mutex}, +}; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use super::{ + issue_opaque_identifier, validate_opaque_identifier, validate_uuid, + AgentPortableRemoteController, AgentPortableRemoteError, PortableFuture, PortableHistoryPage, + PortablePageRequest, PortableRecordsPageRequest, PortableRuntimeStatus, PortableSessionPage, + PortableTargetDescriptor, PortableTargetHandle, PortableTargetLease, MAX_CURRENT_TARGETS, + MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER, MAX_PORTABLE_HISTORY_RECORD_BYTES, +}; + +const PORTABLE_WIRE_SCHEMA_VERSION: u16 = 1; +const MAX_PORTABLE_PAGE_JSON_BYTES: usize = 8 * 1024 * 1024; +const PORTABLE_HISTORY_PAGE_LEDGER_BASE_BYTES: usize = 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(tag = "code", rename_all = "snake_case")] +pub(crate) enum AgentPortableWireError { + Unavailable, + Unauthenticated, + PairingUnavailable, + UnknownTarget, + Busy, + Cancelled, + StaleRuntime, + StaleLease, + InvalidRequest, + InvalidResponse, + PeerUnavailable, + CleanupFailed, +} + +impl From for AgentPortableWireError { + fn from(error: AgentPortableRemoteError) -> Self { + match error { + AgentPortableRemoteError::Unavailable | AgentPortableRemoteError::Internal => { + Self::Unavailable + } + AgentPortableRemoteError::UnsupportedStoredVersion + | AgentPortableRemoteError::CorruptStoredRegistry + | AgentPortableRemoteError::InvalidStoredRegistry + | AgentPortableRemoteError::StoredRegistryRollback + | AgentPortableRemoteError::StoredRegistryInterrupted + | AgentPortableRemoteError::StoredRegistryEquivocation + | AgentPortableRemoteError::DuplicateStoredTarget + | AgentPortableRemoteError::Revoked + | AgentPortableRemoteError::VerificationFailed => Self::PairingUnavailable, + AgentPortableRemoteError::StoredRegistryConflict | AgentPortableRemoteError::Busy => { + Self::Busy + } + AgentPortableRemoteError::Unauthenticated + | AgentPortableRemoteError::AccountMismatch => Self::Unauthenticated, + AgentPortableRemoteError::UnknownTarget => Self::UnknownTarget, + AgentPortableRemoteError::Cancelled => Self::Cancelled, + AgentPortableRemoteError::StaleLease => Self::StaleLease, + AgentPortableRemoteError::InvalidRequest => Self::InvalidRequest, + AgentPortableRemoteError::InvalidResponse => Self::InvalidResponse, + AgentPortableRemoteError::PeerUnavailable => Self::PeerUnavailable, + AgentPortableRemoteError::CleanupFailed => Self::CleanupFailed, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RefreshTargetsRequest { + account_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PrepareTargetRequest { + account_id: String, + runtime_id: String, + target_handle: String, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AgentPortableWireLease { + lease_handle: String, + target_handle: String, + host_epoch: String, + connection_generation: u64, +} + +impl std::fmt::Debug for AgentPortableWireLease { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AgentPortableWireLease") + .field("lease_handle", &"") + .field("target_handle", &"") + .field("host_epoch", &self.host_epoch) + .field("connection_generation", &self.connection_generation) + .finish() + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReadRequest { + account_id: String, + runtime_id: String, + lease: AgentPortableWireLease, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SessionsPageCommandRequest { + account_id: String, + runtime_id: String, + lease: AgentPortableWireLease, + page: PortablePageRequest, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RecordsPageCommandRequest { + account_id: String, + runtime_id: String, + lease: AgentPortableWireLease, + page: PortableRecordsPageRequest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AgentPortableReadCapabilities { + runtime_status: bool, + session_summaries_page: bool, + persisted_records_page: bool, + synchronized_live_tail: bool, + mutations: bool, +} + +impl AgentPortableReadCapabilities { + const READ_ONLY: Self = Self { + runtime_status: true, + session_summaries_page: true, + persisted_records_page: true, + synchronized_live_tail: false, + mutations: false, + }; +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RefreshTargetsResponse { + schema_version: u16, + runtime_id: String, + capabilities: AgentPortableReadCapabilities, + items: Vec, +} + +impl std::fmt::Debug for RefreshTargetsResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RefreshTargetsResponse") + .field("schema_version", &self.schema_version) + .field("runtime_id", &"") + .field("capabilities", &self.capabilities) + .field("target_count", &self.items.len()) + .finish() + } +} + +trait PortableControllerApi: Send + Sync { + fn refresh_targets( + &self, + expected_account_id: String, + ) -> PortableFuture<'_, Result, AgentPortableRemoteError>>; + + fn prepare_target( + &self, + handle: PortableTargetHandle, + ) -> PortableFuture<'_, Result>; + + fn runtime_status( + &self, + lease: PortableTargetLease, + ) -> PortableFuture<'_, Result>; + + fn sessions_page( + &self, + lease: PortableTargetLease, + request: PortablePageRequest, + ) -> PortableFuture<'_, Result>; + + fn records_page( + &self, + lease: PortableTargetLease, + request: PortableRecordsPageRequest, + ) -> PortableFuture<'_, Result>; + + fn network_changed( + &self, + lease: PortableTargetLease, + ) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>>; + + fn native_credentials_invalidated( + &self, + ) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>>; + + fn dispose(&self) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>>; +} + +impl PortableControllerApi for AgentPortableRemoteController { + fn refresh_targets( + &self, + expected_account_id: String, + ) -> PortableFuture<'_, Result, AgentPortableRemoteError>> { + Box::pin(async move { self.refresh_targets_for_account(&expected_account_id).await }) + } + + fn prepare_target( + &self, + handle: PortableTargetHandle, + ) -> PortableFuture<'_, Result> { + Box::pin(async move { self.prepare_target(&handle).await }) + } + + fn runtime_status( + &self, + lease: PortableTargetLease, + ) -> PortableFuture<'_, Result> { + Box::pin(async move { self.runtime_status(&lease).await }) + } + + fn sessions_page( + &self, + lease: PortableTargetLease, + request: PortablePageRequest, + ) -> PortableFuture<'_, Result> { + Box::pin(async move { self.sessions_page(&lease, request).await }) + } + + fn records_page( + &self, + lease: PortableTargetLease, + request: PortableRecordsPageRequest, + ) -> PortableFuture<'_, Result> { + Box::pin(async move { self.records_page(&lease, request).await }) + } + + fn network_changed( + &self, + lease: PortableTargetLease, + ) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>> { + Box::pin(async move { self.network_changed(&lease).await }) + } + + fn native_credentials_invalidated( + &self, + ) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>> { + Box::pin(async move { self.native_credentials_invalidated().await }) + } + + fn dispose(&self) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>> { + Box::pin(async move { self.dispose().await }) + } +} + +struct RuntimeBinding { + account_id: String, + runtime_id: String, + targets: HashMap, + lease: Option, +} + +struct LeaseBinding { + wire: AgentPortableWireLease, + native: PortableTargetLease, +} + +#[derive(Default)] +struct AgentPortableTauriInner { + fence_epoch: u64, + runtime: Option, +} + +/// Mobile-managed portable command state. Production construction is inert. +pub(crate) struct AgentPortableTauriState { + controller: Option>, + inner: Mutex, +} + +impl std::fmt::Debug for AgentPortableTauriState { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let inner = self.inner.lock().map_err(|_| std::fmt::Error)?; + formatter + .debug_struct("AgentPortableTauriState") + .field("enabled", &self.controller.is_some()) + .field("fence_epoch", &inner.fence_epoch) + .field("has_runtime", &inner.runtime.is_some()) + .field( + "has_lease", + &inner + .runtime + .as_ref() + .is_some_and(|runtime| runtime.lease.is_some()), + ) + .finish() + } +} + +impl AgentPortableTauriState { + /// The sole production constructor in this slice. No provider, verifier, + /// credential, store, or network factory is installed implicitly. + pub(crate) fn disabled() -> Self { + Self { + controller: None, + inner: Mutex::new(AgentPortableTauriInner::default()), + } + } + + #[cfg(test)] + fn with_controller(controller: Arc) -> Self { + Self { + controller: Some(controller), + inner: Mutex::new(AgentPortableTauriInner::default()), + } + } + + fn controller(&self) -> Result, AgentPortableWireError> { + self.controller + .clone() + .ok_or(AgentPortableWireError::Unavailable) + } + + async fn refresh_targets( + &self, + request: RefreshTargetsRequest, + ) -> Result { + validate_account_id(&request.account_id)?; + let controller = self.controller()?; + let fence_epoch = { + let mut inner = self.lock_inner()?; + if inner + .runtime + .as_ref() + .is_some_and(|runtime| runtime.account_id != request.account_id) + { + return Err(AgentPortableWireError::StaleRuntime); + } + inner.fence_epoch = next_epoch(inner.fence_epoch)?; + inner.runtime = None; + inner.fence_epoch + }; + + let targets = controller + .refresh_targets(request.account_id.clone()) + .await + .map_err(AgentPortableWireError::from)?; + if targets.len() > MAX_CURRENT_TARGETS { + return self + .retire_invalid_response(controller, fence_epoch, None) + .await; + } + let mut target_map = HashMap::with_capacity(targets.len()); + for descriptor in &targets { + if descriptor.validate().is_err() { + return self + .retire_invalid_response(controller, fence_epoch, None) + .await; + } + let handle = descriptor.handle.0.clone(); + if target_map + .insert(handle, descriptor.handle.clone()) + .is_some() + { + return self + .retire_invalid_response(controller, fence_epoch, None) + .await; + } + } + let runtime_id = match issue_opaque_identifier("runtime") { + Ok(runtime_id) => runtime_id, + Err(error) => { + return self + .finish_failed_operation(controller, fence_epoch, None, error) + .await; + } + }; + let response = RefreshTargetsResponse { + schema_version: PORTABLE_WIRE_SCHEMA_VERSION, + runtime_id: runtime_id.clone(), + capabilities: AgentPortableReadCapabilities::READ_ONLY, + items: targets, + }; + let mut inner = self.lock_inner()?; + if inner.fence_epoch != fence_epoch || inner.runtime.is_some() { + return Err(AgentPortableWireError::Cancelled); + } + inner.runtime = Some(RuntimeBinding { + account_id: request.account_id, + runtime_id, + targets: target_map, + lease: None, + }); + Ok(response) + } + + async fn prepare_target( + &self, + request: PrepareTargetRequest, + ) -> Result { + validate_account_id(&request.account_id)?; + validate_runtime_id(&request.runtime_id)?; + validate_target_handle(&request.target_handle)?; + let controller = self.controller()?; + let (fence_epoch, native_handle) = { + let mut inner = self.lock_inner()?; + let runtime = + require_runtime_mut(&mut inner, &request.account_id, &request.runtime_id)?; + let handle = runtime + .targets + .get(&request.target_handle) + .cloned() + .ok_or(AgentPortableWireError::UnknownTarget)?; + runtime.lease = None; + inner.fence_epoch = next_epoch(inner.fence_epoch)?; + (inner.fence_epoch, handle) + }; + + let native_lease = match controller.prepare_target(native_handle).await { + Ok(lease) => lease, + Err(error) => { + return self + .finish_failed_operation( + controller, + fence_epoch, + Some((&request.account_id, &request.runtime_id, None)), + error, + ) + .await; + } + }; + if native_lease.validate().is_err() { + return self + .retire_invalid_response( + controller, + fence_epoch, + Some((&request.account_id, &request.runtime_id, None)), + ) + .await; + } + let wire = AgentPortableWireLease { + lease_handle: native_lease.target_id.clone(), + target_handle: request.target_handle, + host_epoch: native_lease.host_epoch.to_string(), + connection_generation: native_lease.connection_generation, + }; + if validate_wire_lease(&wire).is_err() { + return self + .retire_invalid_response( + controller, + fence_epoch, + Some((&request.account_id, &request.runtime_id, None)), + ) + .await; + } + let mut inner = self.lock_inner()?; + if inner.fence_epoch != fence_epoch { + return Err(AgentPortableWireError::Cancelled); + } + let runtime = require_runtime_mut(&mut inner, &request.account_id, &request.runtime_id)?; + runtime.lease = Some(LeaseBinding { + wire: wire.clone(), + native: native_lease, + }); + Ok(wire) + } + + async fn runtime_status( + &self, + request: ReadRequest, + ) -> Result { + let (controller, fence_epoch, native_lease) = + self.begin_read(&request.account_id, &request.runtime_id, &request.lease)?; + let response = match controller.runtime_status(native_lease).await { + Ok(response) => response, + Err(error) => { + return self + .finish_failed_operation( + controller, + fence_epoch, + Some(( + &request.account_id, + &request.runtime_id, + Some(&request.lease), + )), + error, + ) + .await; + } + }; + if response.validate().is_err() { + return self + .retire_invalid_response( + controller, + fence_epoch, + Some(( + &request.account_id, + &request.runtime_id, + Some(&request.lease), + )), + ) + .await; + } + self.require_read_current( + fence_epoch, + &request.account_id, + &request.runtime_id, + &request.lease, + )?; + Ok(response) + } + + async fn sessions_page( + &self, + request: SessionsPageCommandRequest, + ) -> Result { + request + .page + .validate() + .map_err(|_| AgentPortableWireError::InvalidRequest)?; + let (controller, fence_epoch, native_lease) = + self.begin_read(&request.account_id, &request.runtime_id, &request.lease)?; + let response = match controller + .sessions_page(native_lease, request.page.clone()) + .await + { + Ok(response) => response, + Err(error) => { + return self + .finish_failed_operation( + controller, + fence_epoch, + Some(( + &request.account_id, + &request.runtime_id, + Some(&request.lease), + )), + error, + ) + .await; + } + }; + if response.validate_for(&request.page).is_err() + || serialized_size_within_limit(&response, MAX_PORTABLE_PAGE_JSON_BYTES).is_err() + { + return self + .retire_invalid_response( + controller, + fence_epoch, + Some(( + &request.account_id, + &request.runtime_id, + Some(&request.lease), + )), + ) + .await; + } + self.require_read_current( + fence_epoch, + &request.account_id, + &request.runtime_id, + &request.lease, + )?; + Ok(response) + } + + async fn records_page( + &self, + request: RecordsPageCommandRequest, + ) -> Result { + request + .page + .validate() + .map_err(|_| AgentPortableWireError::InvalidRequest)?; + let (controller, fence_epoch, native_lease) = + self.begin_read(&request.account_id, &request.runtime_id, &request.lease)?; + let response = match controller + .records_page(native_lease, request.page.clone()) + .await + { + Ok(response) => response, + Err(error) => { + return self + .finish_failed_operation( + controller, + fence_epoch, + Some(( + &request.account_id, + &request.runtime_id, + Some(&request.lease), + )), + error, + ) + .await; + } + }; + if response.validate_for(&request.page).is_err() + || serialized_history_page_within_limits(&response).is_err() + { + return self + .retire_invalid_response( + controller, + fence_epoch, + Some(( + &request.account_id, + &request.runtime_id, + Some(&request.lease), + )), + ) + .await; + } + self.require_read_current( + fence_epoch, + &request.account_id, + &request.runtime_id, + &request.lease, + )?; + Ok(response) + } + + /// Native-only network hint. This is intentionally not a Tauri command. + pub(crate) async fn native_network_changed(&self) -> Result<(), AgentPortableWireError> { + let controller = self.controller()?; + let (fence_epoch, account_id, runtime_id, wire, native) = { + let inner = self.lock_inner()?; + let runtime = inner + .runtime + .as_ref() + .ok_or(AgentPortableWireError::StaleRuntime)?; + let lease = runtime + .lease + .as_ref() + .ok_or(AgentPortableWireError::StaleLease)?; + ( + inner.fence_epoch, + runtime.account_id.clone(), + runtime.runtime_id.clone(), + lease.wire.clone(), + lease.native.clone(), + ) + }; + match controller.network_changed(native).await { + Ok(()) => { + self.require_read_current(fence_epoch, &account_id, &runtime_id, &wire)?; + Ok(()) + } + Err(error) => { + self.finish_failed_operation( + controller, + fence_epoch, + Some((&account_id, &runtime_id, Some(&wire))), + error, + ) + .await + } + } + } + + /// Native auth owner hook. The mapping is cleared before cleanup starts. + pub(crate) async fn native_credentials_invalidated( + &self, + ) -> Result<(), AgentPortableWireError> { + let Some(controller) = self.controller.clone() else { + return Ok(()); + }; + self.clear_all()?; + controller + .native_credentials_invalidated() + .await + .map_err(AgentPortableWireError::from) + } + + /// Native lifecycle hook. The mapping is cleared before cleanup starts. + pub(crate) async fn native_dispose(&self) -> Result<(), AgentPortableWireError> { + let Some(controller) = self.controller.clone() else { + return Ok(()); + }; + self.clear_all()?; + controller + .dispose() + .await + .map_err(AgentPortableWireError::from) + } + + fn begin_read( + &self, + account_id: &str, + runtime_id: &str, + lease: &AgentPortableWireLease, + ) -> Result { + validate_account_id(account_id)?; + validate_runtime_id(runtime_id)?; + validate_wire_lease(lease)?; + let controller = self.controller()?; + let inner = self.lock_inner()?; + let runtime = require_runtime(&inner, account_id, runtime_id)?; + let binding = runtime + .lease + .as_ref() + .ok_or(AgentPortableWireError::StaleLease)?; + if binding.wire != *lease { + return Err(AgentPortableWireError::StaleLease); + } + Ok((controller, inner.fence_epoch, binding.native.clone())) + } + + fn require_read_current( + &self, + fence_epoch: u64, + account_id: &str, + runtime_id: &str, + lease: &AgentPortableWireLease, + ) -> Result<(), AgentPortableWireError> { + let inner = self.lock_inner()?; + let runtime = require_runtime(&inner, account_id, runtime_id)?; + if inner.fence_epoch != fence_epoch { + return Err(AgentPortableWireError::StaleLease); + } + if runtime + .lease + .as_ref() + .is_none_or(|binding| binding.wire != *lease) + { + return Err(AgentPortableWireError::StaleLease); + } + Ok(()) + } + + async fn finish_failed_operation( + &self, + controller: Arc, + fence_epoch: u64, + binding: BindingExpectation<'_>, + error: AgentPortableRemoteError, + ) -> Result { + if should_retire_after(error) + && self.clear_matching(fence_epoch, binding)? + && controller.dispose().await.is_err() + { + return Err(AgentPortableWireError::CleanupFailed); + } + Err(error.into()) + } + + async fn retire_invalid_response( + &self, + controller: Arc, + fence_epoch: u64, + binding: BindingExpectation<'_>, + ) -> Result { + if self.clear_matching(fence_epoch, binding)? && controller.dispose().await.is_err() { + return Err(AgentPortableWireError::CleanupFailed); + } + Err(AgentPortableWireError::InvalidResponse) + } + + fn clear_matching( + &self, + fence_epoch: u64, + binding: BindingExpectation<'_>, + ) -> Result { + let mut inner = self.lock_inner()?; + if inner.fence_epoch != fence_epoch { + return Ok(false); + } + if let Some((account_id, runtime_id, expected_lease)) = binding { + let Some(runtime) = inner.runtime.as_ref() else { + return Ok(false); + }; + if runtime.account_id != account_id || runtime.runtime_id != runtime_id { + return Ok(false); + } + if expected_lease.is_some_and(|expected| { + runtime + .lease + .as_ref() + .is_none_or(|lease| lease.wire != *expected) + }) { + return Ok(false); + } + } + inner.fence_epoch = next_epoch(inner.fence_epoch)?; + inner.runtime = None; + Ok(true) + } + + fn clear_all(&self) -> Result<(), AgentPortableWireError> { + let mut inner = self.lock_inner()?; + inner.fence_epoch = next_epoch(inner.fence_epoch)?; + inner.runtime = None; + Ok(()) + } + + fn lock_inner( + &self, + ) -> Result, AgentPortableWireError> { + self.inner + .lock() + .map_err(|_| AgentPortableWireError::Unavailable) + } +} + +type ReadContext = (Arc, u64, PortableTargetLease); +type BindingExpectation<'a> = Option<(&'a str, &'a str, Option<&'a AgentPortableWireLease>)>; + +fn require_runtime<'a>( + inner: &'a AgentPortableTauriInner, + account_id: &str, + runtime_id: &str, +) -> Result<&'a RuntimeBinding, AgentPortableWireError> { + let runtime = inner + .runtime + .as_ref() + .ok_or(AgentPortableWireError::StaleRuntime)?; + if runtime.account_id != account_id || runtime.runtime_id != runtime_id { + return Err(AgentPortableWireError::StaleRuntime); + } + Ok(runtime) +} + +fn require_runtime_mut<'a>( + inner: &'a mut AgentPortableTauriInner, + account_id: &str, + runtime_id: &str, +) -> Result<&'a mut RuntimeBinding, AgentPortableWireError> { + let runtime = inner + .runtime + .as_mut() + .ok_or(AgentPortableWireError::StaleRuntime)?; + if runtime.account_id != account_id || runtime.runtime_id != runtime_id { + return Err(AgentPortableWireError::StaleRuntime); + } + Ok(runtime) +} + +fn validate_account_id(value: &str) -> Result<(), AgentPortableWireError> { + validate_uuid("portable account", value).map_err(|_| AgentPortableWireError::InvalidRequest) +} + +fn validate_runtime_id(value: &str) -> Result<(), AgentPortableWireError> { + validate_opaque_identifier(value, "runtime").map_err(|_| AgentPortableWireError::InvalidRequest) +} + +fn validate_target_handle(value: &str) -> Result<(), AgentPortableWireError> { + validate_opaque_identifier(value, "target").map_err(|_| AgentPortableWireError::InvalidRequest) +} + +fn validate_wire_lease(lease: &AgentPortableWireLease) -> Result<(), AgentPortableWireError> { + validate_opaque_identifier(&lease.lease_handle, "lease") + .map_err(|_| AgentPortableWireError::InvalidRequest)?; + validate_target_handle(&lease.target_handle)?; + let host_epoch = lease + .host_epoch + .parse::() + .map_err(|_| AgentPortableWireError::InvalidRequest)?; + if host_epoch == 0 + || host_epoch.to_string() != lease.host_epoch + || lease.connection_generation == 0 + || lease.connection_generation > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + { + return Err(AgentPortableWireError::InvalidRequest); + } + Ok(()) +} + +fn next_epoch(current: u64) -> Result { + current + .checked_add(1) + .ok_or(AgentPortableWireError::Unavailable) +} + +fn should_retire_after(error: AgentPortableRemoteError) -> bool { + matches!( + error, + AgentPortableRemoteError::Unavailable + | AgentPortableRemoteError::UnsupportedStoredVersion + | AgentPortableRemoteError::CorruptStoredRegistry + | AgentPortableRemoteError::InvalidStoredRegistry + | AgentPortableRemoteError::StoredRegistryRollback + | AgentPortableRemoteError::StoredRegistryInterrupted + | AgentPortableRemoteError::StoredRegistryEquivocation + | AgentPortableRemoteError::StoredRegistryConflict + | AgentPortableRemoteError::DuplicateStoredTarget + | AgentPortableRemoteError::Unauthenticated + | AgentPortableRemoteError::AccountMismatch + | AgentPortableRemoteError::Revoked + | AgentPortableRemoteError::VerificationFailed + | AgentPortableRemoteError::UnknownTarget + | AgentPortableRemoteError::StaleLease + | AgentPortableRemoteError::InvalidResponse + | AgentPortableRemoteError::PeerUnavailable + | AgentPortableRemoteError::CleanupFailed + | AgentPortableRemoteError::Internal + ) +} + +fn decode_request( + request: Option, +) -> Result { + request + .ok_or(AgentPortableWireError::InvalidRequest) + .and_then(|request| { + serde_json::from_value(request).map_err(|_| AgentPortableWireError::InvalidRequest) + }) +} + +struct BoundedJsonWriter { + written: usize, + limit: usize, +} + +impl Write for BoundedJsonWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let next = self + .written + .checked_add(bytes.len()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "portable page too large"))?; + if next > self.limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "portable page too large", + )); + } + self.written = next; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn serialized_size_within_limit( + value: &T, + limit: usize, +) -> Result { + let mut writer = BoundedJsonWriter { written: 0, limit }; + serde_json::to_writer(&mut writer, value) + .map_err(|_| AgentPortableWireError::InvalidResponse)?; + Ok(writer.written) +} + +fn serialized_history_page_within_limits( + page: &PortableHistoryPage, +) -> Result { + conservative_history_page_ledger_within_limit(page, MAX_PORTABLE_PAGE_JSON_BYTES)?; + serialized_size_within_limit(page, MAX_PORTABLE_PAGE_JSON_BYTES) +} + +fn conservative_history_page_ledger_within_limit( + page: &PortableHistoryPage, + limit: usize, +) -> Result { + let mut total = PORTABLE_HISTORY_PAGE_LEDGER_BASE_BYTES + .checked_add(page.history_revision.len()) + .and_then(|total| total.checked_add(page.next_cursor.as_deref().unwrap_or("").len())) + .ok_or(AgentPortableWireError::InvalidResponse)?; + if total > limit { + return Err(AgentPortableWireError::InvalidResponse); + } + for record in &page.items { + let record_bytes = serialized_size_within_limit(record, MAX_PORTABLE_HISTORY_RECORD_BYTES)?; + total = total + .checked_add(record_bytes) + .and_then(|total| total.checked_add(1)) + .ok_or(AgentPortableWireError::InvalidResponse)?; + if total > limit { + return Err(AgentPortableWireError::InvalidResponse); + } + } + Ok(total) +} + +#[tauri::command] +pub(crate) async fn agent_portable_refresh_targets( + state: tauri::State<'_, AgentPortableTauriState>, + request: Option, +) -> Result { + state.refresh_targets(decode_request(request)?).await +} + +#[tauri::command] +pub(crate) async fn agent_portable_prepare_target( + state: tauri::State<'_, AgentPortableTauriState>, + request: Option, +) -> Result { + state.prepare_target(decode_request(request)?).await +} + +#[tauri::command] +pub(crate) async fn agent_portable_get_runtime_status( + state: tauri::State<'_, AgentPortableTauriState>, + request: Option, +) -> Result { + state.runtime_status(decode_request(request)?).await +} + +#[tauri::command] +pub(crate) async fn agent_portable_list_sessions_page( + state: tauri::State<'_, AgentPortableTauriState>, + request: Option, +) -> Result { + state.sessions_page(decode_request(request)?).await +} + +#[tauri::command] +pub(crate) async fn agent_portable_list_records_page( + state: tauri::State<'_, AgentPortableTauriState>, + request: Option, +) -> Result { + state.records_page(decode_request(request)?).await +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + + use serde_json::{json, Value}; + use tokio::sync::Notify; + + use super::*; + use crate::agent_remote_portable::{ + PortableHistoryRecord, PortableSessionSummary, PortableTimelineItem, + }; + + const ACCOUNT_ID: &str = "00000000-0000-4000-8000-000000000001"; + + #[derive(Default)] + struct TestGate { + open: AtomicBool, + notify: Notify, + } + + impl TestGate { + fn open(&self) { + self.open.store(true, Ordering::Release); + self.notify.notify_waiters(); + } + + async fn wait(&self) { + loop { + let notified = self.notify.notified(); + if self.open.load(Ordering::Acquire) { + return; + } + notified.await; + } + } + } + + struct TestController { + targets: Mutex, AgentPortableRemoteError>>, + prepared_lease: PortableTargetLease, + status: Mutex>, + sessions: Mutex>, + records: Mutex>, + prepare_gate: Mutex>>, + status_gate: Mutex>>, + dispose_gate: Mutex>>, + refresh_calls: AtomicU64, + prepare_calls: AtomicU64, + status_calls: AtomicU64, + sessions_calls: AtomicU64, + records_calls: AtomicU64, + network_calls: AtomicU64, + invalidation_calls: AtomicU64, + dispose_calls: AtomicU64, + } + + impl TestController { + fn valid() -> Self { + Self { + targets: Mutex::new(Ok(vec![PortableTargetDescriptor { + handle: PortableTargetHandle(opaque("target", '1')), + label: "Paired Mac".to_string(), + }])), + prepared_lease: PortableTargetLease { + target_id: opaque("lease", '2'), + host_epoch: 7, + connection_generation: 9, + }, + status: Mutex::new(Ok(PortableRuntimeStatus { + running: true, + active_run_count: 1, + })), + sessions: Mutex::new(Ok(sample_sessions_page())), + records: Mutex::new(Ok(sample_records_page())), + prepare_gate: Mutex::new(None), + status_gate: Mutex::new(None), + dispose_gate: Mutex::new(None), + refresh_calls: AtomicU64::new(0), + prepare_calls: AtomicU64::new(0), + status_calls: AtomicU64::new(0), + sessions_calls: AtomicU64::new(0), + records_calls: AtomicU64::new(0), + network_calls: AtomicU64::new(0), + invalidation_calls: AtomicU64::new(0), + dispose_calls: AtomicU64::new(0), + } + } + } + + impl PortableControllerApi for TestController { + fn refresh_targets( + &self, + _expected_account_id: String, + ) -> PortableFuture<'_, Result, AgentPortableRemoteError>> + { + self.refresh_calls.fetch_add(1, Ordering::AcqRel); + let result = self.targets.lock().unwrap().clone(); + Box::pin(async move { result }) + } + + fn prepare_target( + &self, + _handle: PortableTargetHandle, + ) -> PortableFuture<'_, Result> { + self.prepare_calls.fetch_add(1, Ordering::AcqRel); + let result = self.prepared_lease.clone(); + let gate = self.prepare_gate.lock().unwrap().clone(); + Box::pin(async move { + if let Some(gate) = gate { + gate.wait().await; + } + Ok(result) + }) + } + + fn runtime_status( + &self, + _lease: PortableTargetLease, + ) -> PortableFuture<'_, Result> { + self.status_calls.fetch_add(1, Ordering::AcqRel); + let result = self.status.lock().unwrap().clone(); + let gate = self.status_gate.lock().unwrap().clone(); + Box::pin(async move { + if let Some(gate) = gate { + gate.wait().await; + } + result + }) + } + + fn sessions_page( + &self, + _lease: PortableTargetLease, + _request: PortablePageRequest, + ) -> PortableFuture<'_, Result> { + self.sessions_calls.fetch_add(1, Ordering::AcqRel); + let result = self.sessions.lock().unwrap().clone(); + Box::pin(async move { result }) + } + + fn records_page( + &self, + _lease: PortableTargetLease, + _request: PortableRecordsPageRequest, + ) -> PortableFuture<'_, Result> { + self.records_calls.fetch_add(1, Ordering::AcqRel); + let result = self.records.lock().unwrap().clone(); + Box::pin(async move { result }) + } + + fn network_changed( + &self, + _lease: PortableTargetLease, + ) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>> { + self.network_calls.fetch_add(1, Ordering::AcqRel); + Box::pin(async { Ok(()) }) + } + + fn native_credentials_invalidated( + &self, + ) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>> { + self.invalidation_calls.fetch_add(1, Ordering::AcqRel); + Box::pin(async { Ok(()) }) + } + + fn dispose(&self) -> PortableFuture<'_, Result<(), AgentPortableRemoteError>> { + self.dispose_calls.fetch_add(1, Ordering::AcqRel); + let gate = self.dispose_gate.lock().unwrap().clone(); + Box::pin(async move { + if let Some(gate) = gate { + gate.wait().await; + } + Ok(()) + }) + } + } + + fn opaque(prefix: &str, digit: char) -> String { + format!("{prefix}_{}", digit.to_string().repeat(48)) + } + + fn sample_sessions_page() -> PortableSessionPage { + PortableSessionPage { + items: vec![PortableSessionSummary { + id: "session-1".to_string(), + title: "Portable session".to_string(), + created_ms: 1, + updated_ms: 2, + page_sort_ms: 2, + message_count: 3, + }], + next_cursor: Some("sessions:next".to_string()), + } + } + + fn sample_timeline_item(text: String) -> PortableTimelineItem { + PortableTimelineItem { + id: "item-1".to_string(), + item_type: "message".to_string(), + role: Some("user".to_string()), + title: Some("Message".to_string()), + text: Some(text), + status: Some("completed".to_string()), + created_ms: 3, + merge: "append".to_string(), + } + } + + fn sample_records_page() -> PortableHistoryPage { + PortableHistoryPage { + items: vec![PortableHistoryRecord { + record_id: "record:1".to_string(), + role: "user".to_string(), + created_ms: 3, + items: vec![sample_timeline_item("hello".to_string())], + }], + history_revision: "history:1".to_string(), + next_cursor: Some("records:next".to_string()), + } + } + + fn escape_heavy_record_with_json_size(target_bytes: usize) -> PortableHistoryRecord { + escape_heavy_record_with_json_size_and_suffix(target_bytes, "escape-heavy") + } + + fn escape_heavy_record_with_json_size_and_suffix( + target_bytes: usize, + suffix: &str, + ) -> PortableHistoryRecord { + const ITEM_COUNT: usize = 4; + const MAX_NEWLINES_PER_ITEM: usize = 150_000; + + let mut record = PortableHistoryRecord { + record_id: format!("record:{suffix}"), + role: "assistant".to_string(), + created_ms: 3, + items: (0..ITEM_COUNT) + .map(|index| PortableTimelineItem { + id: format!("item-{suffix}-{index}"), + item_type: "message".to_string(), + role: Some("assistant".to_string()), + title: None, + text: Some(String::new()), + status: None, + created_ms: 3, + merge: "append".to_string(), + }) + .collect(), + }; + let base_bytes = serialized_size_within_limit(&record, usize::MAX).unwrap(); + assert!(base_bytes <= target_bytes); + let mut remaining = target_bytes - base_bytes; + for item in &mut record.items { + let newline_count = (remaining / 2).min(MAX_NEWLINES_PER_ITEM); + item.text + .as_mut() + .unwrap() + .push_str(&"\n".repeat(newline_count)); + remaining -= newline_count * 2; + } + if remaining == 1 { + record + .items + .last_mut() + .unwrap() + .text + .as_mut() + .unwrap() + .push('x'); + remaining = 0; + } + assert_eq!(remaining, 0); + assert_eq!( + serialized_size_within_limit(&record, usize::MAX).unwrap(), + target_bytes + ); + record + } + + fn history_page_at_conservative_ledger_limit() -> PortableHistoryPage { + const FIXED_RECORDS: usize = 8; + const FIXED_RECORD_JSON_BYTES: usize = 930_000; + + let history_revision = "history:ledger-boundary".to_string(); + let next_cursor = Some("records:ledger-next".to_string()); + let metadata_bytes = PORTABLE_HISTORY_PAGE_LEDGER_BASE_BYTES + + history_revision.len() + + next_cursor.as_deref().unwrap().len(); + let final_record_json_bytes = MAX_PORTABLE_PAGE_JSON_BYTES + - metadata_bytes + - FIXED_RECORDS * (FIXED_RECORD_JSON_BYTES + 1) + - 1; + assert!(final_record_json_bytes <= MAX_PORTABLE_HISTORY_RECORD_BYTES); + + let mut items = (0..FIXED_RECORDS) + .map(|index| { + escape_heavy_record_with_json_size_and_suffix( + FIXED_RECORD_JSON_BYTES, + &format!("ledger-{index}"), + ) + }) + .collect::>(); + items.push(escape_heavy_record_with_json_size_and_suffix( + final_record_json_bytes, + "ledger-final", + )); + PortableHistoryPage { + items, + history_revision, + next_cursor, + } + } + + fn increment_last_record_json_byte(page: &mut PortableHistoryPage) { + page.items + .last_mut() + .unwrap() + .items + .last_mut() + .unwrap() + .text + .as_mut() + .unwrap() + .push('x'); + } + + fn refresh_request() -> RefreshTargetsRequest { + RefreshTargetsRequest { + account_id: ACCOUNT_ID.to_string(), + } + } + + async fn bootstrap( + state: &AgentPortableTauriState, + ) -> (RefreshTargetsResponse, AgentPortableWireLease) { + let refresh = state.refresh_targets(refresh_request()).await.unwrap(); + let lease = state + .prepare_target(PrepareTargetRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id.clone(), + target_handle: refresh.items[0].handle.0.clone(), + }) + .await + .unwrap(); + (refresh, lease) + } + + async fn wait_for_counter(counter: &AtomicU64, expected: u64) { + for _ in 0..10_000 { + if counter.load(Ordering::Acquire) >= expected { + return; + } + tokio::task::yield_now().await; + } + panic!("counter did not reach {expected}"); + } + + fn serialized_keys(value: &impl Serialize) -> Vec { + let mut keys = serde_json::to_value(value) + .unwrap() + .as_object() + .unwrap() + .keys() + .cloned() + .collect::>(); + keys.sort(); + keys + } + + #[test] + fn command_roster_is_mobile_only_and_exactly_five() { + let source = include_str!("lib.rs"); + for command in [ + "agent_remote_portable::tauri::agent_portable_refresh_targets", + "agent_remote_portable::tauri::agent_portable_prepare_target", + "agent_remote_portable::tauri::agent_portable_get_runtime_status", + "agent_remote_portable::tauri::agent_portable_list_sessions_page", + "agent_remote_portable::tauri::agent_portable_list_records_page", + ] { + assert_eq!(source.matches(command).count(), 2, "{command}"); + } + for forbidden in [ + "agent_remote_portable::tauri::agent_portable_network_changed", + "agent_remote_portable::tauri::agent_portable_release", + "agent_remote_portable::tauri::agent_portable_dispose", + "agent_remote_portable::tauri::agent_portable_native_credentials_invalidated", + ] { + assert_eq!(source.matches(forbidden).count(), 0, "{forbidden}"); + } + + let desktop = source + .split("// Mobile (iOS and Android) configuration") + .next() + .unwrap(); + assert!(!desktop.contains("agent_remote_portable::tauri::agent_portable_")); + } + + #[tokio::test] + async fn disabled_state_fails_before_any_runtime_or_controller_activity() { + let state = AgentPortableTauriState::disabled(); + assert!(state.controller.is_none()); + assert_eq!( + state.refresh_targets(refresh_request()).await.unwrap_err(), + AgentPortableWireError::Unavailable + ); + let inner = state.inner.lock().unwrap(); + assert_eq!(inner.fence_epoch, 0); + assert!(inner.runtime.is_none()); + } + + #[test] + fn request_decoding_and_error_wire_are_closed() { + assert_eq!( + decode_request::(Some(json!({ + "accountId": ACCOUNT_ID, + "unexpected": true, + }))) + .err() + .unwrap(), + AgentPortableWireError::InvalidRequest + ); + assert_eq!( + decode_request::(None).err().unwrap(), + AgentPortableWireError::InvalidRequest + ); + let noncanonical_epoch = AgentPortableWireLease { + lease_handle: opaque("lease", '1'), + target_handle: opaque("target", '2'), + host_epoch: "07".to_string(), + connection_generation: 1, + }; + assert_eq!( + validate_wire_lease(&noncanonical_epoch).unwrap_err(), + AgentPortableWireError::InvalidRequest + ); + let full_epoch = AgentPortableWireLease { + lease_handle: opaque("lease", '1'), + target_handle: opaque("target", '2'), + host_epoch: u64::MAX.to_string(), + connection_generation: 1, + }; + validate_wire_lease(&full_epoch).unwrap(); + let full_epoch_json = serde_json::to_value(&full_epoch).unwrap(); + assert_eq!(full_epoch_json["hostEpoch"], json!(u64::MAX.to_string())); + assert!(full_epoch_json["hostEpoch"].is_string()); + assert!(full_epoch_json["connectionGeneration"].is_number()); + + let mut overflow_epoch = full_epoch; + overflow_epoch.host_epoch = "18446744073709551616".to_string(); + assert_eq!( + validate_wire_lease(&overflow_epoch).unwrap_err(), + AgentPortableWireError::InvalidRequest + ); + assert_eq!( + decode_request::(Some(json!({ + "accountId": ACCOUNT_ID, + "runtimeId": opaque("runtime", '3'), + "lease": { + "leaseHandle": opaque("lease", '1'), + "targetHandle": opaque("target", '2'), + "hostEpoch": "7", + "connectionGeneration": 1, + "unexpected": true, + }, + }))) + .err() + .unwrap(), + AgentPortableWireError::InvalidRequest + ); + + let errors = [ + (AgentPortableWireError::Unavailable, "unavailable"), + (AgentPortableWireError::Unauthenticated, "unauthenticated"), + ( + AgentPortableWireError::PairingUnavailable, + "pairing_unavailable", + ), + (AgentPortableWireError::UnknownTarget, "unknown_target"), + (AgentPortableWireError::Busy, "busy"), + (AgentPortableWireError::Cancelled, "cancelled"), + (AgentPortableWireError::StaleRuntime, "stale_runtime"), + (AgentPortableWireError::StaleLease, "stale_lease"), + (AgentPortableWireError::InvalidRequest, "invalid_request"), + (AgentPortableWireError::InvalidResponse, "invalid_response"), + (AgentPortableWireError::PeerUnavailable, "peer_unavailable"), + (AgentPortableWireError::CleanupFailed, "cleanup_failed"), + ]; + for (error, code) in errors { + assert_eq!( + serde_json::to_value(error).unwrap(), + json!({ "code": code }) + ); + } + } + + #[test] + fn serialized_page_budget_is_inclusive_and_exact() { + let exact_payload = "x".repeat(MAX_PORTABLE_PAGE_JSON_BYTES - 2); + assert_eq!( + serialized_size_within_limit(&exact_payload, MAX_PORTABLE_PAGE_JSON_BYTES).unwrap(), + MAX_PORTABLE_PAGE_JSON_BYTES + ); + let oversized_payload = format!("{exact_payload}x"); + assert_eq!( + serialized_size_within_limit(&oversized_payload, MAX_PORTABLE_PAGE_JSON_BYTES) + .unwrap_err(), + AgentPortableWireError::InvalidResponse + ); + } + + #[test] + fn escape_heavy_record_json_budget_is_inclusive_and_exact() { + let exact = escape_heavy_record_with_json_size(MAX_PORTABLE_HISTORY_RECORD_BYTES); + exact.validate().unwrap(); + let exact_page = PortableHistoryPage { + items: vec![exact.clone()], + history_revision: "history:escape-heavy".to_string(), + next_cursor: None, + }; + assert!(serialized_history_page_within_limits(&exact_page).is_ok()); + + let mut oversized = exact; + oversized + .items + .last_mut() + .unwrap() + .text + .as_mut() + .unwrap() + .push('x'); + assert_eq!( + serialized_size_within_limit(&oversized, usize::MAX).unwrap(), + MAX_PORTABLE_HISTORY_RECORD_BYTES + 1 + ); + // The retained native CBOR presentation limit still accepts this + // escape-heavy record; the Tauri JSON limit must reject it. + oversized.validate().unwrap(); + let oversized_page = PortableHistoryPage { + items: vec![oversized], + history_revision: "history:escape-heavy".to_string(), + next_cursor: None, + }; + assert!( + serialized_size_within_limit(&oversized_page, MAX_PORTABLE_PAGE_JSON_BYTES).is_ok() + ); + assert_eq!( + serialized_history_page_within_limits(&oversized_page).unwrap_err(), + AgentPortableWireError::InvalidResponse + ); + } + + #[test] + fn conservative_history_page_ledger_is_inclusive_and_exact() { + let exact = history_page_at_conservative_ledger_limit(); + let request = PortableRecordsPageRequest { + session_id: "session-1".to_string(), + cursor: None, + limit: u16::try_from(exact.items.len()).unwrap(), + }; + exact.validate_for(&request).unwrap(); + assert!(exact.items.iter().all(|record| { + serialized_size_within_limit(record, MAX_PORTABLE_HISTORY_RECORD_BYTES).is_ok() + })); + let exact_page_json = + serialized_size_within_limit(&exact, MAX_PORTABLE_PAGE_JSON_BYTES).unwrap(); + assert!(exact_page_json < MAX_PORTABLE_PAGE_JSON_BYTES); + assert_eq!( + conservative_history_page_ledger_within_limit(&exact, usize::MAX).unwrap(), + MAX_PORTABLE_PAGE_JSON_BYTES + ); + assert_eq!( + serialized_history_page_within_limits(&exact).unwrap(), + exact_page_json + ); + + let mut oversized = exact; + increment_last_record_json_byte(&mut oversized); + oversized.validate_for(&request).unwrap(); + assert!(oversized.items.iter().all(|record| { + serialized_size_within_limit(record, MAX_PORTABLE_HISTORY_RECORD_BYTES).is_ok() + })); + assert!(serialized_size_within_limit(&oversized, MAX_PORTABLE_PAGE_JSON_BYTES).is_ok()); + assert_eq!( + conservative_history_page_ledger_within_limit(&oversized, usize::MAX).unwrap(), + MAX_PORTABLE_PAGE_JSON_BYTES + 1 + ); + assert_eq!( + serialized_history_page_within_limits(&oversized).unwrap_err(), + AgentPortableWireError::InvalidResponse + ); + } + + #[tokio::test] + async fn refresh_and_prepare_issue_distinct_strict_runtime_and_lease_handles() { + let controller = Arc::new(TestController::valid()); + let state = AgentPortableTauriState::with_controller(controller.clone()); + let (refresh, lease) = bootstrap(&state).await; + + assert_eq!(refresh.schema_version, 1); + assert_eq!( + refresh.capabilities, + AgentPortableReadCapabilities::READ_ONLY + ); + assert_eq!(refresh.items.len(), 1); + assert!(refresh.runtime_id.starts_with("runtime_")); + assert!(lease.lease_handle.starts_with("lease_")); + assert!(lease.target_handle.starts_with("target_")); + assert_ne!(lease.lease_handle, lease.target_handle); + assert_eq!(lease.host_epoch, "7"); + assert_eq!(lease.connection_generation, 9); + assert_eq!(controller.refresh_calls.load(Ordering::Acquire), 1); + assert_eq!(controller.prepare_calls.load(Ordering::Acquire), 1); + + let value = serde_json::to_value((&refresh, &lease)).unwrap(); + let serialized = value.to_string(); + for forbidden in [ + "accountId", + "projectId", + "projectRoot", + "endpointId", + "model", + "mode", + "runId", + "targetId", + ] { + assert!(!serialized.contains(forbidden), "{forbidden}"); + } + let Value::Array(values) = value else { + panic!("tuple should serialize as an array"); + }; + assert_eq!( + serialized_keys(&values[0]), + ["capabilities", "items", "runtimeId", "schemaVersion",] + ); + assert_eq!( + serialized_keys(&values[0]["capabilities"]), + [ + "mutations", + "persistedRecordsPage", + "runtimeStatus", + "sessionSummariesPage", + "synchronizedLiveTail", + ] + ); + assert_eq!(serialized_keys(&values[0]["items"][0]), ["handle", "label"]); + assert_eq!( + serialized_keys(&values[1]), + [ + "connectionGeneration", + "hostEpoch", + "leaseHandle", + "targetHandle", + ] + ); + } + + #[tokio::test] + async fn prepare_projects_full_u64_host_epoch_only_as_a_decimal_string() { + let mut controller = TestController::valid(); + controller.prepared_lease.host_epoch = u64::MAX; + let controller = Arc::new(controller); + let state = AgentPortableTauriState::with_controller(controller); + let (_, lease) = bootstrap(&state).await; + + assert_eq!(lease.host_epoch, u64::MAX.to_string()); + validate_wire_lease(&lease).unwrap(); + let value = serde_json::to_value(&lease).unwrap(); + assert_eq!(value["hostEpoch"], json!(u64::MAX.to_string())); + assert!(value["hostEpoch"].is_string()); + assert!(value["connectionGeneration"].is_number()); + + let native_host_epoch = state + .inner + .lock() + .unwrap() + .runtime + .as_ref() + .unwrap() + .lease + .as_ref() + .unwrap() + .native + .host_epoch; + assert_eq!(native_host_epoch, u64::MAX); + } + + #[tokio::test] + async fn forged_runtime_target_and_lease_fail_before_controller_calls() { + let controller = Arc::new(TestController::valid()); + let state = AgentPortableTauriState::with_controller(controller.clone()); + let (refresh, lease) = bootstrap(&state).await; + + let prepare_calls = controller.prepare_calls.load(Ordering::Acquire); + assert_eq!( + state + .prepare_target(PrepareTargetRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: opaque("runtime", 'f'), + target_handle: refresh.items[0].handle.0.clone(), + }) + .await + .unwrap_err(), + AgentPortableWireError::StaleRuntime + ); + assert_eq!( + state + .prepare_target(PrepareTargetRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id.clone(), + target_handle: opaque("target", 'f'), + }) + .await + .unwrap_err(), + AgentPortableWireError::UnknownTarget + ); + assert_eq!( + controller.prepare_calls.load(Ordering::Acquire), + prepare_calls + ); + + let mut forged_lease = lease; + forged_lease.connection_generation += 1; + assert_eq!( + state + .runtime_status(ReadRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id, + lease: forged_lease, + }) + .await + .unwrap_err(), + AgentPortableWireError::StaleLease + ); + assert_eq!(controller.status_calls.load(Ordering::Acquire), 0); + } + + #[tokio::test] + async fn reads_return_only_sanitized_pages_with_exact_outer_keys() { + let controller = Arc::new(TestController::valid()); + let state = AgentPortableTauriState::with_controller(controller.clone()); + let (refresh, lease) = bootstrap(&state).await; + let status = state + .runtime_status(ReadRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id.clone(), + lease: lease.clone(), + }) + .await + .unwrap(); + let sessions = state + .sessions_page(SessionsPageCommandRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id.clone(), + lease: lease.clone(), + page: PortablePageRequest { + cursor: None, + limit: 10, + }, + }) + .await + .unwrap(); + let records = state + .records_page(RecordsPageCommandRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id, + lease, + page: PortableRecordsPageRequest { + session_id: "session-1".to_string(), + cursor: None, + limit: 10, + }, + }) + .await + .unwrap(); + assert!(status.running); + assert_eq!(controller.status_calls.load(Ordering::Acquire), 1); + assert_eq!(controller.sessions_calls.load(Ordering::Acquire), 1); + assert_eq!(controller.records_calls.load(Ordering::Acquire), 1); + assert_eq!(serialized_keys(&status), ["activeRunCount", "running"]); + assert_eq!(serialized_keys(&sessions), ["items", "nextCursor"]); + assert_eq!( + serialized_keys(&sessions.items[0]), + [ + "createdMs", + "id", + "messageCount", + "pageSortMs", + "title", + "updatedMs", + ] + ); + assert_eq!( + serialized_keys(&records), + ["historyRevision", "items", "nextCursor"] + ); + assert_eq!( + serialized_keys(&records.items[0]), + ["createdMs", "items", "recordId", "role"] + ); + assert_eq!( + serialized_keys(&records.items[0].items[0]), + [ + "createdMs", + "id", + "itemType", + "merge", + "role", + "status", + "text", + "title", + ] + ); + } + + #[tokio::test] + async fn oversized_records_page_is_rejected_and_cleanup_is_acknowledged() { + let controller = Arc::new(TestController::valid()); + let large_item = sample_timeline_item("x".repeat(190_000)); + let record_items = vec![large_item; 5]; + let records = (0..9) + .map(|index| PortableHistoryRecord { + record_id: format!("record:{index}"), + role: "assistant".to_string(), + created_ms: index, + items: record_items.clone(), + }) + .collect(); + *controller.records.lock().unwrap() = Ok(PortableHistoryPage { + items: records, + history_revision: "history:large".to_string(), + next_cursor: None, + }); + let state = AgentPortableTauriState::with_controller(controller.clone()); + let (refresh, lease) = bootstrap(&state).await; + assert_eq!( + state + .records_page(RecordsPageCommandRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id, + lease, + page: PortableRecordsPageRequest { + session_id: "session-1".to_string(), + cursor: None, + limit: 10, + }, + }) + .await + .unwrap_err(), + AgentPortableWireError::InvalidResponse + ); + assert_eq!(controller.records_calls.load(Ordering::Acquire), 1); + assert_eq!(controller.dispose_calls.load(Ordering::Acquire), 1); + assert!(state.inner.lock().unwrap().runtime.is_none()); + } + + #[tokio::test] + async fn json_oversized_record_is_rejected_and_cleanup_is_acknowledged() { + let controller = Arc::new(TestController::valid()); + let dispose_gate = Arc::new(TestGate::default()); + *controller.dispose_gate.lock().unwrap() = Some(dispose_gate.clone()); + let mut oversized = escape_heavy_record_with_json_size(MAX_PORTABLE_HISTORY_RECORD_BYTES); + oversized + .items + .last_mut() + .unwrap() + .text + .as_mut() + .unwrap() + .push('x'); + oversized.validate().unwrap(); + let response = PortableHistoryPage { + items: vec![oversized], + history_revision: "history:escape-heavy".to_string(), + next_cursor: None, + }; + assert!(serialized_size_within_limit(&response, MAX_PORTABLE_PAGE_JSON_BYTES).is_ok()); + *controller.records.lock().unwrap() = Ok(response); + + let state = Arc::new(AgentPortableTauriState::with_controller(controller.clone())); + let (refresh, lease) = bootstrap(&state).await; + let task_state = state.clone(); + let records = tokio::spawn(async move { + task_state + .records_page(RecordsPageCommandRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id, + lease, + page: PortableRecordsPageRequest { + session_id: "session-1".to_string(), + cursor: None, + limit: 1, + }, + }) + .await + }); + wait_for_counter(&controller.dispose_calls, 1).await; + assert_eq!(controller.records_calls.load(Ordering::Acquire), 1); + assert!(state.inner.lock().unwrap().runtime.is_none()); + assert!(!records.is_finished()); + dispose_gate.open(); + assert_eq!( + records.await.unwrap().unwrap_err(), + AgentPortableWireError::InvalidResponse + ); + assert_eq!(controller.dispose_calls.load(Ordering::Acquire), 1); + } + + #[tokio::test] + async fn conservative_ledger_overflow_is_rejected_and_cleanup_is_acknowledged() { + let controller = Arc::new(TestController::valid()); + let dispose_gate = Arc::new(TestGate::default()); + *controller.dispose_gate.lock().unwrap() = Some(dispose_gate.clone()); + let mut response = history_page_at_conservative_ledger_limit(); + increment_last_record_json_byte(&mut response); + let page_limit = u16::try_from(response.items.len()).unwrap(); + let request = PortableRecordsPageRequest { + session_id: "session-1".to_string(), + cursor: None, + limit: page_limit, + }; + response.validate_for(&request).unwrap(); + assert!(response.items.iter().all(|record| { + serialized_size_within_limit(record, MAX_PORTABLE_HISTORY_RECORD_BYTES).is_ok() + })); + assert!(serialized_size_within_limit(&response, MAX_PORTABLE_PAGE_JSON_BYTES).is_ok()); + assert_eq!( + conservative_history_page_ledger_within_limit(&response, usize::MAX).unwrap(), + MAX_PORTABLE_PAGE_JSON_BYTES + 1 + ); + *controller.records.lock().unwrap() = Ok(response); + + let state = Arc::new(AgentPortableTauriState::with_controller(controller.clone())); + let (refresh, lease) = bootstrap(&state).await; + let task_state = state.clone(); + let records = tokio::spawn(async move { + task_state + .records_page(RecordsPageCommandRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id, + lease, + page: request, + }) + .await + }); + wait_for_counter(&controller.dispose_calls, 1).await; + assert_eq!(controller.records_calls.load(Ordering::Acquire), 1); + assert!(state.inner.lock().unwrap().runtime.is_none()); + assert!(!records.is_finished()); + dispose_gate.open(); + assert_eq!( + records.await.unwrap().unwrap_err(), + AgentPortableWireError::InvalidResponse + ); + assert_eq!(controller.dispose_calls.load(Ordering::Acquire), 1); + } + + #[tokio::test] + async fn native_invalidation_fences_inflight_prepare_before_publication() { + let controller = Arc::new(TestController::valid()); + let prepare_gate = Arc::new(TestGate::default()); + *controller.prepare_gate.lock().unwrap() = Some(prepare_gate.clone()); + let state = Arc::new(AgentPortableTauriState::with_controller(controller.clone())); + let refresh = state.refresh_targets(refresh_request()).await.unwrap(); + let task_state = state.clone(); + let runtime_id = refresh.runtime_id.clone(); + let target_handle = refresh.items[0].handle.0.clone(); + let prepare = tokio::spawn(async move { + task_state + .prepare_target(PrepareTargetRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id, + target_handle, + }) + .await + }); + wait_for_counter(&controller.prepare_calls, 1).await; + state.native_credentials_invalidated().await.unwrap(); + assert!(state.inner.lock().unwrap().runtime.is_none()); + prepare_gate.open(); + assert_eq!( + prepare.await.unwrap().unwrap_err(), + AgentPortableWireError::Cancelled + ); + assert_eq!(controller.invalidation_calls.load(Ordering::Acquire), 1); + } + + #[tokio::test] + async fn native_invalidation_suppresses_inflight_read_response() { + let controller = Arc::new(TestController::valid()); + let status_gate = Arc::new(TestGate::default()); + *controller.status_gate.lock().unwrap() = Some(status_gate.clone()); + let state = Arc::new(AgentPortableTauriState::with_controller(controller.clone())); + let (refresh, lease) = bootstrap(&state).await; + let task_state = state.clone(); + let status = tokio::spawn(async move { + task_state + .runtime_status(ReadRequest { + account_id: ACCOUNT_ID.to_string(), + runtime_id: refresh.runtime_id, + lease, + }) + .await + }); + wait_for_counter(&controller.status_calls, 1).await; + state.native_credentials_invalidated().await.unwrap(); + status_gate.open(); + assert_eq!( + status.await.unwrap().unwrap_err(), + AgentPortableWireError::StaleRuntime + ); + } + + #[tokio::test] + async fn native_dispose_clears_mapping_before_awaiting_acknowledgement() { + let controller = Arc::new(TestController::valid()); + let dispose_gate = Arc::new(TestGate::default()); + *controller.dispose_gate.lock().unwrap() = Some(dispose_gate.clone()); + let state = Arc::new(AgentPortableTauriState::with_controller(controller.clone())); + bootstrap(&state).await; + let task_state = state.clone(); + let dispose = tokio::spawn(async move { task_state.native_dispose().await }); + wait_for_counter(&controller.dispose_calls, 1).await; + assert!(state.inner.lock().unwrap().runtime.is_none()); + assert!(!dispose.is_finished()); + dispose_gate.open(); + dispose.await.unwrap().unwrap(); + } +} diff --git a/frontend/src-tauri/src/agent_tauri.rs b/frontend/src-tauri/src/agent_tauri.rs index 6e207e4c8..24b3d140d 100644 --- a/frontend/src-tauri/src/agent_tauri.rs +++ b/frontend/src-tauri/src/agent_tauri.rs @@ -1,9 +1,10 @@ use crate::agent::{ - AgentConfig, AgentCreateSessionRequest, AgentEventSink, AgentMcpServer, - AgentPermissionModeRequest, AgentPermissionResponse, AgentProjectRootRegistration, - AgentProjectSkillsTrustStatus, AgentRenameSessionRequest, AgentRunEvent, AgentRunResponse, - AgentRunTerminal, AgentRuntimeHandle, AgentRuntimeStatus, AgentSendMessageRequest, - AgentServiceEvent, AgentSessionDetail, AgentSessionMcpServer, AgentSessionSummary, + AgentConfig, AgentCreateSessionRequest, AgentEventSink, AgentHistoryPage, + AgentHistoryPageRequest, AgentMcpServer, AgentPermissionModeRequest, AgentPermissionResponse, + AgentProjectRootRegistration, AgentProjectSkillsTrustStatus, AgentRenameSessionRequest, + AgentRunEvent, AgentRunResponse, AgentRunTerminal, AgentRuntimeHandle, AgentRuntimeStatus, + AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, AgentSessionMcpServer, + AgentSessionPage, AgentSessionPageRequest, AgentSessionSummary, AgentSetSessionMcpServerRequest, AgentStartRequest, AgentTimelineItem, MapleAgentService, RecentProjectRoot, }; @@ -400,6 +401,25 @@ pub async fn agent_list_sessions( .await } +#[tauri::command] +pub async fn agent_list_sessions_page( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + request: Option, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .list_sessions_page(request.unwrap_or(AgentSessionPageRequest { + project_root: None, + cursor: None, + limit: None, + })) + .await + .map_err(|error| error.user_message().to_string()) +} + #[tauri::command] pub async fn agent_load_session( app_handle: AppHandle, @@ -414,6 +434,21 @@ pub async fn agent_load_session( .await } +#[tauri::command] +pub async fn agent_list_session_records_page( + app_handle: AppHandle, + state: State<'_, MapleAgentService>, + user_id: String, + request: AgentHistoryPageRequest, +) -> Result { + let _ = app_handle; + handle_for_user(&state, &user_id) + .await? + .list_session_records_page(request) + .await + .map_err(|error| error.user_message().to_string()) +} + #[tauri::command] pub async fn agent_rename_session( state: State<'_, MapleAgentService>, @@ -546,6 +581,7 @@ mod tests { project_root: "/tmp/project".to_string(), created_ms: 1, updated_ms: 2, + page_sort_ms: 2, message_count: 3, model: Some("maple-model".to_string()), mode: "smart_approve".to_string(), @@ -620,6 +656,7 @@ mod tests { "projectRoot": "/tmp/project", "createdMs": 1, "updatedMs": 2, + "pageSortMs": 2, "messageCount": 3, "model": "maple-model", "mode": "smart_approve" @@ -635,6 +672,7 @@ mod tests { "projectRoot": "/tmp/project", "createdMs": 1, "updatedMs": 2, + "pageSortMs": 2, "messageCount": 3, "model": "maple-model", "mode": "smart_approve" diff --git a/frontend/src-tauri/src/durable_host_epoch.rs b/frontend/src-tauri/src/durable_host_epoch.rs new file mode 100644 index 000000000..ec2dbfe48 --- /dev/null +++ b/frontend/src-tauri/src/durable_host_epoch.rs @@ -0,0 +1,656 @@ +//! Durable, installation-local allocation for remote Agent host epochs. +//! +//! A host epoch is reserved synchronously before an Iroh endpoint is allowed +//! to bind. The secure-state record is written first and a separate lineage +//! guard is written second. Consequently, interruption may consume an epoch, +//! but no interruption can make a previously returned epoch available again. +//! +//! The lineage guard detects a missing, corrupt, or lower secure-state record. +//! As with any entirely local scheme, a coordinated rollback of every secure +//! record is outside this boundary and ultimately needs an OS or remote +//! monotonic witness. Maple nevertheless fails closed for every partial +//! rollback it can observe instead of silently recreating state. +#![allow( + dead_code, + reason = "remote hosting remains disabled until a platform secure store is enabled" +)] + +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::secure_storage::{DeviceSecretStore, SecretStoreError}; + +const PURPOSE: &str = "remote-agent-host-epoch-v1"; +const STATE_ACCOUNT: &str = "host-epoch-state"; +const GUARD_ACCOUNT: &str = "host-epoch-lineage-guard"; +const RECORD_VERSION: u8 = 1; +const STATE_KIND: u8 = 1; +const GUARD_KIND: u8 = 2; +const KEY_DIGEST_LEN: usize = 32; +const EPOCH_LEN: usize = 8; +const COMMITMENT_LEN: usize = 32; +const CHECKSUM_LEN: usize = 32; +const STATE_RECORD_LEN: usize = 1 + 1 + KEY_DIGEST_LEN + EPOCH_LEN + CHECKSUM_LEN; +const GUARD_RECORD_LEN: usize = 1 + 1 + KEY_DIGEST_LEN + EPOCH_LEN + COMMITMENT_LEN + CHECKSUM_LEN; +const STATE_CHECKSUM_DOMAIN: &[u8] = b"maple-host-epoch-state-checksum-v1"; +const GUARD_CHECKSUM_DOMAIN: &[u8] = b"maple-host-epoch-guard-checksum-v1"; +const KEY_DIGEST_DOMAIN: &[u8] = b"maple-host-epoch-storage-key-v1"; +const STATE_COMMITMENT_DOMAIN: &[u8] = b"maple-host-epoch-state-commitment-v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HostEpochRecordKind { + State, + LineageGuard, +} + +impl HostEpochRecordKind { + pub(crate) const fn account(self) -> &'static str { + match self { + Self::State => STATE_ACCOUNT, + Self::LineageGuard => GUARD_ACCOUNT, + } + } +} + +/// Native installation coordinates for the epoch records. There is +/// deliberately no account identifier: signing out, changing accounts, or +/// clearing renderer state must not reset the host's installation lineage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostEpochStorageKey { + app_identifier: String, + install_marker: String, + identity_generation: u64, +} + +impl HostEpochStorageKey { + pub(crate) fn new( + app_identifier: impl Into, + install_marker: impl Into, + identity_generation: u64, + ) -> Result { + let key = Self { + app_identifier: app_identifier.into(), + install_marker: install_marker.into(), + identity_generation, + }; + if key.identity_generation == 0 + || !is_safe_component(&key.app_identifier) + || !is_safe_component(&key.install_marker) + { + return Err(SecretStoreError::Corrupt( + "host epoch storage key has an invalid shape".into(), + )); + } + Ok(key) + } + + pub(crate) fn service(&self) -> String { + format!("{}.{}", self.app_identifier, PURPOSE) + } + + pub(crate) fn account(&self, kind: HostEpochRecordKind) -> &'static str { + kind.account() + } + + pub(crate) fn app_identifier(&self) -> &str { + &self.app_identifier + } + + fn digest(&self) -> [u8; KEY_DIGEST_LEN] { + let mut hasher = Sha256::new(); + hasher.update(KEY_DIGEST_DOMAIN); + hash_len_prefixed(&mut hasher, self.app_identifier.as_bytes()); + hash_len_prefixed(&mut hasher, self.install_marker.as_bytes()); + hasher.update(self.identity_generation.to_be_bytes()); + hasher.finalize().into() + } +} + +/// Opaque proof that the secure store and lineage guard durably accepted this +/// epoch. Only this module can create one; transport code consumes it to build +/// a host connection clock. It is intentionally neither serializable nor +/// constructible from a renderer/caller integer. +#[derive(Debug)] +pub(crate) struct ReservedHostEpoch(u64); + +impl ReservedHostEpoch { + pub(crate) const fn get(&self) -> u64 { + self.0 + } +} + +/// Reserve the next host epoch under the secure backend's cross-process lock. +/// A successful backend write must be atomic and durable before returning; +/// `DeviceSecretStore` documents that contract. No endpoint may bind until +/// this function returns its opaque reservation. +pub(crate) fn reserve_next_host_epoch( + store: &dyn DeviceSecretStore, + key: &HostEpochStorageKey, +) -> Result { + let mut reserve = || reserve_next_host_epoch_locked(store, key); + let epoch = store.with_host_epoch_lock(key, &mut reserve)?; + if epoch == 0 { + return Err(SecretStoreError::Corrupt( + "secure storage returned an invalid host epoch reservation".into(), + )); + } + Ok(ReservedHostEpoch(epoch)) +} + +fn reserve_next_host_epoch_locked( + store: &dyn DeviceSecretStore, + key: &HostEpochStorageKey, +) -> Result { + let state = store.load_host_epoch_record(key, HostEpochRecordKind::State)?; + let mut guard = store.load_host_epoch_record(key, HostEpochRecordKind::LineageGuard)?; + + // Persist an initialization sentinel before the first state write. This + // makes `state present, guard missing` unambiguously unsafe rather than + // indistinguishable from interruption during first use. + if state.is_none() && guard.is_none() { + let initialized = encode_guard_record(key, 0, [0; COMMITMENT_LEN]); + store_and_verify(store, key, HostEpochRecordKind::LineageGuard, &initialized)?; + guard = Some(initialized); + } + + let guard = guard.ok_or_else(|| { + SecretStoreError::Corrupt("host epoch secure state exists without its lineage guard".into()) + })?; + let decoded_guard = decode_guard_record(key, &guard)?; + let decoded_state = state + .as_deref() + .map(|record| decode_state_record(key, record)) + .transpose()?; + + match decoded_state.as_ref() { + None if decoded_guard.epoch != 0 => { + return Err(SecretStoreError::Corrupt( + "host epoch secure state is missing below its lineage guard".into(), + )); + } + None => {} + Some(state) if state.epoch < decoded_guard.epoch => { + return Err(SecretStoreError::Corrupt( + "host epoch secure state is older than its lineage guard".into(), + )); + } + Some(state) if state.epoch == decoded_guard.epoch => { + if state.commitment != decoded_guard.state_commitment { + return Err(SecretStoreError::Corrupt( + "host epoch secure state does not match its lineage guard".into(), + )); + } + } + // A higher secure epoch is the only expected partial state: the + // process stopped after the state write and before the guard write. + // Consume another value rather than ever returning the uncertain one. + Some(_) => {} + } + + let current = decoded_state.as_ref().map_or(decoded_guard.epoch, |state| { + state.epoch.max(decoded_guard.epoch) + }); + let next = current.checked_add(1).ok_or_else(|| { + SecretStoreError::Corrupt("host epoch monotonic counter is exhausted".into()) + })?; + + let next_state = encode_state_record(key, next); + store_and_verify(store, key, HostEpochRecordKind::State, &next_state)?; + let state_commitment = state_commitment(&next_state); + let next_guard = encode_guard_record(key, next, state_commitment); + store_and_verify(store, key, HostEpochRecordKind::LineageGuard, &next_guard)?; + Ok(next) +} + +fn store_and_verify( + store: &dyn DeviceSecretStore, + key: &HostEpochStorageKey, + kind: HostEpochRecordKind, + expected: &[u8], +) -> Result<(), SecretStoreError> { + store.store_host_epoch_record(key, kind, expected)?; + let observed = store.load_host_epoch_record(key, kind)?.ok_or_else(|| { + SecretStoreError::Backend("secure storage lost a completed host epoch write".into()) + })?; + if observed.as_slice() != expected { + return Err(SecretStoreError::Backend( + "secure storage did not retain the completed host epoch write".into(), + )); + } + Ok(()) +} + +#[derive(Debug)] +struct DecodedState { + epoch: u64, + commitment: [u8; COMMITMENT_LEN], +} + +#[derive(Debug)] +struct DecodedGuard { + epoch: u64, + state_commitment: [u8; COMMITMENT_LEN], +} + +fn encode_state_record(key: &HostEpochStorageKey, epoch: u64) -> Zeroizing> { + debug_assert_ne!(epoch, 0); + let mut record = Zeroizing::new(Vec::with_capacity(STATE_RECORD_LEN)); + record.push(RECORD_VERSION); + record.push(STATE_KIND); + record.extend_from_slice(&key.digest()); + record.extend_from_slice(&epoch.to_be_bytes()); + let checksum = checksum(STATE_CHECKSUM_DOMAIN, &record); + record.extend_from_slice(&checksum); + record +} + +fn decode_state_record( + key: &HostEpochStorageKey, + record: &[u8], +) -> Result { + validate_record_prefix( + key, + record, + STATE_RECORD_LEN, + STATE_KIND, + STATE_CHECKSUM_DOMAIN, + )?; + let epoch = decode_epoch(record)?; + if epoch == 0 { + return Err(SecretStoreError::Corrupt( + "host epoch secure state contains zero".into(), + )); + } + Ok(DecodedState { + epoch, + commitment: state_commitment(record), + }) +} + +fn encode_guard_record( + key: &HostEpochStorageKey, + epoch: u64, + state_commitment: [u8; COMMITMENT_LEN], +) -> Zeroizing> { + let mut record = Zeroizing::new(Vec::with_capacity(GUARD_RECORD_LEN)); + record.push(RECORD_VERSION); + record.push(GUARD_KIND); + record.extend_from_slice(&key.digest()); + record.extend_from_slice(&epoch.to_be_bytes()); + record.extend_from_slice(&state_commitment); + let checksum = checksum(GUARD_CHECKSUM_DOMAIN, &record); + record.extend_from_slice(&checksum); + record +} + +fn decode_guard_record( + key: &HostEpochStorageKey, + record: &[u8], +) -> Result { + validate_record_prefix( + key, + record, + GUARD_RECORD_LEN, + GUARD_KIND, + GUARD_CHECKSUM_DOMAIN, + )?; + let epoch = decode_epoch(record)?; + let commitment_start = 1 + 1 + KEY_DIGEST_LEN + EPOCH_LEN; + let state_commitment: [u8; COMMITMENT_LEN] = record + [commitment_start..commitment_start + COMMITMENT_LEN] + .try_into() + .map_err(|_| SecretStoreError::Corrupt("invalid host epoch guard".into()))?; + if (epoch == 0) != (state_commitment == [0; COMMITMENT_LEN]) { + return Err(SecretStoreError::Corrupt( + "host epoch guard initialization state is invalid".into(), + )); + } + Ok(DecodedGuard { + epoch, + state_commitment, + }) +} + +fn validate_record_prefix( + key: &HostEpochStorageKey, + record: &[u8], + expected_len: usize, + expected_kind: u8, + checksum_domain: &[u8], +) -> Result<(), SecretStoreError> { + if record.len() != expected_len + || record.first() != Some(&RECORD_VERSION) + || record.get(1) != Some(&expected_kind) + { + return Err(SecretStoreError::Corrupt( + "host epoch record has an unsupported shape".into(), + )); + } + if record[2..2 + KEY_DIGEST_LEN] != key.digest() { + return Err(SecretStoreError::Corrupt( + "host epoch record belongs to a different installation lineage".into(), + )); + } + let checksum_start = record.len() - CHECKSUM_LEN; + let expected_checksum = checksum(checksum_domain, &record[..checksum_start]); + if record[checksum_start..] != expected_checksum { + return Err(SecretStoreError::Corrupt( + "host epoch record integrity check failed".into(), + )); + } + Ok(()) +} + +fn decode_epoch(record: &[u8]) -> Result { + let epoch_start = 1 + 1 + KEY_DIGEST_LEN; + let epoch_bytes: [u8; EPOCH_LEN] = record[epoch_start..epoch_start + EPOCH_LEN] + .try_into() + .map_err(|_| SecretStoreError::Corrupt("invalid host epoch record".into()))?; + Ok(u64::from_be_bytes(epoch_bytes)) +} + +fn checksum(domain: &[u8], record: &[u8]) -> [u8; CHECKSUM_LEN] { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(record); + hasher.finalize().into() +} + +fn state_commitment(record: &[u8]) -> [u8; COMMITMENT_LEN] { + checksum(STATE_COMMITMENT_DOMAIN, record) +} + +fn hash_len_prefixed(hasher: &mut Sha256, value: &[u8]) { + hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes()); + hasher.update(value); +} + +fn is_safe_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 200 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secure_storage::DeviceSecretSlot; + use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + thread, + }; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum FailureTiming { + BeforeWrite, + AfterWrite, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct WriteFailure { + kind: HostEpochRecordKind, + timing: FailureTiming, + } + + #[derive(Debug, Default)] + struct FaultStore { + records: Mutex>>, + epoch_lock: Mutex<()>, + next_write_failure: Mutex>, + } + + impl FaultStore { + fn fail_next_write(&self, kind: HostEpochRecordKind, timing: FailureTiming) { + *self.next_write_failure.lock().unwrap() = Some(WriteFailure { kind, timing }); + } + + fn snapshot(&self, kind: HostEpochRecordKind) -> Option> { + self.records.lock().unwrap().get(&kind).cloned() + } + + fn restore(&self, kind: HostEpochRecordKind, value: Option>) { + let mut records = self.records.lock().unwrap(); + match value { + Some(value) => { + records.insert(kind, value); + } + None => { + records.remove(&kind); + } + } + } + } + + impl DeviceSecretStore for FaultStore { + fn load( + &self, + _slot: &DeviceSecretSlot, + ) -> Result>>, SecretStoreError> { + Err(SecretStoreError::Unavailable( + "identity records are not used by this test store".into(), + )) + } + + fn store(&self, _slot: &DeviceSecretSlot, _secret: &[u8]) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Unavailable( + "identity records are not used by this test store".into(), + )) + } + + fn delete(&self, _slot: &DeviceSecretSlot) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Unavailable( + "identity records are not used by this test store".into(), + )) + } + + fn load_host_epoch_record( + &self, + _key: &HostEpochStorageKey, + kind: HostEpochRecordKind, + ) -> Result>>, SecretStoreError> { + Ok(self + .records + .lock() + .unwrap() + .get(&kind) + .cloned() + .map(Zeroizing::new)) + } + + fn store_host_epoch_record( + &self, + _key: &HostEpochStorageKey, + kind: HostEpochRecordKind, + record: &[u8], + ) -> Result<(), SecretStoreError> { + let failure = { + let mut pending = self.next_write_failure.lock().unwrap(); + if pending.as_ref().is_some_and(|failure| failure.kind == kind) { + pending.take() + } else { + None + } + }; + if failure.is_some_and(|failure| failure.timing == FailureTiming::BeforeWrite) { + return Err(SecretStoreError::Backend( + "test interruption before durable write".into(), + )); + } + self.records.lock().unwrap().insert(kind, record.to_vec()); + if failure.is_some_and(|failure| failure.timing == FailureTiming::AfterWrite) { + return Err(SecretStoreError::Backend( + "test interruption after durable write".into(), + )); + } + Ok(()) + } + + fn with_host_epoch_lock( + &self, + _key: &HostEpochStorageKey, + operation: &mut dyn FnMut() -> Result, + ) -> Result { + let _guard = self.epoch_lock.lock().unwrap(); + operation() + } + } + + fn key() -> HostEpochStorageKey { + HostEpochStorageKey::new("cloud.opensecret.maple.test", "install-a", 1).unwrap() + } + + fn reserve(store: &FaultStore) -> Result { + reserve_next_host_epoch(store, &key()).map(|reservation| reservation.get()) + } + + #[test] + fn process_restarts_advance_the_durable_epoch() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + assert_eq!(reserve(&store).unwrap(), 2); + assert_eq!(reserve(&store).unwrap(), 3); + } + + #[test] + fn concurrent_runtime_startups_receive_unique_epochs() { + let store = Arc::new(FaultStore::default()); + let workers = (0..16) + .map(|_| { + let store = Arc::clone(&store); + thread::spawn(move || reserve(&store).unwrap()) + }) + .collect::>(); + let mut epochs = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + epochs.sort_unstable(); + assert_eq!(epochs, (1..=16).collect::>()); + } + + #[test] + fn interruption_before_state_persist_returns_no_capability_and_reuses_no_returned_epoch() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + store.fail_next_write(HostEpochRecordKind::State, FailureTiming::BeforeWrite); + assert!( + reserve(&store).is_err(), + "failed persistence must not return an epoch" + ); + assert_eq!(reserve(&store).unwrap(), 2); + } + + #[test] + fn interruption_after_state_persist_skips_the_uncertain_epoch() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + store.fail_next_write(HostEpochRecordKind::State, FailureTiming::AfterWrite); + assert!( + reserve(&store).is_err(), + "interrupted reservation must not escape" + ); + assert_eq!(reserve(&store).unwrap(), 3); + } + + #[test] + fn interruption_after_guard_persist_skips_the_uncertain_epoch() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + store.fail_next_write(HostEpochRecordKind::LineageGuard, FailureTiming::AfterWrite); + assert!( + reserve(&store).is_err(), + "interrupted reservation must not escape" + ); + assert_eq!(reserve(&store).unwrap(), 3); + } + + #[test] + fn lower_secure_state_fails_closed_against_the_guard() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + let old_state = store.snapshot(HostEpochRecordKind::State); + assert_eq!(reserve(&store).unwrap(), 2); + store.restore(HostEpochRecordKind::State, old_state); + assert!(matches!(reserve(&store), Err(SecretStoreError::Corrupt(_)))); + } + + #[test] + fn restored_aba_state_never_reissues_an_old_epoch() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + let state_a = store.snapshot(HostEpochRecordKind::State); + assert_eq!(reserve(&store).unwrap(), 2); + assert_eq!(reserve(&store).unwrap(), 3); + store.restore(HostEpochRecordKind::State, state_a); + assert!(matches!(reserve(&store), Err(SecretStoreError::Corrupt(_)))); + } + + #[test] + fn missing_or_corrupt_secure_state_fails_closed() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + store.restore(HostEpochRecordKind::State, None); + assert!(matches!(reserve(&store), Err(SecretStoreError::Corrupt(_)))); + + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + let mut corrupt = store.snapshot(HostEpochRecordKind::State).unwrap(); + corrupt[4] ^= 0x80; + store.restore(HostEpochRecordKind::State, Some(corrupt)); + assert!(matches!(reserve(&store), Err(SecretStoreError::Corrupt(_)))); + } + + #[test] + fn state_without_initialization_guard_fails_closed() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + store.restore(HostEpochRecordKind::LineageGuard, None); + assert!(matches!(reserve(&store), Err(SecretStoreError::Corrupt(_)))); + } + + #[test] + fn installation_lineage_change_requires_explicit_teardown_instead_of_resetting() { + let store = FaultStore::default(); + assert_eq!(reserve(&store).unwrap(), 1); + let changed = + HostEpochStorageKey::new("cloud.opensecret.maple.test", "install-b", 2).unwrap(); + assert!(matches!( + reserve_next_host_epoch(&store, &changed), + Err(SecretStoreError::Corrupt(_)) + )); + } + + #[test] + fn backend_without_epoch_lock_fails_closed() { + struct NoEpochBackend; + + impl DeviceSecretStore for NoEpochBackend { + fn load( + &self, + _slot: &DeviceSecretSlot, + ) -> Result>>, SecretStoreError> { + Ok(None) + } + + fn store( + &self, + _slot: &DeviceSecretSlot, + _secret: &[u8], + ) -> Result<(), SecretStoreError> { + Ok(()) + } + + fn delete(&self, _slot: &DeviceSecretSlot) -> Result<(), SecretStoreError> { + Ok(()) + } + } + + assert!(matches!( + reserve_next_host_epoch(&NoEpochBackend, &key()), + Err(SecretStoreError::Unavailable(_)) + )); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 205fbbda8..41585ce29 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -6,9 +6,25 @@ mod agent; #[cfg(desktop)] mod agent_acp; #[cfg(desktop)] +mod agent_event_journal; +#[cfg(desktop)] mod agent_host; #[cfg(desktop)] +mod agent_live_authority; +#[cfg(desktop)] +mod agent_live_binding; +#[cfg(desktop)] +mod agent_live_coordinator; +#[cfg(desktop)] +mod agent_live_host; +#[cfg(desktop)] +mod agent_live_projection; +#[cfg(desktop)] +mod agent_live_tauri; +mod agent_remote_portable; +#[cfg(desktop)] mod agent_tauri; +mod durable_host_epoch; #[cfg(any(desktop, target_os = "ios"))] mod legacy_tts_cleanup; #[cfg(desktop)] @@ -18,6 +34,10 @@ mod open_secret_config; mod pdf_extractor; mod pdf_ocr; mod proxy; +mod remote_agent_rpc; +mod remote_protocol; +mod remote_transport; +mod secure_storage; #[cfg(desktop)] #[tauri::command] @@ -168,6 +188,11 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .manage(agent_acp::AgentAcpState::new()) .manage(agent_host::AgentHostLifecycle::new()) + // Synchronized attach remains explicitly disabled until a verified + // pairing/provider composition installs the native authority runtime. + // Keeping managed state and registered commands yields a stable typed + // `unavailable` response instead of trusting renderer lease fields. + .manage(agent_live_tauri::AgentLiveTauriState::disabled()) .manage(maple_api::MapleApiAuthState::new()) .manage(proxy::ProxyState::new()) .invoke_handler(tauri::generate_handler![ @@ -187,7 +212,9 @@ pub fn run() { agent_tauri::agent_save_project_root_order, agent_tauri::agent_create_session, agent_tauri::agent_list_sessions, + agent_tauri::agent_list_sessions_page, agent_tauri::agent_load_session, + agent_tauri::agent_list_session_records_page, agent_tauri::agent_rename_session, agent_tauri::agent_list_session_mcp_servers, agent_tauri::agent_set_session_mcp_server_enabled, @@ -198,6 +225,11 @@ pub fn run() { agent_tauri::agent_permission_respond, agent_tauri::agent_clear_user_history, agent_tauri::agent_clear_user_data, + agent_live_tauri::agent_begin_session_history_attach, + agent_live_tauri::agent_activate_session_history_attach, + agent_live_tauri::agent_cancel_session_history_attach, + agent_live_tauri::agent_resume_live_events, + agent_live_tauri::agent_cancel_live_events, agent_acp::agent_acp_load_config, agent_acp::agent_acp_save_config, agent_acp::agent_acp_start, @@ -403,7 +435,10 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) - .plugin(tauri_plugin_fs::init()); + .plugin(tauri_plugin_fs::init()) + // Portable remote reads stay fail-closed until the native mobile auth + // owner and verified provider composition are installed together. + .manage(agent_remote_portable::tauri::AgentPortableTauriState::disabled()); // Only add the Apple Sign In plugin on iOS #[cfg(all(not(desktop), target_os = "ios"))] @@ -415,6 +450,11 @@ pub fn run() { #[cfg(all(not(desktop), target_os = "android"))] let app = builder .invoke_handler(tauri::generate_handler![ + agent_remote_portable::tauri::agent_portable_refresh_targets, + agent_remote_portable::tauri::agent_portable_prepare_target, + agent_remote_portable::tauri::agent_portable_get_runtime_status, + agent_remote_portable::tauri::agent_portable_list_sessions_page, + agent_remote_portable::tauri::agent_portable_list_records_page, pdf_extractor::extract_document_content, ]) .setup(|app| { @@ -437,6 +477,11 @@ pub fn run() { #[cfg(all(not(desktop), target_os = "ios"))] let app = builder .invoke_handler(tauri::generate_handler![ + agent_remote_portable::tauri::agent_portable_refresh_targets, + agent_remote_portable::tauri::agent_portable_prepare_target, + agent_remote_portable::tauri::agent_portable_get_runtime_status, + agent_remote_portable::tauri::agent_portable_list_sessions_page, + agent_remote_portable::tauri::agent_portable_list_records_page, pdf_extractor::extract_document_content, ]) .setup(|app| { diff --git a/frontend/src-tauri/src/remote_agent_rpc.rs b/frontend/src-tauri/src/remote_agent_rpc.rs new file mode 100644 index 000000000..3bceae688 --- /dev/null +++ b/frontend/src-tauri/src/remote_agent_rpc.rs @@ -0,0 +1,6248 @@ +//! Typed Maple Agent operations carried by the authenticated Iroh transport. +//! +//! This first vertical slice intentionally exposes only runtime status. It is +//! not a Tauri command router and does not accept command names. The controller +//! selects the current authenticated generation through +//! [`GenerationConnectionManager`]; the desktop host injects the exact status +//! provider it wants this paired controller to observe. +#![allow( + dead_code, + reason = "library-level remote slice is wired to Tauri in a later milestone" +)] + +use std::{future::Future, pin::Pin, time::Duration}; + +#[cfg(desktop)] +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex as StdMutex, + }, +}; + +#[cfg(all(test, desktop))] +use crate::remote_protocol::RemoteAgentLiveControlRequest; +#[cfg(desktop)] +use crate::remote_protocol::{ + RemoteAgentBulkRequest, RemoteAgentControlRequest, RemoteAgentHistoryRecord, + RemoteAgentLiveClearReason, RemoteAgentLiveEventsRequest, RemoteAgentLiveRunTerminal, + RemoteAgentSessionSummary, RemoteAgentTimelineItem, +}; +#[cfg(desktop)] +use crate::remote_transport::VerifiedIncomingPeerAuthorization; +#[cfg(desktop)] +use crate::{ + agent::{AgentHistoryPageRequest, AgentLiveEventCursor}, + agent_live_binding::AgentLiveBindingLease, + agent_live_coordinator::{ + AgentLiveCoordinatorError, AgentLiveReceiveError, HeadReloadReason, MapleLiveClearReason, + MapleLiveEvent, MapleLiveItemType, MapleLiveMerge, MapleLiveRole, MapleLiveRunTerminal, + MapleLiveTimelineItem, + }, + agent_live_host::{ + AgentLiveHostError, AgentLivePeerRevocationHook, AgentLiveRemoteAttachError, + AgentLiveRemoteAttachProvider, AgentLiveRemoteAttachService, AgentLiveRemoteDelivery, + AgentLiveRemoteHeadBegin, AgentLiveRemotePendingAttach, AgentLiveRemoteResume, + AgentLiveRemoteStreamError, + }, +}; +use crate::{ + remote_protocol::{ + remote_live_projection_item_wire_bytes, remote_live_projection_session_wire_bytes, + ActivateAgentLiveAttachRequest, ActivateAgentLiveAttachResponse, AgentHistoryPageFrame, + AgentLiveActivationDisposition, AgentLiveCancelKind, AgentLiveStreamFrame, + BeginAgentLiveAttachRequest, CancelAgentLiveRequest, CancelAgentLiveResponse, ErrorCode, + GetRuntimeStatusRequest, GetRuntimeStatusResponse, ListAgentHistoryRecordsRequest, + ListAgentSessionsRequest, ListAgentSessionsResponse, PeerDirection, ProtocolError, + RemoteAgentHistoryPage, RemoteAgentLiveDelivery, RemoteAgentLiveHeadSnapshot, + RemoteAgentLiveSessionSnapshot, RemoteAgentLiveSnapshotReason, RemoteAgentLiveStreamStart, + RemoteAgentRuntimeStatus, RemoteLiveEventCursor, RequestEnvelope, ResponseEnvelope, + ResumeAgentLiveEventsRequest, LIVE_PROJECTION_OUTER_OVERHEAD_BYTES, + MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT, PROTOCOL_VERSION, + }, + remote_transport::{ + validate_frame_encodable, AcceptedRequest, ConnectedPeer, GenerationConnectionManager, + MapleIrohEndpoint, StreamingResponse, + }, +}; +#[cfg(desktop)] +use getrandom::fill as fill_random; +#[cfg(desktop)] +use tokio::sync::oneshot; + +const RUNTIME_STATUS_PROVIDER_TIMEOUT: Duration = Duration::from_secs(1); +const RUNTIME_STATUS_RESPONSE_BUDGET: Duration = Duration::from_millis(50); +const AGENT_HISTORY_PROVIDER_TIMEOUT: Duration = Duration::from_secs(5); +const AGENT_HISTORY_RESPONSE_BUDGET: Duration = Duration::from_secs(1); +const REMOTE_LIVE_SUBSCRIPTION_CAPACITY: usize = 128; +const REMOTE_LIVE_PENDING_TTL: Duration = Duration::from_secs(30); +const MAX_REMOTE_LIVE_PENDING_PER_PEER: usize = 16; +const MAX_REMOTE_LIVE_LIFECYCLES: usize = 128; +const MAX_REMOTE_LIVE_REPLAY_EVENTS: usize = REMOTE_LIVE_SUBSCRIPTION_CAPACITY; +const LIVE_ID_RANDOM_BYTES: usize = 16; +const MAX_LIVE_ID_ATTEMPTS: usize = 8; + +/// Transport-neutral host authority for the one implemented remote operation. +/// +/// Implementations are injected; the shared protocol layer never imports +/// Goose, Tauri, or a generic native-command dispatcher. +pub trait RemoteRuntimeStatusProvider: Send + Sync { + fn runtime_status( + &self, + ) -> Pin> + Send + '_>>; +} + +/// Exact transport-neutral host authority shared with embedded Tauri history. +pub trait RemoteAgentHistoryProvider: Send + Sync { + fn list_agent_history( + &self, + request: &ListAgentHistoryRecordsRequest, + ) -> Pin> + Send + '_>>; +} + +pub trait RemoteAgentSessionListProvider: Send + Sync { + fn list_agent_sessions( + &self, + request: &ListAgentSessionsRequest, + ) -> Pin> + Send + '_>>; +} + +#[cfg(desktop)] +#[derive(Clone)] +pub struct RemoteAgentLiveRpcHost { + inner: Arc, +} + +#[cfg(desktop)] +struct RemoteAgentLiveRpcHostInner { + provider: Arc, + /// Every mutation is non-blocking and may therefore be linearized inside + /// `VerifiedIncomingPeerAuthorization::with_current` while its admission + /// read guard is held. No async work runs under this mutex. + state: StdMutex, +} + +#[cfg(desktop)] +#[derive(Default)] +struct RemoteAgentLiveRpcState { + pending: HashMap, + activating: HashMap, + active: HashMap, +} + +#[cfg(desktop)] +struct PendingRemoteAgentLiveAttach { + authority: VerifiedIncomingPeerAuthorization, + service: Arc, + activate: Option>, + cancellation: Arc, + expires_at: Option, +} + +#[cfg(desktop)] +struct ActivateRemoteAgentLiveCommand { + live_stream_id: String, + response: oneshot::Sender>, +} + +#[cfg(desktop)] +struct ActivatingRemoteAgentLiveStream { + authority: VerifiedIncomingPeerAuthorization, + service: Arc, + cancellation: Arc, + /// Becomes visible in StreamStart before the slot is promoted to Active. + /// Keeping it on the continuously owned Activating slot lets an exact + /// ActiveStream cancellation find the lifecycle in that narrow window. + public_live_stream_id: Option, +} + +#[cfg(desktop)] +struct ActiveRemoteAgentLiveStream { + authority: VerifiedIncomingPeerAuthorization, + service: Arc, + cancellation: Arc, + /// Retained until native unsubscribe acknowledgement so a controller that + /// has not yet learned `live_stream_id` can still cancel by its attach ID. + activation_id: String, +} + +#[cfg(desktop)] +#[derive(Default)] +struct RemoteAgentLiveCancellation { + requested: AtomicBool, + requested_notify: tokio::sync::Notify, + completion: StdMutex>>, + completion_notify: tokio::sync::Notify, +} + +#[cfg(desktop)] +impl RemoteAgentLiveCancellation { + fn is_requested(&self) -> bool { + self.requested.load(Ordering::Acquire) + } + + async fn wait_requested(&self) { + loop { + let notified = self.requested_notify.notified(); + if self.is_requested() { + return; + } + notified.await; + } + } + + fn request(&self) { + self.requested.store(true, Ordering::Release); + self.requested_notify.notify_waiters(); + } + + async fn wait_completion(&self) -> Result<(), ProtocolError> { + loop { + let notified = self.completion_notify.notified(); + if let Some(result) = self + .completion + .lock() + .map_err(|_| live_lifecycle_state_error())? + .clone() + { + return result; + } + notified.await; + } + } + + async fn request_and_wait(&self) -> Result<(), ProtocolError> { + self.request(); + self.wait_completion().await + } + + fn completed_successfully(&self) -> bool { + self.completion + .lock() + .map(|completion| matches!(completion.as_ref(), Some(Ok(())))) + .unwrap_or(false) + } + + fn complete(&self, result: Result<(), ProtocolError>) { + if let Ok(mut completion) = self.completion.lock() { + if completion.is_none() { + *completion = Some(result); + self.completion_notify.notify_waiters(); + } + } + } +} + +#[cfg(desktop)] +impl RemoteAgentLiveRpcHost { + pub fn unavailable() -> Self { + Self::new(Arc::new( + crate::agent_live_host::UnavailableAgentLiveRemoteAttachProvider, + )) + } + + pub(crate) fn new(provider: Arc) -> Self { + Self { + inner: Arc::new(RemoteAgentLiveRpcHostInner { + provider, + state: StdMutex::new(RemoteAgentLiveRpcState::default()), + }), + } + } + + async fn bind_service( + &self, + authority: &VerifiedIncomingPeerAuthorization, + ) -> Result, ProtocolError> { + authority.revalidate_current()?; + let service = self + .inner + .provider + .bind(authority.clone()) + .await + .map_err(map_live_attach_error)?; + authority.revalidate_current()?; + Ok(service) + } + + fn with_current_state( + &self, + authority: &VerifiedIncomingPeerAuthorization, + operation: impl FnOnce(&mut RemoteAgentLiveRpcState) -> Result, + ) -> Result { + authority.with_current(|| { + let mut state = self + .inner + .state + .lock() + .map_err(|_| live_lifecycle_state_error())?; + operation(&mut state) + })? + } + + async fn install_pending( + &self, + attach_id: String, + authority: VerifiedIncomingPeerAuthorization, + service: Arc, + activate: oneshot::Sender, + cancellation: Arc, + ) -> Result<(), ProtocolError> { + let admission = authority.clone(); + self.with_current_state(&admission, move |state| { + prune_closed_lifecycles(state); + let pending_for_peer = state + .pending + .values() + .filter(|known| same_live_occupancy(&known.authority, &authority)) + .count(); + if live_lifecycle_count(state) >= MAX_REMOTE_LIVE_LIFECYCLES + || pending_for_peer >= MAX_REMOTE_LIVE_PENDING_PER_PEER + || stable_occupancy_in_use(state, &authority) + || lifecycle_id_in_use(state, &attach_id) + { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote Agent live attachment capacity is unavailable", + true, + )); + } + state.pending.insert( + attach_id, + PendingRemoteAgentLiveAttach { + authority, + service, + activate: Some(activate), + cancellation, + expires_at: None, + }, + ); + Ok(()) + }) + } + + async fn arm_pending( + &self, + authority: &VerifiedIncomingPeerAuthorization, + attach_id: &str, + ) -> Result { + self.arm_pending_for(authority, attach_id, REMOTE_LIVE_PENDING_TTL) + .await + } + + async fn arm_pending_for( + &self, + authority: &VerifiedIncomingPeerAuthorization, + attach_id: &str, + ttl: Duration, + ) -> Result { + if ttl.is_zero() { + return Err(live_lifecycle_state_error()); + } + let deadline = tokio::time::Instant::now() + ttl; + self.with_current_state(authority, |state| { + let pending = state.pending.get_mut(attach_id).ok_or_else(|| { + live_lifecycle_unavailable("attachment owner closed before arming") + })?; + if !same_remote_authority(&pending.authority, authority) { + return Err(stale_live_lease()); + } + if pending.expires_at.replace(deadline).is_some() { + return Err(live_lifecycle_state_error()); + } + Ok(deadline) + }) + } + + async fn take_pending_for_activation( + &self, + authority: &VerifiedIncomingPeerAuthorization, + attach_id: &str, + ) -> Result { + self.with_current_state(authority, |state| { + prune_closed_lifecycles(state); + match state.pending.get(attach_id) { + Some(pending) if !same_remote_authority(&pending.authority, authority) => { + return Err(stale_live_lease()); + } + Some(pending) + if pending + .expires_at + .is_some_and(|deadline| deadline <= tokio::time::Instant::now()) => + { + return Err(ProtocolError::new( + ErrorCode::AgentLiveUnavailable, + "remote Agent live attachment expired", + true, + )); + } + Some(pending) if pending.expires_at.is_none() => { + return Err(live_lifecycle_unavailable( + "remote Agent live snapshot is not complete", + )); + } + Some(_) => {} + None => { + return Err(ProtocolError::new( + ErrorCode::AgentLiveUnavailable, + "remote Agent live attachment was not found", + true, + )); + } + } + let pending = state + .pending + .remove(attach_id) + .expect("validated pending attachment exists"); + state.activating.insert( + attach_id.to_string(), + ActivatingRemoteAgentLiveStream { + authority: pending.authority.clone(), + service: Arc::clone(&pending.service), + cancellation: Arc::clone(&pending.cancellation), + public_live_stream_id: None, + }, + ); + // Cancellation ownership remains continuously reachable through the + // Activating entry. Only the command sender moves to the Control owner. + Ok(pending) + }) + } + + async fn reserve_activating( + &self, + live_stream_id: String, + authority: VerifiedIncomingPeerAuthorization, + service: Arc, + cancellation: Arc, + ) -> Result<(), ProtocolError> { + let admission = authority.clone(); + self.with_current_state(&admission, move |state| { + prune_closed_lifecycles(state); + if lifecycle_id_in_use(state, &live_stream_id) + || stable_occupancy_in_use(state, &authority) + || live_lifecycle_count(state) >= MAX_REMOTE_LIVE_LIFECYCLES + { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote Agent live stream acquisition capacity is unavailable", + true, + )); + } + state.activating.insert( + live_stream_id.clone(), + ActivatingRemoteAgentLiveStream { + authority, + service, + cancellation, + public_live_stream_id: Some(live_stream_id), + }, + ); + Ok(()) + }) + } + + async fn name_activating_stream( + &self, + activation_id: &str, + live_stream_id: &str, + authority: &VerifiedIncomingPeerAuthorization, + ) -> Result<(), ProtocolError> { + self.with_current_state(authority, |state| { + if lifecycle_id_in_use(state, live_stream_id) { + return Err(live_lifecycle_unavailable( + "remote Agent live stream id is already in use", + )); + } + let activating = state + .activating + .get_mut(activation_id) + .ok_or_else(|| live_lifecycle_unavailable("live acquisition was cancelled"))?; + if !same_remote_authority(&activating.authority, authority) { + return Err(stale_live_lease()); + } + if activating.cancellation.is_requested() + || activating + .public_live_stream_id + .replace(live_stream_id.into()) + .is_some() + { + return Err(live_lifecycle_unavailable("live acquisition was cancelled")); + } + Ok(()) + }) + } + + async fn promote_activating( + &self, + activation_id: &str, + live_stream_id: &str, + authority: &VerifiedIncomingPeerAuthorization, + ) -> Result<(), ProtocolError> { + self.with_current_state(authority, |state| { + let activating = state + .activating + .get(activation_id) + .ok_or_else(|| live_lifecycle_unavailable("live acquisition was cancelled"))?; + if !same_remote_authority(&activating.authority, authority) { + return Err(stale_live_lease()); + } + if activating.cancellation.is_requested() + || activating.public_live_stream_id.as_deref() != Some(live_stream_id) + { + return Err(live_lifecycle_unavailable("live acquisition was cancelled")); + } + let activating = state + .activating + .remove(activation_id) + .expect("validated activating lifecycle exists"); + state.active.insert( + live_stream_id.to_string(), + ActiveRemoteAgentLiveStream { + authority: activating.authority, + service: activating.service, + cancellation: activating.cancellation, + activation_id: activation_id.to_string(), + }, + ); + Ok(()) + }) + } + + async fn remove_pending(&self, live_id: &str, authority: &VerifiedIncomingPeerAuthorization) { + let Ok(mut state) = self.inner.state.lock() else { + return; + }; + if state + .pending + .get(live_id) + .is_some_and(|known| same_remote_authority(&known.authority, authority)) + { + state.pending.remove(live_id); + } + } + + async fn remove_activating( + &self, + live_id: &str, + authority: &VerifiedIncomingPeerAuthorization, + ) { + let Ok(mut state) = self.inner.state.lock() else { + return; + }; + if state + .activating + .get(live_id) + .is_some_and(|known| same_remote_authority(&known.authority, authority)) + { + state.activating.remove(live_id); + } + } + + async fn remove_active(&self, live_id: &str, authority: &VerifiedIncomingPeerAuthorization) { + let Ok(mut state) = self.inner.state.lock() else { + return; + }; + if state + .active + .get(live_id) + .is_some_and(|known| same_remote_authority(&known.authority, authority)) + { + state.active.remove(live_id); + } + } + + async fn cancel_lifecycle( + &self, + authority: &VerifiedIncomingPeerAuthorization, + kind: AgentLiveCancelKind, + live_id: &str, + ) -> Result<(), ProtocolError> { + let cancellation = self.with_current_state(authority, |state| { + prune_closed_lifecycles(state); + Ok(match kind { + AgentLiveCancelKind::PendingAttach => { + let lifecycle = state + .pending + .get(live_id) + .map(|known| (&known.authority, &known.cancellation)) + .or_else(|| { + state + .activating + .get(live_id) + .map(|known| (&known.authority, &known.cancellation)) + }) + .or_else(|| { + state + .active + .values() + .find(|known| known.activation_id == live_id) + .map(|known| (&known.authority, &known.cancellation)) + }); + match lifecycle { + Some((known, _)) if !same_remote_authority(known, authority) => { + return Err(stale_live_lease()); + } + Some((_, cancellation)) => Some(Arc::clone(cancellation)), + None => None, + } + } + AgentLiveCancelKind::ActiveStream => match state.active.get(live_id) { + Some(active) if !same_remote_authority(&active.authority, authority) => { + return Err(stale_live_lease()); + } + Some(active) => Some(Arc::clone(&active.cancellation)), + None => { + let lifecycle = state + .activating + .values() + .find(|known| known.public_live_stream_id.as_deref() == Some(live_id)); + match lifecycle { + Some(known) if !same_remote_authority(&known.authority, authority) => { + return Err(stale_live_lease()); + } + Some(known) => Some(Arc::clone(&known.cancellation)), + None => None, + } + } + }, + }) + })?; + if let Some(cancellation) = cancellation { + cancellation.request_and_wait().await?; + } + Ok(()) + } + + async fn revoke_binding_lease( + &self, + revoked: &AgentLiveBindingLease, + ) -> Result<(), ProtocolError> { + let Some(revoked_authority) = revoked.remote_authority() else { + return Err(live_lifecycle_state_error()); + }; + let cancellations = { + let state = self + .inner + .state + .lock() + .map_err(|_| live_lifecycle_state_error())?; + let mut cancellations = Vec::new(); + let mut collect = + |authority: &VerifiedIncomingPeerAuthorization, + cancellation: &Arc| { + if same_remote_authority(authority, revoked_authority) { + cancellations.push(Arc::clone(cancellation)); + } + }; + for known in state.pending.values() { + collect(&known.authority, &known.cancellation); + } + for known in state.activating.values() { + collect(&known.authority, &known.cancellation); + } + for known in state.active.values() { + collect(&known.authority, &known.cancellation); + } + cancellations + }; + for cancellation in &cancellations { + cancellation.request(); + } + for cancellation in cancellations { + cancellation.wait_completion().await?; + } + Ok(()) + } +} + +#[cfg(desktop)] +#[async_trait::async_trait] +impl AgentLivePeerRevocationHook for RemoteAgentLiveRpcHost { + async fn revoke_exact_peer( + &self, + revoked: &AgentLiveBindingLease, + ) -> Result<(), AgentLiveHostError> { + self.revoke_binding_lease(revoked) + .await + .map_err(|_| AgentLiveHostError::BoundContextRevoked) + } +} + +#[cfg(desktop)] +fn prune_closed_lifecycles(state: &mut RemoteAgentLiveRpcState) { + // A lifecycle may leave the registry only after its exact native + // cancel/unsubscribe has acknowledged. Errors remain fail-closed and keep + // stable occupancy rather than allowing a second overlapping subscriber. + state + .pending + .retain(|_, pending| !pending.cancellation.completed_successfully()); + state + .activating + .retain(|_, known| !known.cancellation.completed_successfully()); + state + .active + .retain(|_, known| !known.cancellation.completed_successfully()); +} + +#[cfg(desktop)] +fn lifecycle_id_in_use(state: &RemoteAgentLiveRpcState, id: &str) -> bool { + state.pending.contains_key(id) + || state.activating.contains_key(id) + || state + .activating + .values() + .any(|known| known.public_live_stream_id.as_deref() == Some(id)) + || state.active.contains_key(id) + || state.active.values().any(|known| known.activation_id == id) +} + +#[cfg(desktop)] +fn live_lifecycle_count(state: &RemoteAgentLiveRpcState) -> usize { + state.pending.len() + state.activating.len() + state.active.len() +} + +#[cfg(desktop)] +fn stable_occupancy_in_use( + state: &RemoteAgentLiveRpcState, + authority: &VerifiedIncomingPeerAuthorization, +) -> bool { + state + .pending + .values() + .any(|known| same_live_occupancy(&known.authority, authority)) + || state + .activating + .values() + .any(|known| same_live_occupancy(&known.authority, authority)) + || state + .active + .values() + .any(|known| same_live_occupancy(&known.authority, authority)) +} + +#[cfg(desktop)] +fn same_live_occupancy( + left: &VerifiedIncomingPeerAuthorization, + right: &VerifiedIncomingPeerAuthorization, +) -> bool { + left.same_admission_instance(right) + && left.authorization().account_epoch() == right.authorization().account_epoch() + && left.controller_endpoint() == right.controller_endpoint() + && left.execution_target_id() == right.execution_target_id() +} + +#[cfg(desktop)] +fn same_remote_authority( + left: &VerifiedIncomingPeerAuthorization, + right: &VerifiedIncomingPeerAuthorization, +) -> bool { + left.same_admission_instance(right) + && left.authorization() == right.authorization() + && left.controller_endpoint() == right.controller_endpoint() + && left.execution_target_id() == right.execution_target_id() + && left.pairing_fence() == right.pairing_fence() + && left.connection_stamp() == right.connection_stamp() +} + +#[cfg(desktop)] +fn stale_live_lease() -> ProtocolError { + ProtocolError::new( + ErrorCode::Revoked, + "remote Agent live lifecycle belongs to another authorization", + false, + ) +} + +#[cfg(desktop)] +fn live_lifecycle_state_error() -> ProtocolError { + ProtocolError::new( + ErrorCode::Internal, + "remote Agent live lifecycle state is unavailable", + false, + ) +} + +#[cfg(desktop)] +fn live_lifecycle_unavailable(message: &'static str) -> ProtocolError { + ProtocolError::new(ErrorCode::AgentLiveUnavailable, message, true) +} + +pub async fn get_remote_agent_sessions_page( + manager: &GenerationConnectionManager, + request_id: &str, + body: ListAgentSessionsRequest, +) -> Result { + let peer = manager.current()?.ok_or_else(|| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote execution target has no current connection", + true, + ) + })?; + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: peer.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: peer.connection_stamp(), + body, + }; + let response: ResponseEnvelope = peer.request(&request).await?; + response.result +} + +/// Fetch one native-record-count page over the Bulk lane. The response uses a +/// typed multi-frame sequence so aggregate bytes never redefine the requested +/// record count. +pub async fn get_remote_agent_history_page( + manager: &GenerationConnectionManager, + request_id: &str, + body: ListAgentHistoryRecordsRequest, +) -> Result { + let peer = manager.current()?.ok_or_else(|| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote execution target has no current connection", + true, + ) + })?; + get_remote_agent_history_page_on_peer(&peer, request_id, body).await +} + +pub struct RemoteAgentLiveEventStream { + peer: ConnectedPeer, + response: StreamingResponse, + attach_id: String, + last_cursor: RemoteLiveEventCursor, + replay_through: Option, + live_stream_id: Option, + replay_complete: bool, +} + +pub struct ResumedRemoteAgentLiveEventStream { + peer: ConnectedPeer, + response: StreamingResponse, + last_cursor: RemoteLiveEventCursor, + replay_through: RemoteLiveEventCursor, + live_stream_id: String, + replay_complete: bool, +} + +pub async fn begin_remote_agent_live_attach( + manager: &GenerationConnectionManager, + request_id: &str, + body: BeginAgentLiveAttachRequest, +) -> Result<(RemoteAgentLiveHeadSnapshot, RemoteAgentLiveEventStream), ProtocolError> { + let peer = manager.current()?.ok_or_else(|| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote execution target has no current connection", + true, + ) + })?; + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: peer.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: peer.connection_stamp(), + body, + }; + let mut response = peer.start_streaming_request(request).await?; + let start: ResponseEnvelope = response.read().await?; + let (attach_id, record_count, live_session_count, through_event_cursor) = match start.result? { + AgentLiveStreamFrame::SnapshotStart { + attach_id, + record_count, + live_session_count, + live_sessions_complete: true, + through_event_cursor, + } => ( + attach_id, + record_count, + live_session_count, + through_event_cursor, + ), + _ => { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "remote Agent live attach did not start with a complete snapshot", + false, + )); + } + }; + let mut records = Vec::with_capacity(usize::from(record_count)); + for expected_index in 0..record_count { + let frame: ResponseEnvelope = response.read().await?; + match frame.result? { + AgentLiveStreamFrame::HistoryRecord { index, record } if index == expected_index => { + records.push(record); + } + _ => { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "remote Agent live history records are discontinuous", + false, + )); + } + } + } + let mut live_sessions = Vec::with_capacity(usize::from(live_session_count)); + let mut account_live_items = 0usize; + let mut account_live_projection_bytes = LIVE_PROJECTION_OUTER_OVERHEAD_BYTES; + let mut previous_session_id: Option = None; + for expected_index in 0..live_session_count { + let frame: ResponseEnvelope = response.read().await?; + let (session_id, item_count) = match frame.result? { + AgentLiveStreamFrame::LiveSessionStart { + index, + session_id, + item_count, + } if index == expected_index + && previous_session_id + .as_deref() + .is_none_or(|previous| previous < session_id.as_str()) => + { + (session_id, item_count) + } + _ => { + return Err(invalid_live_response( + "remote Agent live session snapshot is discontinuous", + )); + } + }; + account_live_items = account_live_items + .checked_add(usize::from(item_count)) + .ok_or_else(|| invalid_live_response("live snapshot item count overflow"))?; + if account_live_items > crate::remote_protocol::MAX_LIVE_ITEMS_PER_ACCOUNT { + return Err(invalid_live_response( + "live account snapshot contains too many items", + )); + } + accumulate_remote_live_projection_bytes( + &mut account_live_projection_bytes, + remote_live_projection_session_wire_bytes(&session_id)?, + )?; + let mut live_items = Vec::with_capacity(usize::from(item_count)); + for expected_item_index in 0..item_count { + let frame: ResponseEnvelope = response.read().await?; + match frame.result? { + AgentLiveStreamFrame::LiveSessionItem { + session_index, + item_index, + item, + } if session_index == expected_index && item_index == expected_item_index => { + accumulate_remote_live_projection_bytes( + &mut account_live_projection_bytes, + remote_live_projection_item_wire_bytes(&item)?, + )?; + live_items.push(item); + } + _ => { + return Err(invalid_live_response( + "remote Agent live session items are discontinuous", + )); + } + } + } + let snapshot = RemoteAgentLiveSessionSnapshot { + session_id, + live_items, + }; + snapshot.validate()?; + previous_session_id = Some(snapshot.session_id.clone()); + live_sessions.push(snapshot); + } + let footer: ResponseEnvelope = response.read().await?; + let (next_cursor, history_revision) = match footer.result? { + AgentLiveStreamFrame::SnapshotEnd { + next_cursor, + history_revision, + } => (next_cursor, history_revision), + _ => { + return Err(invalid_live_response( + "remote Agent live attach snapshot has no footer", + )); + } + }; + if records.is_empty() && next_cursor.is_some() { + return Err(invalid_live_response( + "empty live history head cannot contain a continuation cursor", + )); + } + let stream = RemoteAgentLiveEventStream { + peer: peer.clone(), + response, + attach_id: attach_id.clone(), + last_cursor: through_event_cursor.clone(), + replay_through: None, + live_stream_id: None, + replay_complete: false, + }; + Ok(( + RemoteAgentLiveHeadSnapshot { + attach_id, + records, + next_cursor, + history_revision, + live_sessions, + through_event_cursor, + origin_host_epoch: peer.connection_stamp().host_epoch(), + }, + stream, + )) +} + +fn accumulate_remote_live_projection_bytes( + retained_bytes: &mut usize, + additional_bytes: usize, +) -> Result<(), ProtocolError> { + let next = retained_bytes + .checked_add(additional_bytes) + .ok_or_else(|| invalid_live_response("remote Agent live projection byte count overflow"))?; + if next > MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT { + return Err(invalid_live_response( + "remote Agent live projection exceeds the account byte limit", + )); + } + *retained_bytes = next; + Ok(()) +} + +async fn request_remote_agent_live_activation( + manager: &GenerationConnectionManager, + request_id: &str, + attach_id: &str, +) -> Result { + let peer = manager.current()?.ok_or_else(|| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote execution target has no current connection", + true, + ) + })?; + request_remote_agent_live_activation_on_peer(&peer, request_id, attach_id).await +} + +async fn request_remote_agent_live_activation_on_peer( + peer: &ConnectedPeer, + request_id: &str, + attach_id: &str, +) -> Result { + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: peer.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: peer.connection_stamp(), + body: ActivateAgentLiveAttachRequest::new(attach_id)?, + }; + let response: ResponseEnvelope = + peer.request(&request).await?; + response.result +} + +/// Activate one paused C0 attachment while continuously draining its Events +/// stream. Every replay delivery is handed to `apply_replay` before the next +/// frame is read; the API therefore cannot aggregate a large replay or block +/// the host's bounded frame writer behind an unread Events stream. +/// +/// This function takes ownership of the Events stream and returns it only +/// after the Control acknowledgement and Events replay barrier agree. If the +/// activation future is cancelled, dropping its owned receive half abandons +/// the response stream and wakes the host's acknowledged native cleanup path. +pub async fn activate_remote_agent_live_attach( + activation_request_id: &str, + cancellation_request_id: &str, + mut stream: RemoteAgentLiveEventStream, + mut apply_replay: F, +) -> Result<(RemoteAgentLiveStreamStart, RemoteAgentLiveEventStream), ProtocolError> +where + F: FnMut(RemoteAgentLiveDelivery) -> Fut, + Fut: Future>, +{ + let attach_id = stream.attach_id.clone(); + let peer = stream.peer.clone(); + let result = tokio::try_join!( + request_remote_agent_live_activation_on_peer(&peer, activation_request_id, &attach_id), + stream.pump_activation(&mut apply_replay), + ); + let (response, start) = match result { + Ok(result) => result, + Err(error) => { + let (kind, live_id) = match stream.live_stream_id.as_deref() { + Some(live_stream_id) => (AgentLiveCancelKind::ActiveStream, live_stream_id), + None => (AgentLiveCancelKind::PendingAttach, attach_id.as_str()), + }; + return match cancel_remote_agent_live_on_peer( + &peer, + cancellation_request_id, + kind, + live_id, + ) + .await + { + Ok(()) => Err(error), + Err(cleanup_error) => Err(cleanup_error), + }; + } + }; + match response.result { + AgentLiveActivationDisposition::Activated { + live_stream_id, + through_event_cursor, + } if live_stream_id == start.live_stream_id + && through_event_cursor == start.through_event_cursor => + { + Ok((start, stream)) + } + AgentLiveActivationDisposition::SnapshotRequired { + reason, + last_event_cursor, + } => { + cancel_remote_agent_live_on_peer( + &peer, + cancellation_request_id, + AgentLiveCancelKind::ActiveStream, + &start.live_stream_id, + ) + .await?; + Err(snapshot_required_error(reason, &last_event_cursor)) + } + AgentLiveActivationDisposition::Activated { .. } => { + cancel_remote_agent_live_on_peer( + &peer, + cancellation_request_id, + AgentLiveCancelKind::ActiveStream, + &start.live_stream_id, + ) + .await?; + Err(invalid_live_response( + "remote Agent live activation Control and Events barriers disagree", + )) + } + } +} + +impl RemoteAgentLiveEventStream { + async fn read_start(&mut self) -> Result { + if self.live_stream_id.is_some() { + return Err(invalid_live_response( + "remote Agent live stream already started", + )); + } + let frame: ResponseEnvelope = self.response.read().await?; + let (live_stream_id, from_event_cursor, through_event_cursor) = match frame.result? { + AgentLiveStreamFrame::StreamStart { + live_stream_id, + from_event_cursor, + through_event_cursor, + } => (live_stream_id, from_event_cursor, through_event_cursor), + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor, + } => return Err(snapshot_required_error(reason, &last_event_cursor)), + _ => { + return Err(invalid_live_response( + "activated remote Agent live stream has no replay header", + )); + } + }; + if from_event_cursor != self.last_cursor { + return Err(invalid_live_response( + "activated remote Agent live stream starts after the snapshot cursor", + )); + } + self.replay_through = Some(through_event_cursor.clone()); + self.live_stream_id = Some(live_stream_id.clone()); + self.replay_complete = false; + Ok(RemoteAgentLiveStreamStart { + live_stream_id, + from_event_cursor, + through_event_cursor, + }) + } + + async fn pump_activation( + &mut self, + apply_replay: &mut F, + ) -> Result + where + F: FnMut(RemoteAgentLiveDelivery) -> Fut, + Fut: Future>, + { + let start = self.read_start().await?; + loop { + let frame: ResponseEnvelope = self.response.read().await?; + match frame.result? { + AgentLiveStreamFrame::Event { delivery } => { + validate_next_remote_delivery( + &self.last_cursor, + &delivery, + Some(&start.through_event_cursor), + )?; + self.last_cursor = delivery.cursor.clone(); + apply_replay(delivery).await?; + } + AgentLiveStreamFrame::ReplayComplete { + through_event_cursor, + } if through_event_cursor == start.through_event_cursor + && self.last_cursor == through_event_cursor => + { + self.replay_complete = true; + return Ok(start); + } + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor, + } => return Err(snapshot_required_error(reason, &last_event_cursor)), + _ => { + return Err(invalid_live_response( + "remote Agent live replay frame is out of order", + )) + } + } + } + } + + pub async fn recv(&mut self) -> Result { + let through = self + .replay_through + .clone() + .ok_or_else(|| invalid_live_response("remote Agent live stream was not activated"))?; + loop { + let frame: ResponseEnvelope = self.response.read().await?; + match frame.result? { + AgentLiveStreamFrame::Event { delivery } => { + validate_next_remote_delivery( + &self.last_cursor, + &delivery, + (!self.replay_complete).then_some(&through), + )?; + self.last_cursor = delivery.cursor.clone(); + return Ok(delivery); + } + AgentLiveStreamFrame::ReplayComplete { + through_event_cursor, + } if !self.replay_complete + && through_event_cursor == through + && self.last_cursor == through_event_cursor => + { + self.replay_complete = true; + } + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor, + } => return Err(snapshot_required_error(reason, &last_event_cursor)), + _ => { + return Err(invalid_live_response( + "remote Agent live frame is out of order", + )) + } + } + } + } + + /// Cancel this stream on the exact connection generation which owns it. + /// Consuming `self` also abandons the Events response if the Control + /// cancellation cannot be delivered, waking host-side cleanup. + pub async fn cancel(self, request_id: &str) -> Result<(), ProtocolError> { + let live_stream_id = self + .live_stream_id + .as_deref() + .ok_or_else(|| invalid_live_response("remote Agent live stream was not activated"))?; + cancel_remote_agent_live_on_peer( + &self.peer, + request_id, + AgentLiveCancelKind::ActiveStream, + live_stream_id, + ) + .await + } + + pub async fn finish(self) -> Result<(), ProtocolError> { + self.response.finish().await + } +} + +pub async fn resume_remote_agent_live_events( + manager: &GenerationConnectionManager, + request_id: &str, + cursor: RemoteLiveEventCursor, + origin_host_epoch: u64, +) -> Result { + let peer = manager.current()?.ok_or_else(|| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote execution target has no current connection", + true, + ) + })?; + let body = ResumeAgentLiveEventsRequest::new(cursor.clone(), origin_host_epoch)?; + if let Err(error) = body.validate_for_connection_stamp(peer.connection_stamp()) { + if error.code == ErrorCode::StaleGeneration { + return Err(snapshot_required_error( + RemoteAgentLiveSnapshotReason::OwnerChanged, + &cursor, + )); + } + return Err(error); + } + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: peer.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: peer.connection_stamp(), + body, + }; + let mut response = peer.start_streaming_request(request).await?; + let start: ResponseEnvelope = response.read().await?; + let (live_stream_id, from_event_cursor, through_event_cursor) = match start.result? { + AgentLiveStreamFrame::StreamStart { + live_stream_id, + from_event_cursor, + through_event_cursor, + } => (live_stream_id, from_event_cursor, through_event_cursor), + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor, + } => return Err(snapshot_required_error(reason, &last_event_cursor)), + _ => { + return Err(invalid_live_response( + "resumed remote Agent live stream has no replay header", + )) + } + }; + if from_event_cursor != cursor { + return Err(invalid_live_response( + "resumed remote Agent live stream starts at another cursor", + )); + } + Ok(ResumedRemoteAgentLiveEventStream { + peer, + response, + last_cursor: cursor, + replay_through: through_event_cursor, + live_stream_id, + replay_complete: false, + }) +} + +impl ResumedRemoteAgentLiveEventStream { + pub fn live_stream_id(&self) -> &str { + &self.live_stream_id + } + + pub async fn recv(&mut self) -> Result { + loop { + let frame: ResponseEnvelope = self.response.read().await?; + match frame.result? { + AgentLiveStreamFrame::Event { delivery } => { + validate_next_remote_delivery( + &self.last_cursor, + &delivery, + (!self.replay_complete).then_some(&self.replay_through), + )?; + self.last_cursor = delivery.cursor.clone(); + return Ok(delivery); + } + AgentLiveStreamFrame::ReplayComplete { + through_event_cursor, + } if !self.replay_complete + && through_event_cursor == self.replay_through + && self.last_cursor == through_event_cursor => + { + self.replay_complete = true; + } + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor, + } => return Err(snapshot_required_error(reason, &last_event_cursor)), + _ => { + return Err(invalid_live_response( + "remote Agent live frame is out of order", + )) + } + } + } + } + + /// Cancel this resumed stream on its exact connection generation. + pub async fn cancel(self, request_id: &str) -> Result<(), ProtocolError> { + cancel_remote_agent_live_on_peer( + &self.peer, + request_id, + AgentLiveCancelKind::ActiveStream, + &self.live_stream_id, + ) + .await + } + + pub async fn finish(self) -> Result<(), ProtocolError> { + self.response.finish().await + } +} + +pub async fn cancel_remote_agent_live( + manager: &GenerationConnectionManager, + request_id: &str, + kind: AgentLiveCancelKind, + live_id: &str, +) -> Result<(), ProtocolError> { + let peer = manager.current()?.ok_or_else(|| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote execution target has no current connection", + true, + ) + })?; + cancel_remote_agent_live_on_peer(&peer, request_id, kind, live_id).await +} + +async fn cancel_remote_agent_live_on_peer( + peer: &ConnectedPeer, + request_id: &str, + kind: AgentLiveCancelKind, + live_id: &str, +) -> Result<(), ProtocolError> { + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: peer.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: peer.connection_stamp(), + body: CancelAgentLiveRequest::new(kind, live_id)?, + }; + let response: ResponseEnvelope = peer.request(&request).await?; + response.result.map(|_| ()) +} + +async fn get_remote_agent_history_page_on_peer( + peer: &ConnectedPeer, + request_id: &str, + body: ListAgentHistoryRecordsRequest, +) -> Result { + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: peer.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: peer.connection_stamp(), + body, + }; + let mut response = peer.start_streaming_request(request).await?; + let start: ResponseEnvelope = response.read().await?; + let record_count = match start.result? { + AgentHistoryPageFrame::Start { record_count } => record_count, + _ => { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history response did not start with a page header", + false, + )); + } + }; + let mut records = Vec::with_capacity(usize::from(record_count)); + for expected_index in 0..record_count { + let frame: ResponseEnvelope = response.read().await?; + match frame.result? { + AgentHistoryPageFrame::Record { index, record } if index == expected_index => { + records.push(record); + } + AgentHistoryPageFrame::Record { .. } => { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history response record index is discontinuous", + false, + )); + } + _ => { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history response ended before every declared record", + false, + )); + } + } + } + let footer: ResponseEnvelope = response.read().await?; + let (next_cursor, history_revision) = match footer.result? { + AgentHistoryPageFrame::End { + next_cursor, + history_revision, + } => (next_cursor, history_revision), + _ => { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history response did not end with a page footer", + false, + )); + } + }; + response.finish().await?; + if records.is_empty() && next_cursor.is_some() { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "empty history page cannot contain a continuation cursor", + false, + )); + } + Ok(RemoteAgentHistoryPage { + records, + next_cursor, + history_revision, + }) +} + +/// Query status through the manager's exact current pairing/generation. +/// +/// A manager that is stale, has no live generation, or is in an ambiguous +/// handover state fails before an application stream is opened. Target ID and +/// connection stamp are always copied from the authenticated peer rather than +/// accepted as caller-controlled strings. +pub async fn get_remote_runtime_status( + manager: &GenerationConnectionManager, + request_id: &str, +) -> Result { + let peer = manager.current()?.ok_or_else(|| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote execution target has no current connection", + true, + ) + })?; + get_remote_runtime_status_on_peer(&peer, request_id).await +} + +async fn get_remote_runtime_status_on_peer( + peer: &ConnectedPeer, + request_id: &str, +) -> Result { + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: peer.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: peer.connection_stamp(), + body: GetRuntimeStatusRequest::new(), + }; + let response: ResponseEnvelope = peer.request(&request).await?; + Ok(response.result?.status) +} + +/// Accept and serve exactly one typed runtime-status request. +/// +/// The provider work shares the stream's absolute operation deadline and is +/// dropped immediately if the controller abandons its response stream. No +/// operation name reaches Tauri or Maple's desktop command table. +#[cfg(test)] +pub async fn serve_next_remote_runtime_status

( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + provider: &P, +) -> Result<(), ProtocolError> +where + P: RemoteRuntimeStatusProvider + ?Sized, +{ + serve_next_remote_runtime_status_with_timeout( + host_endpoint, + peer, + provider, + RUNTIME_STATUS_PROVIDER_TIMEOUT, + ) + .await +} + +#[cfg(test)] +async fn serve_next_remote_runtime_status_with_timeout

( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + provider: &P, + provider_timeout: Duration, +) -> Result<(), ProtocolError> +where + P: RemoteRuntimeStatusProvider + ?Sized, +{ + if provider_timeout.is_zero() { + return Err(ProtocolError::new( + ErrorCode::Internal, + "runtime status provider timeout is invalid", + false, + )); + } + host_endpoint.validate_current_incoming_peer(peer)?; + let accepted = peer.accept_stream().await?; + let request: AcceptedRequest = accepted.read_request().await?; + serve_remote_runtime_status_request(host_endpoint, peer, provider, request, provider_timeout) + .await +} + +async fn serve_remote_runtime_status_request( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + provider: &P, + request: AcceptedRequest, + provider_timeout: Duration, +) -> Result<(), ProtocolError> +where + T: crate::remote_protocol::RequestBody, + GetRuntimeStatusResponse: crate::remote_protocol::ResponseBody, + P: RemoteRuntimeStatusProvider + ?Sized, +{ + if provider_timeout.is_zero() { + return Err(ProtocolError::new( + ErrorCode::Internal, + "runtime status provider timeout is invalid", + false, + )); + } + host_endpoint.validate_current_incoming_peer(peer)?; + let operation_deadline = request.operation_deadline(); + let now = tokio::time::Instant::now(); + let latest_provider_deadline = operation_deadline + .checked_sub(RUNTIME_STATUS_RESPONSE_BUDGET) + .unwrap_or(now); + let provider_deadline = latest_provider_deadline.min(now + provider_timeout); + let mut response_cancelled = Box::pin(request.response_cancelled()); + let mut provider_result = provider.runtime_status(); + + let result = tokio::select! { + biased; + cancelled = &mut response_cancelled => { + return match cancelled { + Ok(()) => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote runtime status request was cancelled", + true, + )), + Err(error) => Err(error), + }; + } + provider_result = tokio::time::timeout_at(provider_deadline, provider_result.as_mut()) => { + match provider_result { + Ok(Ok(status)) => GetRuntimeStatusResponse::new(status), + Ok(Err(error)) => Err(error), + Err(_) => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote runtime status provider deadline elapsed", + true, + )), + } + } + }; + // The provider may retain account-scoped resources. Drop it before any + // potentially slow network response write, including the timeout path. + drop(provider_result); + drop(response_cancelled); + host_endpoint.validate_current_incoming_peer(peer)?; + + let envelope = request.request(); + let response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: envelope.request_id.clone(), + execution_target_id: envelope.execution_target_id.clone(), + connection_stamp: envelope.connection_stamp, + result, + }; + request.write_response(&response).await +} + +#[cfg(test)] +pub async fn serve_next_remote_agent_sessions_page

( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + provider: &P, +) -> Result<(), ProtocolError> +where + P: RemoteAgentSessionListProvider + ?Sized, +{ + host_endpoint.validate_current_incoming_peer(peer)?; + let accepted = peer.accept_stream().await?; + let request: AcceptedRequest = accepted.read_request().await?; + let body = request.request().body.clone(); + serve_remote_agent_sessions_page_request(host_endpoint, peer, provider, request, body).await +} + +async fn serve_remote_agent_sessions_page_request( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + provider: &P, + request: AcceptedRequest, + body: ListAgentSessionsRequest, +) -> Result<(), ProtocolError> +where + T: crate::remote_protocol::RequestBody, + ListAgentSessionsResponse: crate::remote_protocol::ResponseBody, + P: RemoteAgentSessionListProvider + ?Sized, +{ + host_endpoint.validate_current_incoming_peer(peer)?; + let operation_deadline = request.operation_deadline(); + let now = tokio::time::Instant::now(); + let provider_deadline = operation_deadline + .checked_sub(AGENT_HISTORY_RESPONSE_BUDGET) + .unwrap_or(now) + .min(now + AGENT_HISTORY_PROVIDER_TIMEOUT); + let mut response_cancelled = Box::pin(request.response_cancelled()); + let mut provider_result = provider.list_agent_sessions(&body); + let result = tokio::select! { + biased; + cancelled = &mut response_cancelled => { + return match cancelled { + Ok(()) => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote Agent task-page request was cancelled", + true, + )), + Err(error) => Err(error), + }; + } + provider_result = tokio::time::timeout_at(provider_deadline, provider_result.as_mut()) => { + match provider_result { + Ok(result) => result, + Err(_) => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote Agent task-page provider deadline elapsed", + true, + )), + } + } + }; + drop(provider_result); + drop(response_cancelled); + host_endpoint.validate_current_incoming_peer(peer)?; + let envelope = request.request(); + let response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: envelope.request_id.clone(), + execution_target_id: envelope.execution_target_id.clone(), + connection_stamp: envelope.connection_stamp, + result, + }; + request.write_response(&response).await +} + +/// Complete authority set consumed by the one peer-wide request dispatcher. +/// Each field is transport-neutral and may retain only the account/runtime +/// authority deliberately injected by the native host. +#[cfg(desktop)] +#[derive(Clone)] +pub(crate) struct RemoteAgentRpcServer { + runtime: Arc, + history: Arc, + sessions: Arc, + live: RemoteAgentLiveRpcHost, +} + +#[cfg(desktop)] +impl RemoteAgentRpcServer { + pub(crate) fn new( + runtime: Arc, + history: Arc, + sessions: Arc, + live: RemoteAgentLiveRpcHost, + ) -> Self { + Self { + runtime, + history, + sessions, + live, + } + } +} + +/// Awaitable owner for one dispatched request worker. +/// +/// The inner Tokio abort handle is deliberately not exposed. Dropping this +/// value (including cancellation of a task awaiting it) detaches the worker +/// instead of aborting it, so a live worker continues through its remote +/// STOP/peer-close/revocation path and awaits native cancel/unsubscribe before +/// releasing occupancy. Runtime shutdown is process-terminal and is not an +/// in-process lifecycle reuse boundary. +#[cfg(desktop)] +#[must_use = "remote Agent request workers must be awaited or deliberately detached"] +pub(crate) struct RemoteAgentRpcWorker { + task: tokio::task::JoinHandle>, +} + +#[cfg(desktop)] +impl RemoteAgentRpcWorker { + fn spawn(worker: impl Future> + Send + 'static) -> Self { + Self { + task: tokio::spawn(worker), + } + } +} + +#[cfg(desktop)] +impl Future for RemoteAgentRpcWorker { + type Output = Result, tokio::task::JoinError>; + + fn poll( + self: Pin<&mut Self>, + context: &mut std::task::Context<'_>, + ) -> std::task::Poll { + Pin::new(&mut self.get_mut().task).poll(context) + } +} + +/// Accept, authenticate, and decode exactly one request, then start its owned +/// worker. This is the only production entry point which dequeues a prepared +/// application stream. Callers may invoke it serially in their connection +/// loop; the returned task lets long-lived Events work continue while the loop +/// accepts the corresponding Control operation. +#[cfg(desktop)] +pub(crate) async fn serve_next_remote_agent_request( + host_endpoint: Arc, + peer: ConnectedPeer, + server: RemoteAgentRpcServer, +) -> Result { + host_endpoint.validate_current_incoming_peer(&peer)?; + let accepted = peer.accept_stream().await?; + let lane = accepted.header().stream_kind; + match lane { + crate::remote_protocol::StreamKind::Control => { + let request: AcceptedRequest = + accepted.read_request().await?; + host_endpoint.validate_current_incoming_peer(&peer)?; + Ok(RemoteAgentRpcWorker::spawn(async move { + match request.request().body.clone() { + RemoteAgentControlRequest::GetRuntimeStatus => { + serve_remote_runtime_status_request( + host_endpoint.as_ref(), + &peer, + server.runtime.as_ref(), + request, + RUNTIME_STATUS_PROVIDER_TIMEOUT, + ) + .await + } + RemoteAgentControlRequest::ActivateAttach { attach_id } => { + let authority = + host_endpoint.verified_incoming_peer_authorization(&peer)?; + let body = ActivateAgentLiveAttachRequest::new(attach_id)?; + remote_live_server::serve_remote_agent_live_activation( + host_endpoint.as_ref(), + &peer, + &server.live, + authority, + request, + body, + ) + .await + } + RemoteAgentControlRequest::Cancel { kind, live_id } => { + let authority = + host_endpoint.verified_incoming_peer_authorization(&peer)?; + let body = CancelAgentLiveRequest::new(kind, live_id)?; + remote_live_server::serve_remote_agent_live_cancel( + host_endpoint.as_ref(), + &peer, + &server.live, + authority, + request, + body, + ) + .await + } + } + })) + } + crate::remote_protocol::StreamKind::Events => { + let request: AcceptedRequest = + accepted.read_request().await?; + let authority = host_endpoint.verified_incoming_peer_authorization(&peer)?; + Ok(RemoteAgentRpcWorker::spawn(async move { + match request.request().body.clone() { + RemoteAgentLiveEventsRequest::BeginAttach { session_id, limit } => { + let body = BeginAgentLiveAttachRequest::new(session_id, limit)?; + remote_live_server::serve_remote_agent_live_begin( + host_endpoint.as_ref(), + &peer, + &server.live, + authority, + request, + body, + ) + .await + } + RemoteAgentLiveEventsRequest::Resume { + cursor, + origin_host_epoch, + } => { + let body = ResumeAgentLiveEventsRequest::new(cursor, origin_host_epoch)?; + remote_live_server::serve_remote_agent_live_resume( + host_endpoint.as_ref(), + &peer, + &server.live, + authority, + request, + body, + ) + .await + } + } + })) + } + crate::remote_protocol::StreamKind::Bulk => { + let request: AcceptedRequest = accepted.read_request().await?; + host_endpoint.validate_current_incoming_peer(&peer)?; + Ok(RemoteAgentRpcWorker::spawn(async move { + match request.request().body.clone() { + RemoteAgentBulkRequest::ListSessionRecords { + session_id, + cursor, + limit, + } => { + let body = ListAgentHistoryRecordsRequest::new(session_id, cursor, limit)?; + serve_remote_agent_history_page_request( + host_endpoint.as_ref(), + &peer, + server.history.as_ref(), + request, + body, + AGENT_HISTORY_PROVIDER_TIMEOUT, + ) + .await + } + RemoteAgentBulkRequest::ListSessions { + project_root, + cursor, + limit, + } => { + let body = ListAgentSessionsRequest { + operation: + crate::remote_protocol::AgentSessionListOperation::ListSessions, + project_root, + cursor, + limit, + }; + serve_remote_agent_sessions_page_request( + host_endpoint.as_ref(), + &peer, + server.sessions.as_ref(), + request, + body, + ) + .await + } + } + })) + } + } +} + +#[cfg(desktop)] +mod remote_live_server { + use super::*; + + pub(super) enum PendingActivationResult { + Activated(crate::agent_live_host::AgentLiveRemoteActivated), + NativeError(AgentLiveRemoteAttachError), + Interrupted(ProtocolError), + } + + /// Poll native activation until it finishes or the request owner is + /// interrupted. The activation future is scoped entirely inside this + /// helper, so it is dropped before the caller can invoke `pending.cancel()`. + /// That ordering is the cancellation-safety boundary promised by + /// `AgentLiveRemotePendingAttach`. + pub(super) async fn activate_pending_until_interrupted( + pending: &mut dyn AgentLiveRemotePendingAttach, + interruption: F, + ) -> PendingActivationResult + where + F: Future, + { + let mut activate = Box::pin(pending.activate()); + tokio::select! { + biased; + error = interruption => PendingActivationResult::Interrupted(error), + result = &mut activate => match result { + Ok(activated) => PendingActivationResult::Activated(activated), + Err(error) => PendingActivationResult::NativeError(error), + }, + } + } + + /// Encode each absolute C0 session as one bounded header followed by one + /// frame per already-bounded presentation item. Aggregate native overlay + /// size may exceed the transport frame cap; no individual frame may. + pub(super) fn append_live_session_snapshot_frames( + frames: &mut Vec, + live_sessions: Vec, + ) -> Result<(), ProtocolError> { + for (session_index, snapshot) in live_sessions.into_iter().enumerate() { + snapshot.validate()?; + let session_index = u16::try_from(session_index) + .map_err(|_| invalid_live_response("live session index is too large"))?; + let item_count = u16::try_from(snapshot.live_items.len()) + .map_err(|_| invalid_live_response("live session item count is too large"))?; + frames.push(AgentLiveStreamFrame::LiveSessionStart { + index: session_index, + session_id: snapshot.session_id, + item_count, + }); + frames.extend( + snapshot + .live_items + .into_iter() + .enumerate() + .map(|(item_index, item)| AgentLiveStreamFrame::LiveSessionItem { + session_index, + item_index: u16::try_from(item_index) + .expect("validated live session item index fits u16"), + item, + }), + ); + } + Ok(()) + } + + /// Accept exactly one remote Agent live request. This is the sole owner of + /// the peer's prepared-stream queue for the live surface: it selects the + /// lane from the already-authenticated stream header and only then decodes + /// that lane's closed tagged operation union. Events and Control handlers + /// can therefore never steal each other's streams. + #[cfg(test)] + async fn serve_next_remote_agent_live_request_for_test( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + rpc: &RemoteAgentLiveRpcHost, + ) -> Result<(), ProtocolError> { + serve_next_remote_agent_live_request_for_test_with_ttl( + host_endpoint, + peer, + rpc, + REMOTE_LIVE_PENDING_TTL, + ) + .await + } + + #[cfg(test)] + pub(super) async fn serve_next_remote_agent_live_request_for_test_with_ttl( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + rpc: &RemoteAgentLiveRpcHost, + pending_ttl: Duration, + ) -> Result<(), ProtocolError> { + host_endpoint.validate_current_incoming_peer(peer)?; + let authority = host_endpoint.verified_incoming_peer_authorization(peer)?; + let accepted = peer.accept_stream().await?; + match accepted.header().stream_kind { + crate::remote_protocol::StreamKind::Events => { + let request: AcceptedRequest = + accepted.read_request().await?; + revalidate_live_authority(host_endpoint, peer, &authority)?; + match request.request().body.clone() { + RemoteAgentLiveEventsRequest::BeginAttach { session_id, limit } => { + let body = BeginAgentLiveAttachRequest::new(session_id, limit)?; + serve_remote_agent_live_begin_with_ttl( + host_endpoint, + peer, + rpc, + authority, + request, + body, + pending_ttl, + ) + .await + } + RemoteAgentLiveEventsRequest::Resume { + cursor, + origin_host_epoch, + } => { + let body = ResumeAgentLiveEventsRequest::new(cursor, origin_host_epoch)?; + serve_remote_agent_live_resume( + host_endpoint, + peer, + rpc, + authority, + request, + body, + ) + .await + } + } + } + crate::remote_protocol::StreamKind::Control => { + let request: AcceptedRequest = + accepted.read_request().await?; + revalidate_live_authority(host_endpoint, peer, &authority)?; + match request.request().body.clone() { + RemoteAgentLiveControlRequest::ActivateAttach { attach_id } => { + let body = ActivateAgentLiveAttachRequest::new(attach_id)?; + serve_remote_agent_live_activation( + host_endpoint, + peer, + rpc, + authority, + request, + body, + ) + .await + } + RemoteAgentLiveControlRequest::Cancel { kind, live_id } => { + let body = CancelAgentLiveRequest::new(kind, live_id)?; + serve_remote_agent_live_cancel( + host_endpoint, + peer, + rpc, + authority, + request, + body, + ) + .await + } + } + } + crate::remote_protocol::StreamKind::Bulk => Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "bulk stream is not a remote Agent live operation", + false, + )), + } + } + + pub(super) async fn serve_remote_agent_live_begin( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + rpc: &RemoteAgentLiveRpcHost, + authority: VerifiedIncomingPeerAuthorization, + request: AcceptedRequest, + body: BeginAgentLiveAttachRequest, + ) -> Result<(), ProtocolError> { + serve_remote_agent_live_begin_with_ttl( + host_endpoint, + peer, + rpc, + authority, + request, + body, + REMOTE_LIVE_PENDING_TTL, + ) + .await + } + + async fn serve_remote_agent_live_begin_with_ttl( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + rpc: &RemoteAgentLiveRpcHost, + authority: VerifiedIncomingPeerAuthorization, + mut request: AcceptedRequest, + body: BeginAgentLiveAttachRequest, + pending_ttl: Duration, + ) -> Result<(), ProtocolError> { + if pending_ttl.is_zero() { + return Err(live_lifecycle_state_error()); + } + let mut response_cancelled = Box::pin(request.response_cancelled()); + let mut peer_closed = Box::pin(peer.wait_closed()); + let service = tokio::select! { + biased; + cancelled = &mut response_cancelled => return Err(cancelled_live_request(cancelled)), + _ = &mut peer_closed => return Err(live_connection_closed()), + result = rpc.bind_service(&authority) => match result { + Ok(service) => service, + Err(error) => { + revalidate_live_authority(host_endpoint, peer, &authority)?; + let response = live_response_envelope(&request, Err(error)); + return request.write_response(&response).await; + } + }, + }; + let attach_id = allocate_live_id().await?; + let cancellation = Arc::new(RemoteAgentLiveCancellation::default()); + let (activate, activate_receive) = oneshot::channel(); + if let Err(error) = rpc + .install_pending( + attach_id.clone(), + authority.clone(), + Arc::clone(&service), + activate, + Arc::clone(&cancellation), + ) + .await + { + revalidate_live_authority(host_endpoint, peer, &authority)?; + let response = live_response_envelope(&request, Err(error)); + return request.write_response(&response).await; + } + let head = tokio::select! { + biased; + _ = cancellation.wait_requested() => { + rpc.remove_pending(&attach_id, &authority).await; + cancellation.complete(Ok(())); + return Err(live_lifecycle_unavailable( + "remote Agent live attachment was cancelled before snapshot", + )); + } + cancelled = &mut response_cancelled => { + rpc.remove_pending(&attach_id, &authority).await; + cancellation.complete(Ok(())); + return Err(cancelled_live_request(cancelled)); + } + _ = &mut peer_closed => { + rpc.remove_pending(&attach_id, &authority).await; + cancellation.complete(Ok(())); + return Err(live_connection_closed()); + } + result = service.begin_newest( + AgentHistoryPageRequest { + session_id: body.session_id, + cursor: None, + limit: Some(usize::from(body.limit)), + }, + Some(REMOTE_LIVE_SUBSCRIPTION_CAPACITY), + ) => match result { + Ok(head) => head, + Err(error) => { + rpc.remove_pending(&attach_id, &authority).await; + cancellation.complete(Ok(())); + revalidate_live_authority(host_endpoint, peer, &authority)?; + let response = live_response_envelope( + &request, + Err(map_live_attach_error(error)), + ); + return request.write_response(&response).await; + } + }, + }; + let AgentLiveRemoteHeadBegin { + page, + through_event_cursor, + live_sessions_complete, + live_sessions, + pending, + } = head; + + let prepared = + (|| -> Result<(Vec, RemoteLiveEventCursor), ProtocolError> { + let through_event_cursor = remote_cursor(through_event_cursor)?; + let records = page + .records + .into_iter() + .map(remote_safe_history_record) + .collect::, _>>()?; + let live_sessions = live_sessions + .into_iter() + .map(|session| { + Ok(RemoteAgentLiveSessionSnapshot { + session_id: session.session_id, + live_items: session + .live_items + .into_iter() + .map(remote_live_timeline_item) + .collect::, ProtocolError>>()?, + }) + }) + .collect::, ProtocolError>>()?; + validate_remote_live_snapshot(live_sessions_complete, &live_sessions)?; + let record_count = u16::try_from(records.len()) + .map_err(|_| invalid_live_response("live history head is too large"))?; + let live_session_count = u16::try_from(live_sessions.len()) + .map_err(|_| invalid_live_response("live account snapshot is too large"))?; + let mut frames = Vec::with_capacity(records.len() + live_sessions.len() + 2); + frames.push(AgentLiveStreamFrame::SnapshotStart { + attach_id: attach_id.clone(), + record_count, + live_session_count, + live_sessions_complete, + through_event_cursor: through_event_cursor.clone(), + }); + frames.extend(records.into_iter().enumerate().map(|(index, record)| { + AgentLiveStreamFrame::HistoryRecord { + index: u16::try_from(index).expect("validated live record index fits u16"), + record, + } + })); + append_live_session_snapshot_frames(&mut frames, live_sessions)?; + frames.push(AgentLiveStreamFrame::SnapshotEnd { + next_cursor: page.next_cursor, + history_revision: page.history_revision, + }); + Ok((frames, through_event_cursor)) + })(); + let (frames, through_event_cursor) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + cancellation.request(); + let cleanup = pending.cancel().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + rpc.remove_pending(&attach_id, &authority).await; + } + cleanup?; + revalidate_live_authority(host_endpoint, peer, &authority)?; + let response = live_response_envelope(&request, Err(error)); + return request.write_response(&response).await; + } + }; + let preflight = frames.iter().try_for_each(|frame| { + let response = live_response_envelope(&request, Ok(frame.clone())); + request + .validate_response_frame(&response) + .and_then(|()| validate_frame_encodable(&response)) + }); + if let Err(error) = preflight { + return finish_pending_lifecycle( + rpc, + &attach_id, + &authority, + &cancellation, + pending, + error, + ) + .await; + } + for frame in frames { + if cancellation.is_requested() { + return finish_pending_lifecycle( + rpc, + &attach_id, + &authority, + &cancellation, + pending, + live_lifecycle_unavailable( + "remote Agent live attachment was cancelled during snapshot", + ), + ) + .await; + } + if let Err(error) = write_live_frame_or_cancel( + host_endpoint, + peer, + &authority, + cancellation.as_ref(), + &mut request, + frame, + ) + .await + { + return finish_pending_lifecycle( + rpc, + &attach_id, + &authority, + &cancellation, + pending, + error, + ) + .await; + } + } + let expiry = match rpc + .arm_pending_for(&authority, &attach_id, pending_ttl) + .await + { + Ok(expiry) => expiry, + Err(error) => { + return finish_pending_lifecycle( + rpc, + &attach_id, + &authority, + &cancellation, + pending, + error, + ) + .await; + } + }; + let command = tokio::select! { + biased; + _ = cancellation.wait_requested() => Err(live_lifecycle_unavailable( + "remote Agent live attachment was cancelled", + )), + cancelled = &mut response_cancelled => Err(cancelled_live_request(cancelled)), + _ = &mut peer_closed => Err(live_connection_closed()), + _ = tokio::time::sleep_until(expiry) => Err(live_lifecycle_unavailable( + "remote Agent live attachment expired", + )), + command = activate_receive => command.map_err(|_| live_lifecycle_unavailable( + "remote Agent live activation channel closed", + )), + }; + let mut command = match command { + Ok(command) => command, + Err(error) => { + return finish_pending_lifecycle( + rpc, + &attach_id, + &authority, + &cancellation, + pending, + error, + ) + .await; + } + }; + if let Err(error) = revalidate_live_authority(host_endpoint, peer, &authority) { + let _ = command.response.send(Err(error.clone())); + return finish_activating_pending_lifecycle( + rpc, + &attach_id, + &authority, + &cancellation, + pending, + error, + ) + .await; + } + if cancellation.is_requested() || command.response.is_closed() { + let error = live_lifecycle_unavailable("remote Agent live activation was cancelled"); + let _ = command.response.send(Err(error.clone())); + return finish_activating_pending_lifecycle( + rpc, + &attach_id, + &authority, + &cancellation, + pending, + error, + ) + .await; + } + let mut pending = pending; + let interruption = async { + tokio::select! { + biased; + _ = cancellation.wait_requested() => live_lifecycle_unavailable( + "remote Agent live activation was cancelled", + ), + cancelled = &mut response_cancelled => cancelled_live_request(cancelled), + _ = &mut peer_closed => live_connection_closed(), + _ = command.response.closed() => live_lifecycle_unavailable( + "remote Agent live activation acknowledgement was abandoned", + ), + } + }; + let activation = activate_pending_until_interrupted(pending.as_mut(), interruption).await; + let activated = match activation { + PendingActivationResult::Activated(activated) => activated, + PendingActivationResult::NativeError(error) => { + let precise_snapshot_reason = snapshot_reason_from_attach_error(&error); + let mapped = map_live_attach_error(error); + // Native activation has already returned, so this owner can + // synchronously reclaim the still-valid pending handle. Do + // not mark the shared cancellation as requested here: the + // Control waiter treats that signal as an external cancel and + // would race the precise SnapshotRequired acknowledgement. + let cleanup = pending.cancel().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + rpc.remove_activating(&attach_id, &authority).await; + } + cleanup?; + if let Some(reason) = + precise_snapshot_reason.or_else(|| snapshot_reason_from_protocol_error(&mapped)) + { + let disposition = AgentLiveActivationDisposition::SnapshotRequired { + reason, + last_event_cursor: through_event_cursor.clone(), + }; + let _ = command.response.send(Ok(disposition)); + write_live_frame( + host_endpoint, + peer, + &authority, + &mut request, + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor: through_event_cursor, + }, + ) + .await?; + return request.finish_response(); + } + let _ = command.response.send(Err(mapped.clone())); + return Err(mapped); + } + PendingActivationResult::Interrupted(mapped) => { + cancellation.request(); + let cleanup = pending.cancel().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + rpc.remove_activating(&attach_id, &authority).await; + } + cleanup?; + if let Some(reason) = snapshot_reason_from_protocol_error(&mapped) { + let disposition = AgentLiveActivationDisposition::SnapshotRequired { + reason, + last_event_cursor: through_event_cursor.clone(), + }; + let _ = command.response.send(Ok(disposition)); + write_live_frame( + host_endpoint, + peer, + &authority, + &mut request, + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor: through_event_cursor, + }, + ) + .await?; + return request.finish_response(); + } + let _ = command.response.send(Err(mapped.clone())); + return Err(mapped); + } + }; + serve_owned_live_stream( + host_endpoint, + peer, + rpc, + authority, + request, + attach_id, + command.live_stream_id, + through_event_cursor, + AgentLiveRemoteResume { + through_event_cursor: activated.through_event_cursor, + stream: activated.stream, + }, + cancellation, + Some(command.response), + ) + .await + } + + pub(super) async fn serve_remote_agent_live_activation( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + rpc: &RemoteAgentLiveRpcHost, + authority: VerifiedIncomingPeerAuthorization, + request: AcceptedRequest, + body: ActivateAgentLiveAttachRequest, + ) -> Result<(), ProtocolError> + where + T: crate::remote_protocol::RequestBody, + ActivateAgentLiveAttachResponse: crate::remote_protocol::ResponseBody, + { + let mut pending = match rpc + .take_pending_for_activation(&authority, &body.attach_id) + .await + { + Ok(pending) => pending, + Err(error) => { + revalidate_live_authority(host_endpoint, peer, &authority)?; + let response: ResponseEnvelope = + control_response_envelope(&request, Err(error)); + return request.write_response(&response).await; + } + }; + let cancellation = Arc::clone(&pending.cancellation); + let activate = pending.activate.take().ok_or_else(|| { + live_lifecycle_unavailable("remote Agent live attachment is no longer activatable") + })?; + let live_stream_id = match allocate_live_id().await { + Ok(id) => id, + Err(error) => { + drop(activate); + cancellation.request_and_wait().await?; + return Err(error); + } + }; + if let Err(error) = rpc + .name_activating_stream(&body.attach_id, &live_stream_id, &authority) + .await + { + drop(activate); + cancellation.request_and_wait().await?; + return Err(error); + } + let (response_send, mut response_receive) = oneshot::channel(); + if activate + .send(ActivateRemoteAgentLiveCommand { + live_stream_id, + response: response_send, + }) + .is_err() + { + cancellation.request_and_wait().await?; + return Err(live_lifecycle_unavailable( + "remote Agent live attachment owner closed", + )); + } + let mut response_cancelled = Box::pin(request.response_cancelled()); + let result: Result = tokio::select! { + biased; + _ = cancellation.wait_requested() => { + cancellation.wait_completion().await?; + Err(live_lifecycle_unavailable("remote Agent live activation was cancelled")) + } + cancelled = &mut response_cancelled => { + cancellation.request_and_wait().await?; + Err(cancelled_live_request(cancelled)) + } + _ = peer.wait_closed() => { + cancellation.request_and_wait().await?; + Err(live_connection_closed()) + } + result = &mut response_receive => result.unwrap_or_else(|_| { + Err(live_lifecycle_unavailable( + "remote Agent live activation owner closed before acknowledgement", + )) + }), + }; + if let Err(error) = revalidate_live_authority(host_endpoint, peer, &authority) { + cancellation.request_and_wait().await?; + return Err(error); + } + let response = control_response_envelope( + &request, + result.map(|result| ActivateAgentLiveAttachResponse { + attach_id: body.attach_id, + result, + }), + ); + match request.write_response(&response).await { + Ok(()) => Ok(()), + Err(error) => { + cancellation.request_and_wait().await?; + Err(error) + } + } + } + + pub(super) async fn serve_remote_agent_live_cancel( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + rpc: &RemoteAgentLiveRpcHost, + authority: VerifiedIncomingPeerAuthorization, + request: AcceptedRequest, + body: CancelAgentLiveRequest, + ) -> Result<(), ProtocolError> + where + T: crate::remote_protocol::RequestBody, + CancelAgentLiveResponse: crate::remote_protocol::ResponseBody, + { + let result = rpc + .cancel_lifecycle(&authority, body.kind, &body.live_id) + .await + .map(|()| CancelAgentLiveResponse { + kind: body.kind, + live_id: body.live_id, + }); + revalidate_live_authority(host_endpoint, peer, &authority)?; + let response = control_response_envelope(&request, result); + request.write_response(&response).await + } + + pub(super) async fn serve_remote_agent_live_resume( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + rpc: &RemoteAgentLiveRpcHost, + authority: VerifiedIncomingPeerAuthorization, + mut request: AcceptedRequest, + body: ResumeAgentLiveEventsRequest, + ) -> Result<(), ProtocolError> { + let from = body.cursor.clone(); + let mut response_cancelled = Box::pin(request.response_cancelled()); + let mut peer_closed = Box::pin(peer.wait_closed()); + let (native_from, service) = tokio::select! { + biased; + cancelled = &mut response_cancelled => return Err(cancelled_live_request(cancelled)), + _ = &mut peer_closed => return Err(live_connection_closed()), + result = prepare_remote_agent_live_resume( + rpc, + &authority, + peer.connection_stamp(), + &body, + ) => match result { + Ok(prepared) => prepared, + Err(error) => { + revalidate_live_authority(host_endpoint, peer, &authority)?; + let response = live_response_envelope(&request, Err(error)); + return request.write_response(&response).await; + } + }, + }; + let live_stream_id = allocate_live_id().await?; + let cancellation = Arc::new(RemoteAgentLiveCancellation::default()); + rpc.reserve_activating( + live_stream_id.clone(), + authority.clone(), + Arc::clone(&service), + Arc::clone(&cancellation), + ) + .await?; + let resume = tokio::select! { + biased; + _ = cancellation.wait_requested() => { + rpc.remove_activating(&live_stream_id, &authority).await; + cancellation.complete(Ok(())); + return Err(live_lifecycle_unavailable("remote Agent live resume was cancelled")); + } + cancelled = &mut response_cancelled => { + rpc.remove_activating(&live_stream_id, &authority).await; + cancellation.complete(Ok(())); + return Err(cancelled_live_request(cancelled)); + } + _ = &mut peer_closed => { + rpc.remove_activating(&live_stream_id, &authority).await; + cancellation.complete(Ok(())); + return Err(live_connection_closed()); + } + result = service.resume(native_from, Some(REMOTE_LIVE_SUBSCRIPTION_CAPACITY)) => match result { + Ok(resume) => resume, + Err(error) => { + rpc.remove_activating(&live_stream_id, &authority).await; + cancellation.complete(Ok(())); + if let Some(reason) = snapshot_reason_from_attach_error(&error) { + write_live_frame( + host_endpoint, + peer, + &authority, + &mut request, + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor: from, + }, + ) + .await?; + return request.finish_response(); + } + let response = live_response_envelope( + &request, + Err(map_live_attach_error(error)), + ); + revalidate_live_authority(host_endpoint, peer, &authority)?; + return request.write_response(&response).await; + } + }, + }; + serve_owned_live_stream( + host_endpoint, + peer, + rpc, + authority, + request, + live_stream_id.clone(), + live_stream_id, + from, + resume, + cancellation, + None, + ) + .await + } + + pub(super) async fn prepare_remote_agent_live_resume( + rpc: &RemoteAgentLiveRpcHost, + authority: &VerifiedIncomingPeerAuthorization, + connection_stamp: crate::remote_protocol::ConnectionStamp, + body: &ResumeAgentLiveEventsRequest, + ) -> Result<(AgentLiveEventCursor, Arc), ProtocolError> { + body.validate_for_connection_stamp(connection_stamp)?; + let native_from = native_cursor(&body.cursor)?; + let service = rpc.bind_service(authority).await?; + Ok((native_from, service)) + } + + #[allow(clippy::too_many_arguments)] + async fn serve_owned_live_stream( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + rpc: &RemoteAgentLiveRpcHost, + authority: VerifiedIncomingPeerAuthorization, + mut request: AcceptedRequest, + activation_id: String, + live_stream_id: String, + from: RemoteLiveEventCursor, + resume: AgentLiveRemoteResume, + cancellation: Arc, + mut activation_response: Option< + oneshot::Sender>, + >, + ) -> Result<(), ProtocolError> { + let mut stream = resume.stream; + let mut promoted = false; + let through = match remote_cursor(resume.through_event_cursor) { + Ok(cursor) => cursor, + Err(error) => { + let cleanup = stream.unsubscribe().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + rpc.remove_activating(&activation_id, &authority).await; + } + if let Some(response) = activation_response.take() { + let _ = response.send(Err(error.clone())); + } + cleanup?; + return Err(error); + } + }; + if let Err(error) = validate_remote_cursor_range(&from, &through) { + let cleanup = stream.unsubscribe().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + rpc.remove_activating(&activation_id, &authority).await; + } + if let Some(response) = activation_response.take() { + let _ = response.send(Err(error.clone())); + } + cleanup?; + return Err(error); + } + let start = AgentLiveStreamFrame::StreamStart { + live_stream_id: live_stream_id.clone(), + from_event_cursor: from.clone(), + through_event_cursor: through.clone(), + }; + let setup = async { + if cancellation.is_requested() { + return Err(live_lifecycle_unavailable( + "remote Agent live stream was cancelled", + )); + } + write_live_frame_or_cancel( + host_endpoint, + peer, + &authority, + cancellation.as_ref(), + &mut request, + start, + ) + .await?; + rpc.promote_activating(&activation_id, &live_stream_id, &authority) + .await?; + promoted = true; + if let Some(response) = activation_response.take() { + response + .send(Ok(AgentLiveActivationDisposition::Activated { + live_stream_id: live_stream_id.clone(), + through_event_cursor: through.clone(), + })) + .map_err(|_| { + live_lifecycle_unavailable( + "remote Agent live activation acknowledgement was abandoned", + ) + })?; + } + Ok(()) + } + .await; + if let Err(error) = setup { + let cleanup = stream.unsubscribe().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + if promoted { + rpc.remove_active(&live_stream_id, &authority).await; + } else { + rpc.remove_activating(&activation_id, &authority).await; + } + } + if let Some(response) = activation_response.take() { + let _ = response.send(Err(error.clone())); + } + cleanup?; + return Err(error); + } + + let mut response_cancelled = Box::pin(request.response_cancelled()); + let mut peer_closed = Box::pin(peer.wait_closed()); + let result = async { + let mut last = from; + let mut replay_count = 0usize; + while last.sequence < through.sequence { + if replay_count >= MAX_REMOTE_LIVE_REPLAY_EVENTS { + write_live_frame_or_cancel( + host_endpoint, + peer, + &authority, + cancellation.as_ref(), + &mut request, + AgentLiveStreamFrame::SnapshotRequired { + reason: RemoteAgentLiveSnapshotReason::RetentionGap, + last_event_cursor: last, + }, + ) + .await?; + return Ok(()); + } + revalidate_live_authority(host_endpoint, peer, &authority)?; + let delivery = tokio::select! { + biased; + _ = cancellation.wait_requested() => return Err(live_lifecycle_unavailable( + "remote Agent live stream was cancelled", + )), + cancelled = &mut response_cancelled => return Err(cancelled_live_request(cancelled)), + _ = &mut peer_closed => return Err(live_connection_closed()), + delivery = stream.recv() => delivery, + }; + let delivery = match delivery { + Ok(delivery) => remote_live_delivery(delivery)?, + Err(error) => { + if let Some(reason) = snapshot_reason_from_stream_error(&error) { + write_live_frame_or_cancel( + host_endpoint, + peer, + &authority, + cancellation.as_ref(), + &mut request, + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor: last, + }, + ) + .await?; + return Ok(()); + } + return Err(map_live_stream_error(error)); + } + }; + validate_next_remote_delivery(&last, &delivery, Some(&through))?; + last = delivery.cursor.clone(); + replay_count += 1; + write_live_frame_or_cancel( + host_endpoint, + peer, + &authority, + cancellation.as_ref(), + &mut request, + AgentLiveStreamFrame::Event { delivery }, + ) + .await?; + } + if last != through { + return Err(invalid_live_response( + "remote Agent live replay ended at another cursor", + )); + } + write_live_frame_or_cancel( + host_endpoint, + peer, + &authority, + cancellation.as_ref(), + &mut request, + AgentLiveStreamFrame::ReplayComplete { + through_event_cursor: through, + }, + ) + .await?; + + loop { + revalidate_live_authority(host_endpoint, peer, &authority)?; + let delivery = tokio::select! { + biased; + _ = cancellation.wait_requested() => return Err(live_lifecycle_unavailable( + "remote Agent live stream was cancelled", + )), + cancelled = &mut response_cancelled => return Err(cancelled_live_request(cancelled)), + _ = &mut peer_closed => return Err(live_connection_closed()), + delivery = stream.recv() => delivery, + }; + let delivery = match delivery { + Ok(delivery) => remote_live_delivery(delivery)?, + Err(error) => { + if let Some(reason) = snapshot_reason_from_stream_error(&error) { + write_live_frame_or_cancel( + host_endpoint, + peer, + &authority, + cancellation.as_ref(), + &mut request, + AgentLiveStreamFrame::SnapshotRequired { + reason, + last_event_cursor: last, + }, + ) + .await?; + return Ok(()); + } + return Err(map_live_stream_error(error)); + } + }; + validate_next_remote_delivery(&last, &delivery, None)?; + last = delivery.cursor.clone(); + write_live_frame_or_cancel( + host_endpoint, + peer, + &authority, + cancellation.as_ref(), + &mut request, + AgentLiveStreamFrame::Event { delivery }, + ) + .await?; + } + } + .await; + + let cleanup = stream.unsubscribe().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + rpc.remove_active(&live_stream_id, &authority).await; + } + cleanup?; + result?; + request.finish_response() + } + + async fn finish_pending_lifecycle( + rpc: &RemoteAgentLiveRpcHost, + attach_id: &str, + authority: &VerifiedIncomingPeerAuthorization, + cancellation: &Arc, + pending: Box, + terminal_error: ProtocolError, + ) -> Result<(), ProtocolError> { + cancellation.request(); + let cleanup = pending.cancel().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + rpc.remove_pending(attach_id, authority).await; + // Activation may have atomically moved the registry marker while + // this Events owner was waking. Native acknowledgement precedes + // both removals, so stable occupancy remains fail-closed. + rpc.remove_activating(attach_id, authority).await; + } + cleanup?; + Err(terminal_error) + } + + pub(super) async fn finish_activating_pending_lifecycle( + rpc: &RemoteAgentLiveRpcHost, + attach_id: &str, + authority: &VerifiedIncomingPeerAuthorization, + cancellation: &Arc, + pending: Box, + terminal_error: ProtocolError, + ) -> Result<(), ProtocolError> { + cancellation.request(); + let cleanup = pending.cancel().await.map_err(map_live_attach_error); + cancellation.complete(cleanup.clone()); + if cleanup.is_ok() { + rpc.remove_activating(attach_id, authority).await; + } + cleanup?; + Err(terminal_error) + } + + fn snapshot_reason_from_protocol_error( + error: &ProtocolError, + ) -> Option { + match error.code { + ErrorCode::SnapshotRequired | ErrorCode::StaleGeneration => { + Some(RemoteAgentLiveSnapshotReason::OwnerChanged) + } + _ => None, + } + } + + async fn write_live_frame( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + authority: &VerifiedIncomingPeerAuthorization, + request: &mut AcceptedRequest, + frame: AgentLiveStreamFrame, + ) -> Result<(), ProtocolError> + where + T: crate::remote_protocol::RequestBody + serde::Serialize, + AgentLiveStreamFrame: crate::remote_protocol::ResponseBody, + { + revalidate_live_authority(host_endpoint, peer, authority)?; + let response = live_response_envelope(request, Ok(frame)); + request + .validate_response_frame(&response) + .and_then(|()| validate_frame_encodable(&response))?; + revalidate_live_authority(host_endpoint, peer, authority)?; + request.write_response_frame(&response).await + } + + /// Write one Events frame while retaining prompt cancellation/revocation + /// responsiveness. Dropping the in-flight write is terminal for this + /// request owner; every caller immediately performs the exact native + /// cancel/unsubscribe acknowledgement before releasing lifecycle + /// occupancy. + async fn write_live_frame_or_cancel( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + authority: &VerifiedIncomingPeerAuthorization, + cancellation: &RemoteAgentLiveCancellation, + request: &mut AcceptedRequest, + frame: AgentLiveStreamFrame, + ) -> Result<(), ProtocolError> + where + T: crate::remote_protocol::RequestBody + serde::Serialize, + AgentLiveStreamFrame: crate::remote_protocol::ResponseBody, + { + let mut response_cancelled = Box::pin(request.response_cancelled()); + let mut peer_closed = Box::pin(peer.wait_closed()); + let mut write = Box::pin(write_live_frame( + host_endpoint, + peer, + authority, + request, + frame, + )); + tokio::select! { + biased; + _ = cancellation.wait_requested() => Err(live_lifecycle_unavailable( + "remote Agent live stream was cancelled during frame delivery", + )), + cancelled = &mut response_cancelled => Err(cancelled_live_request(cancelled)), + _ = &mut peer_closed => Err(live_connection_closed()), + result = &mut write => result, + } + } + + fn live_response_envelope( + request: &AcceptedRequest, + result: Result, + ) -> ResponseEnvelope + where + T: crate::remote_protocol::RequestBody, + { + let envelope = request.request(); + ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: envelope.request_id.clone(), + execution_target_id: envelope.execution_target_id.clone(), + connection_stamp: envelope.connection_stamp, + result, + } + } + + fn control_response_envelope( + request: &AcceptedRequest, + result: Result, + ) -> ResponseEnvelope + where + T: crate::remote_protocol::RequestBody, + { + let envelope = request.request(); + ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: envelope.request_id.clone(), + execution_target_id: envelope.execution_target_id.clone(), + connection_stamp: envelope.connection_stamp, + result, + } + } + + fn revalidate_live_authority( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + authority: &VerifiedIncomingPeerAuthorization, + ) -> Result<(), ProtocolError> { + host_endpoint.validate_current_incoming_peer(peer)?; + if authority.controller_endpoint() != peer.remote_id() + || authority.execution_target_id() != peer.execution_target_id() + || authority.pairing_fence() != peer.pairing_fence() + || authority.connection_stamp() != peer.connection_stamp() + { + return Err(stale_live_lease()); + } + authority.revalidate_current() + } + + fn validate_remote_live_snapshot( + complete: bool, + sessions: &[RemoteAgentLiveSessionSnapshot], + ) -> Result<(), ProtocolError> { + if !complete || sessions.len() > crate::remote_protocol::MAX_LIVE_SESSIONS_PER_ACCOUNT { + return Err(invalid_live_response( + "remote Agent live snapshot is incomplete or too large", + )); + } + let mut total_items = 0usize; + let mut previous_session_id: Option<&str> = None; + for session in sessions { + session.validate()?; + if previous_session_id.is_some_and(|previous| previous >= session.session_id.as_str()) { + return Err(invalid_live_response( + "remote Agent live snapshot sessions are not unique and sorted", + )); + } + previous_session_id = Some(&session.session_id); + total_items = total_items + .checked_add(session.live_items.len()) + .ok_or_else(|| invalid_live_response("remote live snapshot item count overflow"))?; + } + if total_items > crate::remote_protocol::MAX_LIVE_ITEMS_PER_ACCOUNT { + return Err(invalid_live_response( + "remote Agent live snapshot contains too many items", + )); + } + Ok(()) + } +} + +fn validate_next_remote_delivery( + last: &RemoteLiveEventCursor, + delivery: &RemoteAgentLiveDelivery, + replay_through: Option<&RemoteLiveEventCursor>, +) -> Result<(), ProtocolError> { + delivery.validate()?; + let expected = last.sequence.checked_add(1).ok_or_else(|| { + ProtocolError::new( + ErrorCode::SnapshotRequired, + "remote Agent live event sequence exhausted", + true, + ) + })?; + if delivery.cursor.journal_id != last.journal_id || delivery.cursor.sequence != expected { + return Err(ProtocolError::new( + ErrorCode::SnapshotRequired, + "remote Agent live event ordering was lost", + true, + )); + } + if replay_through.is_some_and(|through| { + delivery.cursor.journal_id != through.journal_id + || delivery.cursor.sequence > through.sequence + }) { + return Err(invalid_live_response( + "remote Agent live replay crossed its FIFO barrier", + )); + } + Ok(()) +} + +fn validate_remote_cursor_range( + from: &RemoteLiveEventCursor, + through: &RemoteLiveEventCursor, +) -> Result<(), ProtocolError> { + from.validate()?; + through.validate()?; + if from.journal_id == through.journal_id && from.sequence <= through.sequence { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::SnapshotRequired, + "remote Agent live cursor requires an authoritative snapshot", + true, + )) + } +} + +fn invalid_live_response(message: impl Into) -> ProtocolError { + ProtocolError::new(ErrorCode::InvalidFrame, message, false) +} + +fn snapshot_required_error( + reason: RemoteAgentLiveSnapshotReason, + last: &RemoteLiveEventCursor, +) -> ProtocolError { + ProtocolError::new( + ErrorCode::SnapshotRequired, + format!( + "remote Agent live snapshot required ({reason:?}) after event {}", + last.sequence + ), + true, + ) +} + +fn cancelled_live_request(result: Result<(), ProtocolError>) -> ProtocolError { + result.err().unwrap_or_else(|| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote Agent live response was cancelled", + true, + ) + }) +} + +fn live_connection_closed() -> ProtocolError { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote Agent live connection closed", + true, + ) +} + +#[cfg(desktop)] +async fn allocate_live_id() -> Result { + allocate_live_id_value() +} + +#[cfg(desktop)] +fn allocate_live_id_value() -> Result { + for _ in 0..MAX_LIVE_ID_ATTEMPTS { + let mut bytes = [0_u8; LIVE_ID_RANDOM_BYTES]; + if fill_random(&mut bytes).is_err() { + continue; + } + let mut encoded = String::with_capacity(LIVE_ID_RANDOM_BYTES * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in bytes { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + return Ok(encoded); + } + Err(ProtocolError::new( + ErrorCode::Internal, + "remote Agent live ID allocation failed", + false, + )) +} + +#[cfg(desktop)] +fn remote_cursor(cursor: AgentLiveEventCursor) -> Result { + let cursor = RemoteLiveEventCursor { + journal_id: cursor.journal_id, + sequence: cursor.sequence, + }; + cursor.validate()?; + Ok(cursor) +} + +#[cfg(desktop)] +fn native_cursor(cursor: &RemoteLiveEventCursor) -> Result { + cursor.validate()?; + Ok(AgentLiveEventCursor { + journal_id: cursor.journal_id.clone(), + sequence: cursor.sequence, + }) +} + +#[cfg(desktop)] +fn remote_safe_history_record( + record: crate::agent_live_host::AgentLiveSafeHistoryRecord, +) -> Result { + let record = RemoteAgentHistoryRecord { + record_id: record.record_id, + role: record.role, + created_ms: record.created_ms, + items: record + .items + .into_iter() + .map(remote_live_timeline_item) + .collect::, _>>()?, + }; + record.validate()?; + Ok(record) +} + +#[cfg(desktop)] +fn remote_live_timeline_item( + item: MapleLiveTimelineItem, +) -> Result { + let item = RemoteAgentTimelineItem { + id: item.id, + item_type: match item.item_type { + MapleLiveItemType::Message => "message", + MapleLiveItemType::Thinking => "thinking", + MapleLiveItemType::Tool => "tool", + MapleLiveItemType::Permission => "permission", + MapleLiveItemType::System => "system", + MapleLiveItemType::Error => "error", + } + .to_string(), + role: item.role.map(|role| { + match role { + MapleLiveRole::User => "user", + MapleLiveRole::Assistant => "assistant", + MapleLiveRole::Thought => "thought", + MapleLiveRole::System => "system", + } + .to_string() + }), + title: item.title, + text: item.text, + status: item.status, + created_ms: item.created_ms, + merge: match item.merge { + MapleLiveMerge::Append => "append", + MapleLiveMerge::Replace => "replace", + } + .to_string(), + }; + item.validate()?; + Ok(item) +} + +#[cfg(desktop)] +fn remote_live_event_timeline_item( + item: MapleLiveTimelineItem, +) -> Result { + // The closed item validator admits only terminal, non-actionable + // permission presentations. Pending/control-bearing permission state is + // still rejected; resolved and cancelled audit rows remain displayable. + remote_live_timeline_item(item) +} + +#[cfg(desktop)] +fn remote_session_summary( + session: crate::agent_live_coordinator::MapleLiveSessionSummary, +) -> Result { + let session = RemoteAgentSessionSummary { + id: session.id, + title: session.title, + project_root: session.project_root, + created_ms: session.created_ms, + updated_ms: session.updated_ms, + page_sort_ms: session.page_sort_ms, + message_count: u64::try_from(session.message_count) + .map_err(|_| invalid_live_response("remote Agent live session count is invalid"))?, + model: session.model, + mode: session.mode, + }; + session.validate()?; + Ok(session) +} + +#[cfg(desktop)] +fn remote_live_delivery( + delivery: AgentLiveRemoteDelivery, +) -> Result { + let event = match delivery.event { + MapleLiveEvent::RunStarted { .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::RunStarted + } + MapleLiveEvent::TimelineUpsert { item, .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::TimelineUpsert { + item: remote_live_event_timeline_item(item)?, + } + } + MapleLiveEvent::TimelineCleared { reason, .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::TimelineCleared { + reason: match reason { + MapleLiveClearReason::RunStarted => RemoteAgentLiveClearReason::RunStarted, + MapleLiveClearReason::HistoryReplaced => { + RemoteAgentLiveClearReason::HistoryReplaced + } + MapleLiveClearReason::ExplicitReload => { + RemoteAgentLiveClearReason::ExplicitReload + } + }, + } + } + MapleLiveEvent::HistoryReplaced { .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::HistoryReplaced + } + MapleLiveEvent::HistoryHeadCommitted { .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::CursorAdvanced + } + MapleLiveEvent::SessionUpdated { session, .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::SessionUpdated { + session: remote_session_summary(session)?, + } + } + MapleLiveEvent::RunFinished { terminal, .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::RunFinished { + terminal: match terminal { + MapleLiveRunTerminal::Completed => RemoteAgentLiveRunTerminal::Completed, + MapleLiveRunTerminal::Cancelled => RemoteAgentLiveRunTerminal::Cancelled, + MapleLiveRunTerminal::Failed => RemoteAgentLiveRunTerminal::Failed, + }, + } + } + MapleLiveEvent::SessionDeleted { .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::SessionDeleted + } + MapleLiveEvent::UserFacingError { error, .. } => { + crate::remote_protocol::RemoteAgentPresentedLiveEvent::UserFacingError { + item: remote_live_timeline_item(error.to_timeline_item())?, + } + } + }; + let delivery = RemoteAgentLiveDelivery { + cursor: remote_cursor(delivery.cursor)?, + session_id: delivery.session_id, + run_id: delivery.run_id, + event, + }; + delivery.validate()?; + Ok(delivery) +} + +#[cfg(desktop)] +fn map_head_reload_reason(reason: HeadReloadReason) -> RemoteAgentLiveSnapshotReason { + match reason { + HeadReloadReason::PausedSubscriberOverflow => { + RemoteAgentLiveSnapshotReason::PausedSubscriberOverflow + } + HeadReloadReason::SlowSubscriber => RemoteAgentLiveSnapshotReason::SlowSubscriber, + HeadReloadReason::JournalReplaced | HeadReloadReason::ReseedRequired => { + RemoteAgentLiveSnapshotReason::JournalReplaced + } + HeadReloadReason::RetentionGap => RemoteAgentLiveSnapshotReason::RetentionGap, + HeadReloadReason::CursorAhead => RemoteAgentLiveSnapshotReason::CursorAhead, + HeadReloadReason::OwnerChanged => RemoteAgentLiveSnapshotReason::OwnerChanged, + HeadReloadReason::OrderingLost => RemoteAgentLiveSnapshotReason::OrderingLost, + HeadReloadReason::JournalUnavailable => RemoteAgentLiveSnapshotReason::JournalUnavailable, + } +} + +#[cfg(desktop)] +fn snapshot_reason_from_attach_error( + error: &AgentLiveRemoteAttachError, +) -> Option { + match error { + AgentLiveRemoteAttachError::Host(AgentLiveHostError::Coordinator( + AgentLiveCoordinatorError::HeadReloadRequired(reason), + )) => Some(map_head_reload_reason(*reason)), + AgentLiveRemoteAttachError::Host(AgentLiveHostError::Coordinator( + AgentLiveCoordinatorError::ReseedRequired(_), + )) + | AgentLiveRemoteAttachError::Host(AgentLiveHostError::JournalReseedRequired(_)) => { + Some(RemoteAgentLiveSnapshotReason::JournalReplaced) + } + _ => None, + } +} + +#[cfg(desktop)] +fn snapshot_reason_from_stream_error( + error: &AgentLiveRemoteStreamError, +) -> Option { + match error { + AgentLiveRemoteStreamError::Receive(AgentLiveReceiveError::HeadReloadRequired(reason)) => { + Some(map_head_reload_reason(*reason)) + } + AgentLiveRemoteStreamError::Receive(AgentLiveReceiveError::Closed) => { + Some(RemoteAgentLiveSnapshotReason::OrderingLost) + } + AgentLiveRemoteStreamError::Attach(error) => snapshot_reason_from_attach_error(error), + } +} + +#[cfg(desktop)] +fn map_live_attach_error(error: AgentLiveRemoteAttachError) -> ProtocolError { + if let Some(reason) = snapshot_reason_from_attach_error(&error) { + return ProtocolError::new( + ErrorCode::SnapshotRequired, + format!("remote Agent live snapshot required ({reason:?})"), + true, + ); + } + match error { + AgentLiveRemoteAttachError::Unavailable => ProtocolError::new( + ErrorCode::AgentLiveUnavailable, + "verified remote Agent live attachment is unavailable", + true, + ), + AgentLiveRemoteAttachError::ProjectionRejected => ProtocolError::new( + ErrorCode::InvalidFrame, + "remote Agent live projection was rejected", + false, + ), + AgentLiveRemoteAttachError::Host(AgentLiveHostError::BoundContextRevoked) => { + ProtocolError::new( + ErrorCode::Revoked, + "remote Agent live authorization was revoked", + false, + ) + } + AgentLiveRemoteAttachError::Host(AgentLiveHostError::RuntimeOwnerMismatch) + | AgentLiveRemoteAttachError::Host(AgentLiveHostError::BoundContextSealed) => { + stale_live_lease() + } + AgentLiveRemoteAttachError::Host(_) => ProtocolError::new( + ErrorCode::AgentLiveUnavailable, + "verified remote Agent live attachment is unavailable", + true, + ), + } +} + +#[cfg(desktop)] +fn map_live_stream_error(error: AgentLiveRemoteStreamError) -> ProtocolError { + match error { + AgentLiveRemoteStreamError::Attach(error) => map_live_attach_error(error), + AgentLiveRemoteStreamError::Receive(AgentLiveReceiveError::HeadReloadRequired(reason)) => { + ProtocolError::new( + ErrorCode::SnapshotRequired, + format!( + "remote Agent live snapshot required ({:?})", + map_head_reload_reason(reason) + ), + true, + ) + } + AgentLiveRemoteStreamError::Receive(AgentLiveReceiveError::Closed) => { + live_connection_closed() + } + } +} + +/// Accept and serve one authenticated, generation-fenced history page. +#[cfg(test)] +pub async fn serve_next_remote_agent_history_page

( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + provider: &P, +) -> Result<(), ProtocolError> +where + P: RemoteAgentHistoryProvider + ?Sized, +{ + serve_next_remote_agent_history_page_with_timeout( + host_endpoint, + peer, + provider, + AGENT_HISTORY_PROVIDER_TIMEOUT, + ) + .await +} + +#[cfg(test)] +async fn serve_next_remote_agent_history_page_with_timeout

( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + provider: &P, + provider_timeout: Duration, +) -> Result<(), ProtocolError> +where + P: RemoteAgentHistoryProvider + ?Sized, +{ + if provider_timeout.is_zero() { + return Err(ProtocolError::new( + ErrorCode::Internal, + "Agent history provider timeout is invalid", + false, + )); + } + host_endpoint.validate_current_incoming_peer(peer)?; + let accepted = peer.accept_stream().await?; + let request: AcceptedRequest = accepted.read_request().await?; + let body = request.request().body.clone(); + serve_remote_agent_history_page_request( + host_endpoint, + peer, + provider, + request, + body, + provider_timeout, + ) + .await +} + +async fn serve_remote_agent_history_page_request( + host_endpoint: &MapleIrohEndpoint, + peer: &ConnectedPeer, + provider: &P, + mut request: AcceptedRequest, + body: ListAgentHistoryRecordsRequest, + provider_timeout: Duration, +) -> Result<(), ProtocolError> +where + T: crate::remote_protocol::RequestBody, + AgentHistoryPageFrame: crate::remote_protocol::ResponseBody, + P: RemoteAgentHistoryProvider + ?Sized, +{ + if provider_timeout.is_zero() { + return Err(ProtocolError::new( + ErrorCode::Internal, + "Agent history provider timeout is invalid", + false, + )); + } + host_endpoint.validate_current_incoming_peer(peer)?; + + let operation_deadline = request.operation_deadline(); + let now = tokio::time::Instant::now(); + let latest_provider_deadline = operation_deadline + .checked_sub(AGENT_HISTORY_RESPONSE_BUDGET) + .unwrap_or(now); + let provider_deadline = latest_provider_deadline.min(now + provider_timeout); + let mut response_cancelled = Box::pin(request.response_cancelled()); + let mut provider_result = provider.list_agent_history(&body); + let result = tokio::select! { + biased; + cancelled = &mut response_cancelled => { + return match cancelled { + Ok(()) => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote Agent history request was cancelled", + true, + )), + Err(error) => Err(error), + }; + } + provider_result = tokio::time::timeout_at(provider_deadline, provider_result.as_mut()) => { + match provider_result { + Ok(result) => result, + Err(_) => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote Agent history provider deadline elapsed", + true, + )), + } + } + }; + drop(provider_result); + drop(response_cancelled); + host_endpoint.validate_current_incoming_peer(peer)?; + + let request_id = request.request().request_id.clone(); + let execution_target_id = request.request().execution_target_id.clone(); + let connection_stamp = request.request().connection_stamp; + let requested_limit = body.limit; + let response_envelope = |result| ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.clone(), + execution_target_id: execution_target_id.clone(), + connection_stamp, + result, + }; + + let page = match result { + Ok(page) => page, + Err(error) => { + let response: ResponseEnvelope = response_envelope(Err(error)); + return request.write_response(&response).await; + } + }; + if page.records.len() > usize::from(requested_limit) + || page.records.len() > usize::from(crate::remote_protocol::MAX_PAGE_SIZE) + || (page.records.is_empty() && page.next_cursor.is_some()) + { + let response: ResponseEnvelope = + response_envelope(Err(ProtocolError::new( + ErrorCode::InvalidPage, + "Agent history provider returned an invalid record count", + false, + ))); + return request.write_response(&response).await; + } + + let mut frames = Vec::with_capacity(page.records.len() + 2); + frames.push(AgentHistoryPageFrame::Start { + record_count: u16::try_from(page.records.len()).map_err(|_| { + ProtocolError::new(ErrorCode::InvalidPage, "history page is too large", false) + })?, + }); + frames.extend(page.records.into_iter().enumerate().map(|(index, record)| { + AgentHistoryPageFrame::Record { + index: u16::try_from(index).expect("validated history page index fits u16"), + record, + } + })); + frames.push(AgentHistoryPageFrame::End { + next_cursor: page.next_cursor, + history_revision: page.history_revision, + }); + + // Preflight every record before emitting Start. A single oversized record + // therefore returns one typed error rather than a truncated partial page. + for frame in &frames { + let response = response_envelope(Ok(frame.clone())); + if let Err(error) = request + .validate_response_frame(&response) + .and_then(|()| validate_frame_encodable(&response)) + { + let error = if error.code == ErrorCode::FrameTooLarge { + ProtocolError::new( + ErrorCode::HistoryRecordTooLarge, + "one Agent history record exceeds Maple's frame limit", + false, + ) + } else { + error + }; + let response: ResponseEnvelope = response_envelope(Err(error)); + return request.write_response(&response).await; + } + } + + for frame in frames { + host_endpoint.validate_current_incoming_peer(peer)?; + let response = response_envelope(Ok(frame)); + request.write_response_frame(&response).await?; + } + request.finish_response() +} + +#[cfg(desktop)] +impl RemoteRuntimeStatusProvider for crate::agent::AgentRuntimeHandle { + fn runtime_status( + &self, + ) -> Pin> + Send + '_>> + { + Box::pin(async move { + let status = self.status().await.map_err(|_| { + // Agent errors can contain local account/runtime detail. Keep + // the remote result bounded and category-only. + ProtocolError::new( + ErrorCode::Internal, + "Maple Agent runtime status is unavailable", + true, + ) + })?; + let status = RemoteAgentRuntimeStatus { + running: status.running, + project_root: status.project_root, + model: status.model, + mode: status.mode, + active_runs: status.active_runs.into_iter().collect(), + }; + status.validate()?; + Ok(status) + }) + } +} + +#[cfg(desktop)] +impl RemoteAgentHistoryProvider for crate::agent::AgentRuntimeHandle { + fn list_agent_history( + &self, + request: &ListAgentHistoryRecordsRequest, + ) -> Pin> + Send + '_>> + { + let request = request.clone(); + Box::pin(async move { + let page = self + .list_session_records_page(crate::agent::AgentHistoryPageRequest { + session_id: request.session_id, + cursor: request.cursor, + limit: Some(usize::from(request.limit)), + }) + .await + .map_err(remote_history_provider_error)?; + if page.live_items.is_some() || page.through_event_cursor.is_some() { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "ordinary Agent history must be persisted-only", + false, + )); + } + let records = page + .records + .into_iter() + .map(|record| { + let items = record + .items + .iter() + .map(crate::agent::project_safe_remote_history_item) + .map(|item| item.map_err(remote_history_provider_error)) + .collect::, ProtocolError>>()?; + let record = RemoteAgentHistoryRecord { + record_id: record.record_id, + role: record.role, + created_ms: record.created_ms, + items, + }; + record.validate()?; + Ok(record) + }) + .collect::, ProtocolError>>()?; + Ok(RemoteAgentHistoryPage { + records, + next_cursor: page.next_cursor, + history_revision: page.history_revision, + }) + }) + } +} + +#[cfg(desktop)] +fn remote_timeline_item( + item: crate::agent::AgentTimelineItem, + absolute_live_snapshot: bool, +) -> Result { + let item = RemoteAgentTimelineItem { + id: item.id, + item_type: item.item_type, + role: item.role, + title: item.title, + text: item.text, + status: item.status, + created_ms: u64::try_from(item.created_ms).map_err(|_| { + ProtocolError::new( + ErrorCode::InvalidFrame, + "Agent timeline timestamp is invalid", + false, + ) + })?, + merge: if absolute_live_snapshot { + "replace".to_string() + } else { + item.merge + }, + }; + item.validate()?; + Ok(item) +} + +#[cfg(desktop)] +impl RemoteAgentSessionListProvider for crate::agent::AgentRuntimeHandle { + fn list_agent_sessions( + &self, + request: &ListAgentSessionsRequest, + ) -> Pin> + Send + '_>> + { + let request = request.clone(); + Box::pin(async move { + let page = self + .list_sessions_page(crate::agent::AgentSessionPageRequest { + project_root: request.project_root, + cursor: request.cursor, + limit: Some(usize::from(request.limit)), + }) + .await + .map_err(remote_history_provider_error)?; + let items = page + .items + .into_iter() + .map(|session| { + let item = RemoteAgentSessionSummary { + id: session.id, + title: session.title, + project_root: session.project_root, + created_ms: session.created_ms, + updated_ms: session.updated_ms, + page_sort_ms: session.page_sort_ms, + message_count: u64::try_from(session.message_count).map_err(|_| { + ProtocolError::new( + ErrorCode::InvalidFrame, + "Agent task message count is invalid", + false, + ) + })?, + model: session.model, + mode: session.mode, + }; + item.validate()?; + Ok(item) + }) + .collect::, ProtocolError>>()?; + Ok(ListAgentSessionsResponse { + items, + next_cursor: page.next_cursor, + }) + }) + } +} + +#[cfg(desktop)] +fn remote_history_provider_error(error: crate::agent::AgentPagingError) -> ProtocolError { + match error { + crate::agent::AgentPagingError::InvalidRequest(_) => ProtocolError::new( + ErrorCode::InvalidPage, + "Agent history page request is invalid", + false, + ), + crate::agent::AgentPagingError::StaleHistory => ProtocolError::new( + ErrorCode::StaleHistory, + "Agent task history changed; reload its newest page", + true, + ), + crate::agent::AgentPagingError::HistoryRecordTooLarge => ProtocolError::new( + ErrorCode::HistoryRecordTooLarge, + "one Agent history record exceeds Maple's frame limit", + false, + ), + crate::agent::AgentPagingError::Unavailable => ProtocolError::new( + ErrorCode::Internal, + "Agent task history is unavailable", + true, + ), + } +} + +#[cfg(all(test, desktop))] +mod tests { + use super::*; + use crate::{ + remote_protocol::{ + ConnectionStamp, ErrorCode, RemoteAgentPresentedLiveEvent, StreamKind, WireBody, + }, + remote_transport::{ + AuthorizationSnapshot, CachedEndpointAddr, HostConnectionClock, HostEpoch, + MapleIrohEndpoint, PairingFence, PairingIncarnation, + }, + secure_storage::{testing::InMemorySecretStore, DeviceIdentity, DeviceSecretSlot}, + }; + use std::{ + collections::{BTreeMap, HashMap, VecDeque}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + }; + use tokio::sync::Notify; + + const TEST_TIMEOUT: Duration = Duration::from_secs(5); + + struct RpcFixture { + controller_endpoint: Arc, + host_endpoint: Arc, + manager: GenerationConnectionManager, + controller_peer: ConnectedPeer, + host_peer: ConnectedPeer, + target_id: String, + } + + impl RpcFixture { + async fn close(self) { + self.manager.clear().expect("clear controller manager"); + tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!(self.controller_endpoint.close(), self.host_endpoint.close()) + }) + .await + .expect("endpoint close timed out"); + } + } + + fn identity(label: &str) -> DeviceIdentity { + let store = InMemorySecretStore::default(); + let slot = DeviceSecretSlot::new("cloud.opensecret.maple.rpc-test", label, 1) + .expect("valid device slot"); + DeviceIdentity::load_or_create(&store, &slot).expect("test identity") + } + + fn endpoint_id(identity: &DeviceIdentity) -> iroh::EndpointId { + identity.public_id().parse().expect("endpoint id") + } + + fn pairing_fence(incarnation: u64) -> PairingFence { + PairingFence::new(PairingIncarnation::new(incarnation).expect("pairing incarnation")) + .expect("pairing fence") + } + + async fn cached_addr(endpoint: &MapleIrohEndpoint) -> CachedEndpointAddr { + tokio::time::timeout(TEST_TIMEOUT, async { + loop { + if let Ok(cached) = endpoint.cached_endpoint_addr(endpoint.endpoint_addr()) { + return cached; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("endpoint address publication timed out") + } + + async fn fixture(label: &str) -> RpcFixture { + fixture_with_target(label, format!("{label}-host-install")).await + } + + async fn fixture_with_target(label: &str, target_id: impl Into) -> RpcFixture { + let controller_identity = identity(&format!("{label}-controller")); + let host_identity = identity(&format!("{label}-host")); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let target_id = target_id.into(); + let controller_endpoint = Arc::new( + MapleIrohEndpoint::bind_direct( + &controller_identity, + &format!("{label}-controller-install"), + HostConnectionClock::new(HostEpoch::new(91).expect("controller epoch")), + ) + .await + .expect("bind controller"), + ); + let host_endpoint = Arc::new( + MapleIrohEndpoint::bind_direct( + &host_identity, + &target_id, + HostConnectionClock::new(HostEpoch::new(41).expect("host epoch")), + ) + .await + .expect("bind host"), + ); + let pairing_incarnation = PairingIncarnation::new(1).expect("pairing incarnation"); + controller_endpoint + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 17, + snapshot_revision: 1, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::from([(host_id, pairing_incarnation)]), + }) + .expect("install controller authorization snapshot"); + host_endpoint + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 17, + snapshot_revision: 1, + incoming_controllers: HashMap::from([(controller_id, pairing_incarnation)]), + outgoing_execution_targets: HashMap::new(), + }) + .expect("install host authorization snapshot"); + let cached_host = cached_addr(&host_endpoint).await; + let manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + target_id.clone(), + pairing_fence(1), + None, + ) + .expect("generation manager"); + let bootstrap_request_id = format!("{label}-bootstrap"); + let (controller_peer, host_peer) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + controller_endpoint.connect_and_install_cached( + &manager, + &cached_host, + host_id, + &bootstrap_request_id, + &target_id, + ), + host_endpoint.accept_authenticated(), + ) + }) + .await + .expect("pair bootstrap timed out"); + RpcFixture { + controller_endpoint, + host_endpoint, + manager, + controller_peer: controller_peer.expect("controller peer"), + host_peer: host_peer.expect("host peer"), + target_id, + } + } + + #[derive(Clone)] + struct StaticProvider { + status: RemoteAgentRuntimeStatus, + calls: Arc, + } + + impl RemoteRuntimeStatusProvider for StaticProvider { + fn runtime_status( + &self, + ) -> Pin< + Box> + Send + '_>, + > { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(std::future::ready(Ok(self.status.clone()))) + } + } + + #[derive(Clone)] + struct StaticHistoryProvider { + page: RemoteAgentHistoryPage, + calls: Arc, + } + + impl RemoteAgentHistoryProvider for StaticHistoryProvider { + fn list_agent_history( + &self, + _request: &ListAgentHistoryRecordsRequest, + ) -> Pin> + Send + '_>> + { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(std::future::ready(Ok(self.page.clone()))) + } + } + + #[derive(Clone)] + struct StaticSessionPageProvider { + page: ListAgentSessionsResponse, + calls: Arc, + } + + impl RemoteAgentSessionListProvider for StaticSessionPageProvider { + fn list_agent_sessions( + &self, + _request: &ListAgentSessionsRequest, + ) -> Pin< + Box> + Send + '_>, + > { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(std::future::ready(Ok(self.page.clone()))) + } + } + + #[derive(Clone)] + struct SnapshotRequiredLiveProvider { + cancel_calls: Arc, + } + + struct SnapshotRequiredLiveService { + cancel_calls: Arc, + } + + struct SnapshotRequiredPendingAttach { + cancel_calls: Arc, + } + + #[async_trait::async_trait] + impl AgentLiveRemoteAttachProvider for SnapshotRequiredLiveProvider { + async fn bind( + &self, + _authority: VerifiedIncomingPeerAuthorization, + ) -> Result, AgentLiveRemoteAttachError> { + Ok(Arc::new(SnapshotRequiredLiveService { + cancel_calls: Arc::clone(&self.cancel_calls), + })) + } + } + + #[async_trait::async_trait] + impl AgentLiveRemoteAttachService for SnapshotRequiredLiveService { + async fn begin_newest( + &self, + _request: AgentHistoryPageRequest, + _subscription_capacity: Option, + ) -> Result { + Ok(AgentLiveRemoteHeadBegin { + page: crate::agent_live_host::AgentLiveSafeHistoryPage { + records: Vec::new(), + next_cursor: None, + history_revision: "0123456789abcdef0123456789abcdef".to_string(), + }, + through_event_cursor: AgentLiveEventCursor { + journal_id: "0123456789abcdef0123456789abcdef".to_string(), + sequence: 7, + }, + live_sessions_complete: true, + live_sessions: Vec::new(), + pending: Box::new(SnapshotRequiredPendingAttach { + cancel_calls: Arc::clone(&self.cancel_calls), + }), + }) + } + + async fn resume( + &self, + _cursor: AgentLiveEventCursor, + _subscription_capacity: Option, + ) -> Result { + Err(AgentLiveRemoteAttachError::Host( + AgentLiveHostError::Coordinator(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::RetentionGap, + )), + )) + } + } + + #[async_trait::async_trait] + impl AgentLiveRemotePendingAttach for SnapshotRequiredPendingAttach { + async fn activate( + &mut self, + ) -> Result + { + Err(AgentLiveRemoteAttachError::Host( + AgentLiveHostError::Coordinator(AgentLiveCoordinatorError::HeadReloadRequired( + HeadReloadReason::PausedSubscriberOverflow, + )), + )) + } + + async fn cancel(self: Box) -> Result<(), AgentLiveRemoteAttachError> { + self.cancel_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[derive(Clone)] + struct ReplayLiveProvider { + deliveries: Vec, + pending_cancel_calls: Arc, + unsubscribe_calls: Arc, + } + + #[derive(Clone)] + struct CountingBindLiveProvider { + bind_calls: Arc, + } + + #[async_trait::async_trait] + impl AgentLiveRemoteAttachProvider for CountingBindLiveProvider { + async fn bind( + &self, + _authority: VerifiedIncomingPeerAuthorization, + ) -> Result, AgentLiveRemoteAttachError> { + self.bind_calls.fetch_add(1, Ordering::SeqCst); + Ok(Arc::new( + crate::agent_live_host::UnavailableAgentLiveRemoteAttachService, + )) + } + } + + struct ReplayLiveService { + deliveries: Vec, + pending_cancel_calls: Arc, + unsubscribe_calls: Arc, + } + + struct ReplayPendingAttach { + deliveries: Option>, + pending_cancel_calls: Arc, + unsubscribe_calls: Arc, + } + + struct ReplayRemoteStream { + deliveries: VecDeque, + unsubscribe_calls: Arc, + } + + #[async_trait::async_trait] + impl AgentLiveRemoteAttachProvider for ReplayLiveProvider { + async fn bind( + &self, + _authority: VerifiedIncomingPeerAuthorization, + ) -> Result, AgentLiveRemoteAttachError> { + Ok(Arc::new(ReplayLiveService { + deliveries: self.deliveries.clone(), + pending_cancel_calls: Arc::clone(&self.pending_cancel_calls), + unsubscribe_calls: Arc::clone(&self.unsubscribe_calls), + })) + } + } + + #[async_trait::async_trait] + impl AgentLiveRemoteAttachService for ReplayLiveService { + async fn begin_newest( + &self, + _request: AgentHistoryPageRequest, + _subscription_capacity: Option, + ) -> Result { + Ok(AgentLiveRemoteHeadBegin { + page: crate::agent_live_host::AgentLiveSafeHistoryPage { + records: Vec::new(), + next_cursor: None, + history_revision: "0123456789abcdef0123456789abcdef".to_string(), + }, + through_event_cursor: replay_cursor(0), + live_sessions_complete: true, + live_sessions: Vec::new(), + pending: Box::new(ReplayPendingAttach { + deliveries: Some(self.deliveries.clone()), + pending_cancel_calls: Arc::clone(&self.pending_cancel_calls), + unsubscribe_calls: Arc::clone(&self.unsubscribe_calls), + }), + }) + } + + async fn resume( + &self, + _cursor: AgentLiveEventCursor, + _subscription_capacity: Option, + ) -> Result { + Err(AgentLiveRemoteAttachError::Unavailable) + } + } + + #[async_trait::async_trait] + impl AgentLiveRemotePendingAttach for ReplayPendingAttach { + async fn activate( + &mut self, + ) -> Result + { + let deliveries = self + .deliveries + .take() + .expect("test pending attachment activates once"); + let through_event_cursor = replay_cursor( + u64::try_from(deliveries.len()).expect("test replay delivery count fits u64"), + ); + Ok(crate::agent_live_host::AgentLiveRemoteActivated { + through_event_cursor, + stream: Box::new(ReplayRemoteStream { + deliveries: deliveries.into(), + unsubscribe_calls: Arc::clone(&self.unsubscribe_calls), + }), + }) + } + + async fn cancel(self: Box) -> Result<(), AgentLiveRemoteAttachError> { + self.pending_cancel_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[async_trait::async_trait] + impl crate::agent_live_host::AgentLiveRemoteStream for ReplayRemoteStream { + async fn recv(&mut self) -> Result { + match self.deliveries.pop_front() { + Some(delivery) => Ok(delivery), + None => std::future::pending().await, + } + } + + async fn unsubscribe(self: Box) -> Result<(), AgentLiveRemoteAttachError> { + self.unsubscribe_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + fn replay_cursor(sequence: u64) -> AgentLiveEventCursor { + AgentLiveEventCursor { + journal_id: "0123456789abcdef0123456789abcdef".to_string(), + sequence, + } + } + + fn replay_delivery(sequence: u64, text: String) -> AgentLiveRemoteDelivery { + AgentLiveRemoteDelivery { + cursor: replay_cursor(sequence), + session_id: "session-replay".to_string(), + run_id: Some("run-replay".to_string()), + event: MapleLiveEvent::TimelineUpsert { + event_id: format!("event-{sequence:03}"), + item: MapleLiveTimelineItem { + id: format!("message-{sequence:03}"), + item_type: MapleLiveItemType::Message, + role: Some(MapleLiveRole::Assistant), + title: None, + text: Some(text), + status: None, + created_ms: 1_700_000_000_000 + sequence, + merge: MapleLiveMerge::Replace, + }, + }, + } + } + + fn history_item(id: impl Into, text: impl Into) -> RemoteAgentTimelineItem { + RemoteAgentTimelineItem { + id: id.into(), + item_type: "message".to_string(), + role: Some("assistant".to_string()), + title: None, + text: Some(text.into()), + status: None, + created_ms: 1_700_000_000_000, + merge: "replace".to_string(), + } + } + + fn history_record( + record_id: impl Into, + items: Vec, + ) -> RemoteAgentHistoryRecord { + RemoteAgentHistoryRecord { + record_id: record_id.into(), + role: "assistant".to_string(), + created_ms: 1_700_000_000_000, + items, + } + } + + fn history_page(records: Vec) -> RemoteAgentHistoryPage { + RemoteAgentHistoryPage { + records, + next_cursor: None, + history_revision: "0123456789abcdef0123456789abcdef".to_string(), + } + } + + fn running_status() -> RemoteAgentRuntimeStatus { + RemoteAgentRuntimeStatus { + running: true, + project_root: Some("/tmp/maple-remote-rpc".into()), + model: Some("glm-5-2".into()), + mode: Some("smart_approve".into()), + active_runs: BTreeMap::from([("session-01".into(), "run-01".into())]), + } + } + + fn test_server_with_live(live: RemoteAgentLiveRpcHost) -> RemoteAgentRpcServer { + RemoteAgentRpcServer::new( + Arc::new(StaticProvider { + status: running_status(), + calls: Arc::new(AtomicUsize::new(0)), + }), + Arc::new(StaticHistoryProvider { + page: history_page(Vec::new()), + calls: Arc::new(AtomicUsize::new(0)), + }), + Arc::new(StaticSessionPageProvider { + page: ListAgentSessionsResponse { + items: Vec::new(), + next_cursor: None, + }, + calls: Arc::new(AtomicUsize::new(0)), + }), + live, + ) + } + + async fn wait_for_count(counter: &AtomicUsize, expected: usize, label: &str) { + tokio::time::timeout(TEST_TIMEOUT, async { + while counter.load(Ordering::SeqCst) != expected { + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {label}")); + } + + #[tokio::test] + async fn runtime_status_roundtrip_decodes_the_remote_result_on_control_lane() { + let fixture = fixture("status-roundtrip").await; + let calls = Arc::new(AtomicUsize::new(0)); + let provider = StaticProvider { + status: running_status(), + calls: calls.clone(), + }; + assert_eq!( + GetRuntimeStatusRequest::new().stream_kind(), + StreamKind::Control + ); + + let (controller_result, host_result) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + get_remote_runtime_status(&fixture.manager, "status-request-01"), + serve_next_remote_runtime_status( + &fixture.host_endpoint, + &fixture.host_peer, + &provider, + ), + ) + }) + .await + .expect("runtime status RPC timed out"); + assert_eq!( + controller_result.expect("controller result"), + running_status() + ); + host_result.expect("host result"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn native_history_page_roundtrip_preserves_one_row_with_multiple_items() { + let fixture = fixture("history-roundtrip").await; + let calls = Arc::new(AtomicUsize::new(0)); + let page = history_page(vec![history_record( + "epoch-record-01", + vec![ + history_item("message-01", "answer"), + RemoteAgentTimelineItem { + id: "tool-01".to_string(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: Some(crate::remote_protocol::SAFE_REMOTE_TOOL_TITLE.to_string()), + text: None, + status: Some("completed".to_string()), + created_ms: 1_700_000_000_000, + merge: "replace".to_string(), + }, + ], + )]); + let provider = StaticHistoryProvider { + page: page.clone(), + calls: calls.clone(), + }; + let request = ListAgentHistoryRecordsRequest::new("session-01", None, 1) + .expect("valid history request"); + + let (controller_result, host_result) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + get_remote_agent_history_page(&fixture.manager, "history-request-01", request), + serve_next_remote_agent_history_page( + &fixture.host_endpoint, + &fixture.host_peer, + &provider, + ), + ) + }) + .await + .expect("history RPC timed out"); + assert_eq!(controller_result.expect("controller result"), page); + host_result.expect("host result"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn count_page_can_exceed_one_mebibyte_in_aggregate() { + let fixture = fixture("history-aggregate").await; + let calls = Arc::new(AtomicUsize::new(0)); + let records = (0..40) + .map(|index| { + history_record( + format!("epoch-record-{index:02}"), + vec![history_item( + format!("message-{index:02}"), + "x".repeat(30_000), + )], + ) + }) + .collect::>(); + assert!( + records + .iter() + .flat_map(|record| &record.items) + .filter_map(|item| item.text.as_ref()) + .map(String::len) + .sum::() + > crate::remote_protocol::MAX_FRAME_BYTES as usize + ); + let page = history_page(records); + let provider = StaticHistoryProvider { + page: page.clone(), + calls: calls.clone(), + }; + let request = ListAgentHistoryRecordsRequest::new("session-01", None, 50) + .expect("valid history request"); + + let (controller_result, host_result) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + get_remote_agent_history_page( + &fixture.manager, + "history-request-aggregate", + request + ), + serve_next_remote_agent_history_page( + &fixture.host_endpoint, + &fixture.host_peer, + &provider, + ), + ) + }) + .await + .expect("aggregate history RPC timed out"); + assert_eq!(controller_result.expect("controller result"), page); + host_result.expect("host result"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn one_oversized_history_record_returns_the_explicit_error() { + let fixture = fixture("history-oversized").await; + let half = crate::remote_protocol::MAX_HISTORY_RECORD_PRESENTATION_BYTES / 2; + let provider = StaticHistoryProvider { + page: history_page(vec![history_record( + "epoch-record-oversized", + vec![ + history_item("message-a", "a".repeat(half)), + history_item("message-b", "b".repeat(half)), + ], + )]), + calls: Arc::new(AtomicUsize::new(0)), + }; + let request = ListAgentHistoryRecordsRequest::new("session-01", None, 1) + .expect("valid history request"); + + let (controller_result, host_result) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + get_remote_agent_history_page( + &fixture.manager, + "history-request-oversized", + request + ), + serve_next_remote_agent_history_page( + &fixture.host_endpoint, + &fixture.host_peer, + &provider, + ), + ) + }) + .await + .expect("oversized history RPC timed out"); + assert_eq!( + controller_result + .expect_err("controller rejects oversized row") + .code, + ErrorCode::HistoryRecordTooLarge + ); + host_result.expect("host writes typed oversized-row error"); + fixture.close().await; + } + + #[tokio::test] + async fn session_summary_page_uses_the_same_authenticated_bulk_adapter() { + let fixture = fixture("session-page-roundtrip").await; + let calls = Arc::new(AtomicUsize::new(0)); + let page = ListAgentSessionsResponse { + items: vec![RemoteAgentSessionSummary { + id: "session-01".to_string(), + title: "Native task".to_string(), + project_root: "/tmp/maple".to_string(), + created_ms: 1_700_000_000_000, + updated_ms: 1_700_000_000_001, + page_sort_ms: 1_700_000_000_002, + message_count: 3, + model: Some("glm-5-2".to_string()), + mode: "smart_approve".to_string(), + }], + next_cursor: Some("session-cursor-02".to_string()), + }; + let provider = StaticSessionPageProvider { + page: page.clone(), + calls: calls.clone(), + }; + let request = ListAgentSessionsRequest { + operation: crate::remote_protocol::AgentSessionListOperation::ListSessions, + project_root: Some("/tmp/maple".to_string()), + cursor: None, + limit: 1, + }; + + let (controller_result, host_result) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + get_remote_agent_sessions_page(&fixture.manager, "session-page-request", request), + serve_next_remote_agent_sessions_page( + &fixture.host_endpoint, + &fixture.host_peer, + &provider, + ), + ) + }) + .await + .expect("session page RPC timed out"); + assert_eq!(controller_result.expect("controller result"), page); + host_result.expect("host result"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn peer_wide_dispatcher_routes_mixed_control_and_bulk_operations_without_theft() { + let fixture = fixture("peer-wide-dispatch").await; + let status_calls = Arc::new(AtomicUsize::new(0)); + let history_calls = Arc::new(AtomicUsize::new(0)); + let session_calls = Arc::new(AtomicUsize::new(0)); + let expected_history = history_page(vec![history_record( + "dispatch-record-01", + vec![history_item("dispatch-message-01", "answer")], + )]); + let expected_sessions = ListAgentSessionsResponse { + items: vec![RemoteAgentSessionSummary { + id: "session-dispatch".to_string(), + title: "Dispatched task".to_string(), + project_root: "/tmp/maple".to_string(), + created_ms: 1_700_000_000_000, + updated_ms: 1_700_000_000_001, + page_sort_ms: 1_700_000_000_002, + message_count: 1, + model: None, + mode: "smart_approve".to_string(), + }], + next_cursor: None, + }; + let server = RemoteAgentRpcServer::new( + Arc::new(StaticProvider { + status: running_status(), + calls: Arc::clone(&status_calls), + }), + Arc::new(StaticHistoryProvider { + page: expected_history.clone(), + calls: Arc::clone(&history_calls), + }), + Arc::new(StaticSessionPageProvider { + page: expected_sessions.clone(), + calls: Arc::clone(&session_calls), + }), + RemoteAgentLiveRpcHost::unavailable(), + ); + let history_request = ListAgentHistoryRecordsRequest::new("session-dispatch", None, 1) + .expect("history request"); + let sessions_request = ListAgentSessionsRequest { + operation: crate::remote_protocol::AgentSessionListOperation::ListSessions, + project_root: Some("/tmp/maple".to_string()), + cursor: None, + limit: 1, + }; + + let host = async { + let mut workers = Vec::new(); + for _ in 0..4 { + workers.push( + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server.clone(), + ) + .await + .expect("peer-wide dispatcher accepts mixed request"), + ); + } + for worker in workers { + worker + .await + .expect("peer-wide worker join") + .expect("peer-wide worker result"); + } + }; + let (status, activation, history, sessions, ()) = + tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + get_remote_runtime_status(&fixture.manager, "dispatch-status"), + request_remote_agent_live_activation( + &fixture.manager, + "dispatch-activate", + "missing-attach" + ), + get_remote_agent_history_page( + &fixture.manager, + "dispatch-history", + history_request + ), + get_remote_agent_sessions_page( + &fixture.manager, + "dispatch-sessions", + sessions_request + ), + host, + ) + }) + .await + .expect("mixed peer-wide dispatch timed out"); + assert_eq!(status.expect("status result"), running_status()); + assert_eq!( + activation + .expect_err("unknown attachment returns a typed live error") + .code, + ErrorCode::AgentLiveUnavailable + ); + assert_eq!(history.expect("history result"), expected_history); + assert_eq!(sessions.expect("task-list result"), expected_sessions); + assert_eq!(status_calls.load(Ordering::SeqCst), 1); + assert_eq!(history_calls.load(Ordering::SeqCst), 1); + assert_eq!(session_calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn activation_snapshot_required_is_correlated_on_control_and_first_events_frame() { + let fixture = fixture("activation-snapshot-required").await; + let cancel_calls = Arc::new(AtomicUsize::new(0)); + let server = RemoteAgentRpcServer::new( + Arc::new(StaticProvider { + status: running_status(), + calls: Arc::new(AtomicUsize::new(0)), + }), + Arc::new(StaticHistoryProvider { + page: history_page(Vec::new()), + calls: Arc::new(AtomicUsize::new(0)), + }), + Arc::new(StaticSessionPageProvider { + page: ListAgentSessionsResponse { + items: Vec::new(), + next_cursor: None, + }, + calls: Arc::new(AtomicUsize::new(0)), + }), + RemoteAgentLiveRpcHost::new(Arc::new(SnapshotRequiredLiveProvider { + cancel_calls: Arc::clone(&cancel_calls), + })), + ); + let begin_body = + BeginAgentLiveAttachRequest::new("session-snapshot", 25).expect("valid Begin request"); + let (begin_result, begin_worker) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + begin_remote_agent_live_attach( + &fixture.manager, + "snapshot-required-begin", + begin_body, + ), + async { + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server.clone(), + ) + .await + }, + ) + }) + .await + .expect("Begin snapshot timed out"); + let (snapshot, stream) = begin_result.expect("receive complete C0 snapshot"); + assert_eq!(snapshot.through_event_cursor.sequence, 7); + let begin_worker = begin_worker.expect("Events dispatcher accepts Begin"); + + let host_control = async { + let mut workers = Vec::new(); + for _ in 0..2 { + workers.push( + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server.clone(), + ) + .await + .expect("dispatcher accepts Activate then cleanup Cancel"), + ); + } + for worker in workers { + worker + .await + .expect("Control worker join") + .expect("Control worker result"); + } + }; + let (activation, ()) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + activate_remote_agent_live_attach( + "snapshot-required-activate", + "snapshot-required-cleanup", + stream, + |_| async { Ok(()) }, + ), + host_control, + ) + }) + .await + .expect("activation SnapshotRequired flow timed out"); + let error = match activation { + Ok(_) => panic!("activation requires a fresh authoritative snapshot"), + Err(error) => error, + }; + assert_eq!( + error.code, + ErrorCode::SnapshotRequired, + "unexpected activation error: {error:?}" + ); + assert!(error.message.contains("PausedSubscriberOverflow")); + begin_worker + .await + .expect("Events Begin worker join") + .expect("Events Begin worker returns after terminal frame"); + assert_eq!(cancel_calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn activation_drains_replay_larger_than_256_kib_before_resolving() { + let fixture = fixture("activation-large-replay").await; + let deliveries = (1..=10) + .map(|sequence| replay_delivery(sequence, "x".repeat(40 * 1_024))) + .collect::>(); + let replay_bytes = deliveries + .iter() + .filter_map(|delivery| match &delivery.event { + MapleLiveEvent::TimelineUpsert { item, .. } => item.text.as_ref(), + _ => None, + }) + .map(String::len) + .sum::(); + assert!(replay_bytes > 256 * 1_024); + let pending_cancel_calls = Arc::new(AtomicUsize::new(0)); + let unsubscribe_calls = Arc::new(AtomicUsize::new(0)); + let live = RemoteAgentLiveRpcHost::new(Arc::new(ReplayLiveProvider { + deliveries, + pending_cancel_calls: Arc::clone(&pending_cancel_calls), + unsubscribe_calls: Arc::clone(&unsubscribe_calls), + })); + let server = test_server_with_live(live); + let begin_body = BeginAgentLiveAttachRequest::new("session-replay", 25) + .expect("valid replay Begin request"); + let (begin_result, begin_dispatch) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + begin_remote_agent_live_attach(&fixture.manager, "large-replay-begin", begin_body,), + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server.clone(), + ), + ) + }) + .await + .expect("large replay Begin timed out"); + let (snapshot, stream) = begin_result.expect("receive replay C0 snapshot"); + assert_eq!(snapshot.through_event_cursor.sequence, 0); + let begin_worker = begin_dispatch.expect("Events dispatcher accepts replay Begin"); + + let applied_events = Arc::new(AtomicUsize::new(0)); + let applied_bytes = Arc::new(AtomicUsize::new(0)); + let activation = { + let applied_events = Arc::clone(&applied_events); + let applied_bytes = Arc::clone(&applied_bytes); + activate_remote_agent_live_attach( + "large-replay-activate", + "large-replay-activation-cleanup", + stream, + move |delivery| { + let applied_events = Arc::clone(&applied_events); + let applied_bytes = Arc::clone(&applied_bytes); + async move { + let text_bytes = match delivery.event { + RemoteAgentPresentedLiveEvent::TimelineUpsert { item } => { + item.text.map_or(0, |text| text.len()) + } + _ => 0, + }; + applied_events.fetch_add(1, Ordering::SeqCst); + applied_bytes.fetch_add(text_bytes, Ordering::SeqCst); + Ok(()) + } + }, + ) + }; + let host_activation = async { + let worker = serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server.clone(), + ) + .await + .expect("dispatcher accepts replay Activate"); + worker + .await + .expect("replay Activate worker join") + .expect("replay Activate worker result"); + }; + let (activation, ()) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!(activation, host_activation) + }) + .await + .expect("large replay activation timed out"); + let (start, stream) = activation.expect("large replay activation succeeds"); + assert_eq!(start.through_event_cursor.sequence, 10); + assert_eq!(applied_events.load(Ordering::SeqCst), 10); + assert_eq!(applied_bytes.load(Ordering::SeqCst), replay_bytes); + + let (cancel_result, cancel_dispatch) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + stream.cancel("large-replay-cancel"), + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server, + ), + ) + }) + .await + .expect("large replay cancellation timed out"); + cancel_result.expect("active replay cancellation acknowledged"); + cancel_dispatch + .expect("dispatcher accepts replay Cancel") + .await + .expect("replay Cancel worker join") + .expect("replay Cancel worker result"); + let events_error = begin_worker + .await + .expect("large replay Events worker join") + .expect_err("active cancellation terminates the Events owner"); + assert_eq!(events_error.code, ErrorCode::AgentLiveUnavailable); + assert_eq!(pending_cancel_calls.load(Ordering::SeqCst), 0); + assert_eq!(unsubscribe_calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn replay_callback_failure_cancels_the_learned_live_stream_id() { + let fixture = fixture("activation-callback-failure").await; + let deliveries = (1..=2) + .map(|sequence| replay_delivery(sequence, format!("replay-{sequence}"))) + .collect::>(); + let pending_cancel_calls = Arc::new(AtomicUsize::new(0)); + let unsubscribe_calls = Arc::new(AtomicUsize::new(0)); + let live = RemoteAgentLiveRpcHost::new(Arc::new(ReplayLiveProvider { + deliveries, + pending_cancel_calls: Arc::clone(&pending_cancel_calls), + unsubscribe_calls: Arc::clone(&unsubscribe_calls), + })); + let server = test_server_with_live(live); + let (begin_result, begin_dispatch) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + begin_remote_agent_live_attach( + &fixture.manager, + "callback-failure-begin", + BeginAgentLiveAttachRequest::new("session-replay", 25) + .expect("valid callback-failure Begin request"), + ), + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server.clone(), + ), + ) + }) + .await + .expect("callback-failure Begin timed out"); + let (_snapshot, stream) = begin_result.expect("receive callback-failure C0 snapshot"); + let begin_worker = begin_dispatch.expect("Events dispatcher accepts callback Begin"); + let callback_calls = Arc::new(AtomicUsize::new(0)); + + let host_control = async { + let mut workers = Vec::new(); + for _ in 0..2 { + workers.push( + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server.clone(), + ) + .await + .expect("dispatcher accepts callback Activate then learned-id Cancel"), + ); + } + for worker in workers { + worker + .await + .expect("callback Control worker join") + .expect("callback Control worker result"); + } + }; + let activation = { + let callback_calls = Arc::clone(&callback_calls); + activate_remote_agent_live_attach( + "callback-failure-activate", + "callback-failure-cleanup", + stream, + move |_| { + let callback_calls = Arc::clone(&callback_calls); + async move { + callback_calls.fetch_add(1, Ordering::SeqCst); + Err(ProtocolError::new( + ErrorCode::Internal, + "test replay projection failed", + false, + )) + } + }, + ) + }; + let (activation, ()) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!(activation, host_control) + }) + .await + .expect("callback-failure activation timed out"); + let activation_error = match activation { + Ok(_) => panic!("replay callback failure must remain visible"), + Err(error) => error, + }; + assert_eq!(activation_error.message, "test replay projection failed"); + let events_error = begin_worker + .await + .expect("callback Events worker join") + .expect_err("learned-id cancellation terminates the Events owner"); + assert_eq!(events_error.code, ErrorCode::AgentLiveUnavailable); + assert_eq!(callback_calls.load(Ordering::SeqCst), 1); + assert_eq!( + pending_cancel_calls.load(Ordering::SeqCst), + 0, + "cleanup must use the live-stream alias learned from StreamStart" + ); + assert_eq!(unsubscribe_calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn cancelling_controller_activation_after_stream_start_wakes_host_unsubscribe() { + let fixture = fixture("activation-future-cancel").await; + let pending_cancel_calls = Arc::new(AtomicUsize::new(0)); + let unsubscribe_calls = Arc::new(AtomicUsize::new(0)); + let live = RemoteAgentLiveRpcHost::new(Arc::new(ReplayLiveProvider { + deliveries: vec![replay_delivery(1, "activation-cancel".to_string())], + pending_cancel_calls: Arc::clone(&pending_cancel_calls), + unsubscribe_calls: Arc::clone(&unsubscribe_calls), + })); + let server = test_server_with_live(live); + let (begin_result, begin_dispatch) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + begin_remote_agent_live_attach( + &fixture.manager, + "activation-cancel-begin", + BeginAgentLiveAttachRequest::new("session-replay", 25) + .expect("valid activation-cancel Begin request"), + ), + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server.clone(), + ), + ) + }) + .await + .expect("activation-cancel Begin timed out"); + let (_snapshot, stream) = begin_result.expect("receive activation-cancel C0 snapshot"); + let begin_worker = begin_dispatch.expect("Events dispatcher accepts cancellation Begin"); + let callback_started = Arc::new(Notify::new()); + let activation_task = { + let callback_started = Arc::clone(&callback_started); + tokio::spawn(activate_remote_agent_live_attach( + "activation-cancel-activate", + "activation-cancel-explicit-cleanup", + stream, + move |_| { + let callback_started = Arc::clone(&callback_started); + async move { + callback_started.notify_one(); + std::future::pending().await + } + }, + )) + }; + let activation_worker = serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server, + ) + .await + .expect("dispatcher accepts cancellable Activate"); + tokio::time::timeout(TEST_TIMEOUT, callback_started.notified()) + .await + .expect("controller observed StreamStart and first replay event"); + activation_task.abort(); + let _ = activation_task.await; + let activation_result = activation_worker + .await + .expect("cancellable Activate worker join"); + if let Err(error) = activation_result { + assert!(matches!( + error.code, + ErrorCode::TransportUnavailable | ErrorCode::AgentLiveUnavailable + )); + } + wait_for_count( + unsubscribe_calls.as_ref(), + 1, + "host unsubscribe after controller activation cancellation", + ) + .await; + let events_error = begin_worker + .await + .expect("activation-cancel Events worker join") + .expect_err("abandoned Events response terminates its native owner"); + assert!(matches!( + events_error.code, + ErrorCode::TransportUnavailable | ErrorCode::AgentLiveUnavailable + )); + assert_eq!(pending_cancel_calls.load(Ordering::SeqCst), 0); + fixture.close().await; + } + + #[tokio::test] + async fn dropping_worker_waiter_detaches_owner_and_stop_still_cancels_pending_attach() { + let fixture = fixture("worker-detach-stop").await; + let pending_cancel_calls = Arc::new(AtomicUsize::new(0)); + let unsubscribe_calls = Arc::new(AtomicUsize::new(0)); + let live = RemoteAgentLiveRpcHost::new(Arc::new(ReplayLiveProvider { + deliveries: Vec::new(), + pending_cancel_calls: Arc::clone(&pending_cancel_calls), + unsubscribe_calls, + })); + let server = test_server_with_live(live.clone()); + let (begin_result, begin_dispatch) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + begin_remote_agent_live_attach( + &fixture.manager, + "worker-detach-begin", + BeginAgentLiveAttachRequest::new("session-replay", 25) + .expect("valid worker-detach Begin request"), + ), + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server, + ), + ) + }) + .await + .expect("worker-detach Begin timed out"); + let (_snapshot, stream) = begin_result.expect("receive worker-detach C0 snapshot"); + let worker = begin_dispatch.expect("dispatcher returns opaque worker owner"); + drop(worker); + drop(stream); + wait_for_count( + pending_cancel_calls.as_ref(), + 1, + "detached owner pending cancellation", + ) + .await; + assert!(live + .inner + .state + .lock() + .expect("live registry") + .pending + .is_empty()); + fixture.close().await; + } + + #[tokio::test] + async fn pending_attach_ttl_cancels_native_token_before_releasing_occupancy() { + let fixture = fixture("pending-ttl").await; + let pending_cancel_calls = Arc::new(AtomicUsize::new(0)); + let live = RemoteAgentLiveRpcHost::new(Arc::new(ReplayLiveProvider { + deliveries: Vec::new(), + pending_cancel_calls: Arc::clone(&pending_cancel_calls), + unsubscribe_calls: Arc::new(AtomicUsize::new(0)), + })); + let (begin_result, host_result) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + begin_remote_agent_live_attach( + &fixture.manager, + "pending-ttl-begin", + BeginAgentLiveAttachRequest::new("session-replay", 25) + .expect("valid pending-TTL Begin request"), + ), + remote_live_server::serve_next_remote_agent_live_request_for_test_with_ttl( + &fixture.host_endpoint, + &fixture.host_peer, + &live, + Duration::from_millis(20), + ), + ) + }) + .await + .expect("pending TTL flow timed out"); + let (_snapshot, stream) = begin_result.expect("receive pending-TTL C0 snapshot"); + let host_error = host_result.expect_err("TTL terminates the pending Events owner"); + assert_eq!(host_error.code, ErrorCode::AgentLiveUnavailable); + assert_eq!(pending_cancel_calls.load(Ordering::SeqCst), 1); + assert!(live + .inner + .state + .lock() + .expect("live registry") + .pending + .is_empty()); + drop(stream); + fixture.close().await; + } + + #[tokio::test] + async fn c0_to_activate_handover_uses_captured_peer_and_old_owner_cleans_on_close() { + let fixture = fixture("activation-peer-handover").await; + let pending_cancel_calls = Arc::new(AtomicUsize::new(0)); + let live = RemoteAgentLiveRpcHost::new(Arc::new(ReplayLiveProvider { + deliveries: Vec::new(), + pending_cancel_calls: Arc::clone(&pending_cancel_calls), + unsubscribe_calls: Arc::new(AtomicUsize::new(0)), + })); + let server = test_server_with_live(live); + let (begin_result, begin_dispatch) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + begin_remote_agent_live_attach( + &fixture.manager, + "handover-begin", + BeginAgentLiveAttachRequest::new("session-replay", 25) + .expect("valid handover Begin request"), + ), + serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server, + ), + ) + }) + .await + .expect("handover Begin timed out"); + let (_snapshot, stream) = begin_result.expect("receive handover C0 snapshot"); + let begin_worker = begin_dispatch.expect("Events dispatcher accepts handover Begin"); + let old_stamp = fixture.controller_peer.connection_stamp(); + let cached_host = cached_addr(&fixture.host_endpoint).await; + let host_id = fixture.controller_peer.remote_id(); + let (new_controller_peer, new_host_peer) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + fixture.controller_endpoint.connect_and_install_cached( + &fixture.manager, + &cached_host, + host_id, + "handover-reconnect", + &fixture.target_id, + ), + fixture.host_endpoint.accept_authenticated(), + ) + }) + .await + .expect("same-epoch handover timed out"); + let new_controller_peer = new_controller_peer.expect("install handover controller peer"); + let new_host_peer = new_host_peer.expect("accept handover host peer"); + assert_eq!( + new_controller_peer.connection_stamp().host_epoch(), + old_stamp.host_epoch() + ); + assert!(new_controller_peer.connection_stamp().generation() > old_stamp.generation()); + + let activation = tokio::time::timeout( + TEST_TIMEOUT, + activate_remote_agent_live_attach( + "handover-activate", + "handover-cleanup", + stream, + |_| async { Ok(()) }, + ), + ) + .await + .expect("captured old peer activation must fail promptly"); + let activation_error = match activation { + Ok(_) => panic!("C0 attachment cannot migrate to the manager's new peer"), + Err(error) => error, + }; + assert!(matches!( + activation_error.code, + ErrorCode::TransportUnavailable | ErrorCode::AgentLiveUnavailable + )); + wait_for_count( + pending_cancel_calls.as_ref(), + 1, + "old peer pending cancellation after handover", + ) + .await; + begin_worker + .await + .expect("old peer Events worker join") + .expect_err("handover closes the old Events owner"); + drop((new_controller_peer, new_host_peer)); + fixture.close().await; + } + + #[tokio::test] + async fn peer_wide_events_dispatcher_routes_resume_snapshot_required() { + let fixture = fixture("resume-snapshot-required").await; + let server = RemoteAgentRpcServer::new( + Arc::new(StaticProvider { + status: running_status(), + calls: Arc::new(AtomicUsize::new(0)), + }), + Arc::new(StaticHistoryProvider { + page: history_page(Vec::new()), + calls: Arc::new(AtomicUsize::new(0)), + }), + Arc::new(StaticSessionPageProvider { + page: ListAgentSessionsResponse { + items: Vec::new(), + next_cursor: None, + }, + calls: Arc::new(AtomicUsize::new(0)), + }), + RemoteAgentLiveRpcHost::new(Arc::new(SnapshotRequiredLiveProvider { + cancel_calls: Arc::new(AtomicUsize::new(0)), + })), + ); + let cursor = RemoteLiveEventCursor { + journal_id: "0123456789abcdef0123456789abcdef".to_string(), + sequence: 7, + }; + let (controller, host_worker) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + resume_remote_agent_live_events( + &fixture.manager, + "resume-snapshot-required", + cursor, + fixture.controller_peer.connection_stamp().host_epoch(), + ), + async { + let worker = serve_next_remote_agent_request( + Arc::clone(&fixture.host_endpoint), + fixture.host_peer.clone(), + server, + ) + .await + .expect("dispatcher accepts Resume"); + worker.await.expect("Resume worker join") + }, + ) + }) + .await + .expect("Resume SnapshotRequired flow timed out"); + let error = match controller { + Err(error) => error, + Ok(_) => panic!("resume requires a fresh authoritative snapshot"), + }; + assert_eq!(error.code, ErrorCode::SnapshotRequired); + assert!(error.message.contains("RetentionGap")); + host_worker.expect("Resume worker writes SnapshotRequired"); + fixture.close().await; + } + + #[tokio::test] + async fn stale_resume_origin_epoch_is_rejected_before_live_provider_bind() { + let fixture = fixture("resume-origin-epoch-before-bind").await; + let bind_calls = Arc::new(AtomicUsize::new(0)); + let live = RemoteAgentLiveRpcHost::new(Arc::new(CountingBindLiveProvider { + bind_calls: Arc::clone(&bind_calls), + })); + let authority = fixture + .host_endpoint + .verified_incoming_peer_authorization(&fixture.host_peer) + .expect("verified host authority"); + let current_stamp = fixture.host_peer.connection_stamp(); + let stale_epoch = current_stamp + .host_epoch() + .checked_add(1) + .expect("test host epoch can advance"); + let request = ResumeAgentLiveEventsRequest::new( + RemoteLiveEventCursor { + journal_id: "0123456789abcdef0123456789abcdef".to_string(), + sequence: 7, + }, + stale_epoch, + ) + .expect("valid stale-origin Resume request"); + + let error = match remote_live_server::prepare_remote_agent_live_resume( + &live, + &authority, + current_stamp, + &request, + ) + .await + { + Ok(_) => panic!("stale host epoch must fail before provider binding"), + Err(error) => error, + }; + assert_eq!(error.code, ErrorCode::StaleGeneration); + assert_eq!( + bind_calls.load(Ordering::SeqCst), + 0, + "stale host-epoch validation must precede provider binding" + ); + fixture.close().await; + } + + #[tokio::test] + async fn wrong_target_and_generation_are_rejected_before_host_dispatch() { + let fixture = fixture("status-fence").await; + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "wrong-target".into(), + execution_target_id: "different-host".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: fixture.controller_peer.connection_stamp(), + body: GetRuntimeStatusRequest::new(), + }; + let target_error = fixture + .controller_peer + .request::<_, GetRuntimeStatusResponse>(&request) + .await + .expect_err("wrong target must fail"); + assert_eq!(target_error.code, ErrorCode::WrongEndpoint); + + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "wrong-generation".into(), + execution_target_id: fixture.target_id.clone(), + direction: PeerDirection::ControllerToHost, + connection_stamp: ConnectionStamp::new( + fixture.controller_peer.connection_stamp().host_epoch(), + fixture.controller_peer.connection_stamp().generation() + 1, + ) + .expect("different valid stamp"), + body: GetRuntimeStatusRequest::new(), + }; + let generation_error = fixture + .controller_peer + .request::<_, GetRuntimeStatusResponse>(&request) + .await + .expect_err("stale generation must fail"); + assert_eq!(generation_error.code, ErrorCode::StaleGeneration); + + let history_body = ListAgentHistoryRecordsRequest::new("session-01", None, 1) + .expect("valid history request"); + let wrong_target_history = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "wrong-history-target".into(), + execution_target_id: "different-host".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: fixture.controller_peer.connection_stamp(), + body: history_body.clone(), + }; + let history_target_error = match fixture + .controller_peer + .start_streaming_request(wrong_target_history) + .await + { + Err(error) => error, + Ok(_) => panic!("wrong history target must fail before opening the stream"), + }; + assert_eq!(history_target_error.code, ErrorCode::WrongEndpoint); + + let wrong_generation_history = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "wrong-history-generation".into(), + execution_target_id: fixture.target_id.clone(), + direction: PeerDirection::ControllerToHost, + connection_stamp: ConnectionStamp::new( + fixture.controller_peer.connection_stamp().host_epoch(), + fixture.controller_peer.connection_stamp().generation() + 1, + ) + .expect("different valid stamp"), + body: history_body, + }; + let history_generation_error = match fixture + .controller_peer + .start_streaming_request(wrong_generation_history) + .await + { + Err(error) => error, + Ok(_) => panic!("stale history generation must fail before opening the stream"), + }; + assert_eq!(history_generation_error.code, ErrorCode::StaleGeneration); + fixture.close().await; + } + + #[tokio::test] + async fn generation_manager_rejects_a_different_pairing_fence_before_dial() { + let controller_identity = identity("wrong-pair-controller"); + let host_identity = identity("wrong-pair-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let target_id = "wrong-pair-host-install"; + let controller = MapleIrohEndpoint::bind_direct( + &controller_identity, + "wrong-pair-controller-install", + HostConnectionClock::new(HostEpoch::new(92).expect("controller epoch")), + ) + .await + .expect("bind controller"); + let host = MapleIrohEndpoint::bind_direct( + &host_identity, + target_id, + HostConnectionClock::new(HostEpoch::new(42).expect("host epoch")), + ) + .await + .expect("bind host"); + controller + .authorize_outgoing_execution_target(host_id) + .expect("authorize outgoing host"); + host.authorize_incoming_controller(controller_id) + .expect("authorize incoming controller"); + let cached_host = cached_addr(&host).await; + let wrong_manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + target_id, + pairing_fence(2), + None, + ) + .expect("wrong-fence manager is structurally valid"); + let error = controller + .connect_and_install_cached( + &wrong_manager, + &cached_host, + host_id, + "wrong-pair-bootstrap", + target_id, + ) + .await + .expect_err("pairing fence mismatch must fail before dial"); + assert_eq!(error.code, ErrorCode::Unauthorized); + tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!(controller.close(), host.close()) + }) + .await + .expect("endpoint close timed out"); + } + + #[tokio::test] + async fn revoked_host_generation_is_rejected_before_provider_dispatch() { + let fixture = fixture("status-revoked").await; + let calls = Arc::new(AtomicUsize::new(0)); + let provider = StaticProvider { + status: running_status(), + calls: calls.clone(), + }; + fixture + .host_endpoint + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 17, + snapshot_revision: 2, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::new(), + }) + .expect("revoke controller through a newer authorization snapshot"); + + let error = + serve_next_remote_runtime_status(&fixture.host_endpoint, &fixture.host_peer, &provider) + .await + .expect_err("revoked generation must fail closed"); + assert_eq!(error.code, ErrorCode::Revoked); + assert_eq!(calls.load(Ordering::SeqCst), 0); + fixture.close().await; + } + + struct PendingProvider { + started: Arc, + dropped: Arc, + } + + struct DropSignal(Arc); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.notify_one(); + } + } + + impl RemoteRuntimeStatusProvider for PendingProvider { + fn runtime_status( + &self, + ) -> Pin< + Box> + Send + '_>, + > { + let started = self.started.clone(); + let dropped = self.dropped.clone(); + Box::pin(async move { + let _drop_signal = DropSignal(dropped); + started.notify_one(); + std::future::pending().await + }) + } + } + + #[tokio::test] + async fn controller_cancellation_drops_provider_work_and_releases_the_lane() { + let fixture = fixture("status-cancel").await; + let started = Arc::new(Notify::new()); + let dropped = Arc::new(Notify::new()); + let provider = PendingProvider { + started: started.clone(), + dropped: dropped.clone(), + }; + let controller_peer = fixture.controller_peer.clone(); + let controller_task = tokio::spawn(async move { + get_remote_runtime_status_on_peer(&controller_peer, "status-cancel-01").await + }); + let host_result = tokio::time::timeout(TEST_TIMEOUT, async { + let (host_result, ()) = tokio::join!( + serve_next_remote_runtime_status( + &fixture.host_endpoint, + &fixture.host_peer, + &provider, + ), + async { + started.notified().await; + controller_task.abort(); + let _ = controller_task.await; + dropped.notified().await; + }, + ); + host_result + }) + .await + .expect("host cancellation did not finish"); + let host_error = host_result.expect_err("cancelled request must not report success"); + assert_eq!(host_error.code, ErrorCode::TransportUnavailable); + + let calls = Arc::new(AtomicUsize::new(0)); + let succeeding = StaticProvider { + status: running_status(), + calls: calls.clone(), + }; + let (controller_result, host_result) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + get_remote_runtime_status(&fixture.manager, "status-after-cancel"), + serve_next_remote_runtime_status( + &fixture.host_endpoint, + &fixture.host_peer, + &succeeding, + ), + ) + }) + .await + .expect("follow-up status RPC timed out"); + assert_eq!( + controller_result.expect("follow-up result"), + running_status() + ); + host_result.expect("follow-up host result"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + fixture.close().await; + } + + #[tokio::test] + async fn provider_timeout_returns_a_typed_retryable_error() { + let fixture = fixture("status-timeout").await; + let provider = PendingProvider { + started: Arc::new(Notify::new()), + dropped: Arc::new(Notify::new()), + }; + let (controller_result, host_result) = tokio::time::timeout(TEST_TIMEOUT, async { + tokio::join!( + get_remote_runtime_status(&fixture.manager, "status-timeout-01"), + serve_next_remote_runtime_status_with_timeout( + &fixture.host_endpoint, + &fixture.host_peer, + &provider, + Duration::from_millis(25), + ), + ) + }) + .await + .expect("timeout RPC did not terminate"); + let controller_error = controller_result.expect_err("controller must receive timeout"); + assert_eq!(controller_error.code, ErrorCode::TransportUnavailable); + assert!(controller_error.retryable); + host_result.expect("host writes the typed timeout response"); + fixture.close().await; + } + + struct ActivationInFlightGuard(Arc); + + impl Drop for ActivationInFlightGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } + } + + struct StalledPendingAttach { + activation_in_flight: Arc, + activation_started: Arc, + cancel_calls: Arc, + cancel_started: Arc, + allow_cancel_ack: Arc, + } + + #[async_trait::async_trait] + impl AgentLiveRemotePendingAttach for StalledPendingAttach { + async fn activate( + &mut self, + ) -> Result + { + assert!(!self.activation_in_flight.swap(true, Ordering::AcqRel)); + let _in_flight = ActivationInFlightGuard(Arc::clone(&self.activation_in_flight)); + self.activation_started.notify_one(); + std::future::pending().await + } + + async fn cancel(self: Box) -> Result<(), AgentLiveRemoteAttachError> { + assert!( + !self.activation_in_flight.load(Ordering::Acquire), + "activation future must be dropped before native cancel" + ); + self.cancel_calls.fetch_add(1, Ordering::SeqCst); + self.cancel_started.notify_one(); + self.allow_cancel_ack.notified().await; + Ok(()) + } + } + + #[tokio::test] + async fn stalled_activation_is_dropped_then_cancelled_once_before_occupancy_release() { + let fixture = fixture("live-stalled-activation").await; + let authority = fixture + .host_endpoint + .verified_incoming_peer_authorization(&fixture.host_peer) + .expect("verified host authority"); + let rpc = RemoteAgentLiveRpcHost::unavailable(); + let service: Arc = + Arc::new(crate::agent_live_host::UnavailableAgentLiveRemoteAttachService); + let cancellation = Arc::new(RemoteAgentLiveCancellation::default()); + let (activate_send, _activate_receive) = oneshot::channel(); + rpc.install_pending( + "attach-stalled".to_string(), + authority.clone(), + Arc::clone(&service), + activate_send, + Arc::clone(&cancellation), + ) + .await + .expect("install pending lifecycle"); + rpc.arm_pending(&authority, "attach-stalled") + .await + .expect("arm pending lifecycle"); + drop( + rpc.take_pending_for_activation(&authority, "attach-stalled") + .await + .expect("move pending lifecycle to Activating"), + ); + rpc.name_activating_stream("attach-stalled", "live-stalled", &authority) + .await + .expect("name activating stream"); + + let activation_in_flight = Arc::new(AtomicBool::new(false)); + let activation_started = Arc::new(Notify::new()); + let cancel_calls = Arc::new(AtomicUsize::new(0)); + let cancel_started = Arc::new(Notify::new()); + let allow_cancel_ack = Arc::new(Notify::new()); + let mut pending: Box = Box::new(StalledPendingAttach { + activation_in_flight: Arc::clone(&activation_in_flight), + activation_started: Arc::clone(&activation_started), + cancel_calls: Arc::clone(&cancel_calls), + cancel_started: Arc::clone(&cancel_started), + allow_cancel_ack: Arc::clone(&allow_cancel_ack), + }); + let interrupt = Arc::new(Notify::new()); + let outcome = { + let interrupted = Arc::clone(&interrupt); + let activation = remote_live_server::activate_pending_until_interrupted( + pending.as_mut(), + async move { + interrupted.notified().await; + live_lifecycle_unavailable("test activation interruption") + }, + ); + tokio::pin!(activation); + tokio::select! { + _ = activation_started.notified() => {} + _ = &mut activation => panic!("stalled activation completed unexpectedly"), + } + assert!(activation_in_flight.load(Ordering::Acquire)); + interrupt.notify_one(); + activation.await + }; + assert!(matches!( + outcome, + remote_live_server::PendingActivationResult::Interrupted(_) + )); + assert!(!activation_in_flight.load(Ordering::Acquire)); + + let finish_rpc = rpc.clone(); + let finish_authority = authority.clone(); + let finish_cancellation = Arc::clone(&cancellation); + let finish = tokio::spawn(async move { + remote_live_server::finish_activating_pending_lifecycle( + &finish_rpc, + "attach-stalled", + &finish_authority, + &finish_cancellation, + pending, + live_lifecycle_unavailable("test activation interruption"), + ) + .await + }); + cancel_started.notified().await; + assert_eq!(cancel_calls.load(Ordering::SeqCst), 1); + assert!( + rpc.inner + .state + .lock() + .expect("live registry") + .activating + .contains_key("attach-stalled"), + "occupancy must remain until native cancel acknowledges" + ); + assert_eq!( + rpc.reserve_activating( + "live-overlap".to_string(), + authority.clone(), + Arc::clone(&service), + Arc::new(RemoteAgentLiveCancellation::default()), + ) + .await + .expect_err("stable occupancy must remain fail-closed") + .code, + ErrorCode::TransportUnavailable + ); + allow_cancel_ack.notify_one(); + assert_eq!( + finish + .await + .expect("cleanup task join") + .expect_err("terminal owner error remains visible") + .code, + ErrorCode::AgentLiveUnavailable + ); + assert_eq!(cancel_calls.load(Ordering::SeqCst), 1); + assert!( + !rpc.inner + .state + .lock() + .expect("live registry") + .activating + .contains_key("attach-stalled"), + "occupancy is released only after native cancel acknowledgement" + ); + rpc.reserve_activating( + "live-after-ack".to_string(), + authority.clone(), + service, + Arc::new(RemoteAgentLiveCancellation::default()), + ) + .await + .expect("capacity is reusable after acknowledged cleanup"); + rpc.remove_activating("live-after-ack", &authority).await; + fixture.close().await; + } + + #[tokio::test] + async fn concurrent_pending_cancels_resolve_the_active_alias_and_share_cleanup_error() { + let fixture = fixture("live-active-alias").await; + let authority = fixture + .host_endpoint + .verified_incoming_peer_authorization(&fixture.host_peer) + .expect("verified host authority"); + let rpc = RemoteAgentLiveRpcHost::unavailable(); + let service: Arc = + Arc::new(crate::agent_live_host::UnavailableAgentLiveRemoteAttachService); + let cancellation = Arc::new(RemoteAgentLiveCancellation::default()); + let (activate_send, _activate_receive) = oneshot::channel(); + rpc.install_pending( + "attach-alias".to_string(), + authority.clone(), + Arc::clone(&service), + activate_send, + Arc::clone(&cancellation), + ) + .await + .expect("install pending lifecycle"); + rpc.arm_pending(&authority, "attach-alias") + .await + .expect("arm pending lifecycle"); + drop( + rpc.take_pending_for_activation(&authority, "attach-alias") + .await + .expect("move pending lifecycle to Activating"), + ); + rpc.name_activating_stream("attach-alias", "live-active", &authority) + .await + .expect("name activating stream"); + rpc.promote_activating("attach-alias", "live-active", &authority) + .await + .expect("promote lifecycle"); + + let first = rpc.cancel_lifecycle( + &authority, + AgentLiveCancelKind::PendingAttach, + "attach-alias", + ); + let second = rpc.cancel_lifecycle( + &authority, + AgentLiveCancelKind::PendingAttach, + "attach-alias", + ); + let cleanup = async { + cancellation.wait_requested().await; + tokio::task::yield_now().await; + cancellation.complete(Err(ProtocolError::new( + ErrorCode::Internal, + "test native cleanup failed", + false, + ))); + }; + let (first, second, ()) = tokio::join!(first, second, cleanup); + assert_eq!( + first.expect_err("first cancel shares cleanup error").code, + ErrorCode::Internal + ); + assert_eq!( + second + .expect_err("concurrent cancel shares cleanup error") + .code, + ErrorCode::Internal + ); + { + let mut state = rpc.inner.state.lock().expect("live registry"); + prune_closed_lifecycles(&mut state); + let active = state + .active + .get("live-active") + .expect("failed cleanup retains active occupancy"); + assert_eq!(active.activation_id, "attach-alias"); + assert!(stable_occupancy_in_use(&state, &authority)); + } + assert_eq!( + rpc.reserve_activating( + "live-overlap".to_string(), + authority, + service, + Arc::new(RemoteAgentLiveCancellation::default()), + ) + .await + .expect_err("cleanup failure must retain stable occupancy") + .code, + ErrorCode::TransportUnavailable + ); + fixture.close().await; + } + + #[tokio::test] + async fn retained_peer_authorization_refresh_awaits_live_cleanup_before_reoccupancy() { + let fixture = fixture_with_target( + "live-retained-auth-refresh", + "11111111-1111-4111-8111-111111111111", + ) + .await; + let old_authority = fixture + .host_endpoint + .verified_incoming_peer_authorization(&fixture.host_peer) + .expect("verified old host authority"); + let journal_root = tempfile::tempdir().expect("temporary live host root"); + let live_host = crate::agent_live_host::AgentLiveHost::open(journal_root.path()) + .expect("open test live host"); + let initial_binding = + crate::agent_live_binding::VerifiedAgentTargetBinding::from_verified_remote_adapter( + "account-scope-a".to_string(), + 7, + old_authority.clone(), + ) + .expect("construct initial verified binding"); + assert!(matches!( + live_host + .bind_verified(initial_binding) + .await + .expect("bind initial remote authority"), + crate::agent_live_host::AgentLiveHostBindOutcome::Bound(_) + )); + + let rpc = RemoteAgentLiveRpcHost::unavailable(); + let service: Arc = + Arc::new(crate::agent_live_host::UnavailableAgentLiveRemoteAttachService); + let cancellation = Arc::new(RemoteAgentLiveCancellation::default()); + rpc.reserve_activating( + "live-old-authority".to_string(), + old_authority.clone(), + Arc::clone(&service), + Arc::clone(&cancellation), + ) + .await + .expect("reserve old-authority lifecycle"); + rpc.promote_activating("live-old-authority", "live-old-authority", &old_authority) + .await + .expect("promote idle old-authority lifecycle"); + + let cleanup_started = Arc::new(Notify::new()); + let allow_cleanup_ack = Arc::new(Notify::new()); + let unsubscribe_calls = Arc::new(AtomicUsize::new(0)); + let cleanup_owner = { + let rpc = rpc.clone(); + let authority = old_authority.clone(); + let cancellation = Arc::clone(&cancellation); + let cleanup_started = Arc::clone(&cleanup_started); + let allow_cleanup_ack = Arc::clone(&allow_cleanup_ack); + let unsubscribe_calls = Arc::clone(&unsubscribe_calls); + tokio::spawn(async move { + cancellation.wait_requested().await; + cleanup_started.notify_one(); + allow_cleanup_ack.notified().await; + unsubscribe_calls.fetch_add(1, Ordering::SeqCst); + cancellation.complete(Ok(())); + rpc.remove_active("live-old-authority", &authority).await; + }) + }; + + let controller_endpoint = fixture.host_peer.remote_id(); + let transition_receipt = fixture + .host_endpoint + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 17, + snapshot_revision: 2, + incoming_controllers: HashMap::from([( + controller_endpoint, + PairingIncarnation::new(1).expect("pairing incarnation"), + )]), + outgoing_execution_targets: HashMap::new(), + }) + .expect("install retained-peer authorization revision"); + fixture + .host_endpoint + .validate_current_incoming_peer(&fixture.host_peer) + .expect("retained peer connection stays current"); + assert!(old_authority.revalidate_current().is_err()); + + let transition = { + let live_host = live_host.clone(); + let rpc = Arc::new(rpc.clone()); + tokio::spawn(async move { + live_host + .apply_authorization_transition(transition_receipt, rpc) + .await + }) + }; + tokio::time::timeout(TEST_TIMEOUT, cleanup_started.notified()) + .await + .expect("authorization refresh wakes idle live owner"); + assert!( + !transition.is_finished(), + "authorization transition must await native unsubscribe acknowledgement" + ); + assert!( + rpc.inner + .state + .lock() + .expect("live registry") + .active + .contains_key("live-old-authority"), + "old occupancy remains fail-closed before cleanup acknowledgement" + ); + allow_cleanup_ack.notify_one(); + transition + .await + .expect("authorization transition task join") + .expect("authorization transition cleanup succeeds"); + cleanup_owner.await.expect("idle cleanup owner task join"); + assert_eq!(unsubscribe_calls.load(Ordering::SeqCst), 1); + + let refreshed_authority = fixture + .host_endpoint + .verified_incoming_peer_authorization(&fixture.host_peer) + .expect("verified refreshed host authority"); + assert!(old_authority.same_admission_instance(&refreshed_authority)); + assert_ne!( + old_authority.authorization(), + refreshed_authority.authorization() + ); + let refreshed_binding = + crate::agent_live_binding::VerifiedAgentTargetBinding::from_verified_remote_adapter( + "account-scope-a".to_string(), + 7, + refreshed_authority.clone(), + ) + .expect("construct refreshed verified binding"); + assert!(matches!( + live_host + .bind_verified(refreshed_binding) + .await + .expect("bind refreshed remote authority"), + crate::agent_live_host::AgentLiveHostBindOutcome::Bound(_) + )); + rpc.reserve_activating( + "live-refreshed-authority".to_string(), + refreshed_authority.clone(), + service, + Arc::new(RemoteAgentLiveCancellation::default()), + ) + .await + .expect("refreshed authority can occupy only after old cleanup ACK"); + rpc.remove_activating("live-refreshed-authority", &refreshed_authority) + .await; + fixture.close().await; + } + + #[test] + fn aggregate_c0_overlay_larger_than_one_frame_is_split_into_encodable_item_frames() { + let live_items = (0..6) + .map(|index| history_item(format!("live-item-{index}"), "x".repeat(190 * 1_024))) + .collect::>(); + assert!( + live_items + .iter() + .filter_map(|item| item.text.as_ref()) + .map(String::len) + .sum::() + > crate::remote_protocol::MAX_FRAME_BYTES as usize + ); + let mut frames = Vec::new(); + remote_live_server::append_live_session_snapshot_frames( + &mut frames, + vec![RemoteAgentLiveSessionSnapshot { + session_id: "session-large-c0".to_string(), + live_items, + }], + ) + .expect("split a valid aggregate C0 overlay"); + assert_eq!(frames.len(), 7); + assert!(matches!( + frames.first(), + Some(AgentLiveStreamFrame::LiveSessionStart { + index: 0, + item_count: 6, + .. + }) + )); + for frame in frames { + frame.validate().expect("split frame is valid"); + let response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "large-c0-response".to_string(), + execution_target_id: "host-01".to_string(), + connection_stamp: ConnectionStamp::new(41, 1).expect("valid stamp"), + result: Ok(frame), + }; + validate_frame_encodable(&response).expect("each C0 item frame fits transport cap"); + } + } + + #[test] + fn c0_projection_budget_rejects_oversized_account_before_retaining_the_next_item() { + let mut retained_bytes = LIVE_PROJECTION_OUTER_OVERHEAD_BYTES; + accumulate_remote_live_projection_bytes( + &mut retained_bytes, + remote_live_projection_session_wire_bytes("session-budget").expect("session charge"), + ) + .expect("session header fits account budget"); + let item = history_item( + "budget-item", + "x".repeat(crate::remote_protocol::MAX_LIVE_ITEM_PRESENTATION_BYTES), + ); + let item_bytes = + remote_live_projection_item_wire_bytes(&item).expect("bounded item charge"); + let fitting_items = (MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT - retained_bytes) / item_bytes; + assert!(fitting_items < crate::remote_protocol::MAX_LIVE_ITEMS_PER_ACCOUNT); + for _ in 0..fitting_items { + accumulate_remote_live_projection_bytes(&mut retained_bytes, item_bytes) + .expect("item within native-parity account budget"); + } + let before_rejection = retained_bytes; + let error = accumulate_remote_live_projection_bytes(&mut retained_bytes, item_bytes) + .expect_err("next individually valid item exceeds aggregate account budget"); + assert_eq!(error.code, ErrorCode::InvalidFrame); + assert_eq!(retained_bytes, before_rejection); + } + + #[test] + fn status_request_schema_cannot_name_an_arbitrary_tauri_command() { + let request = serde_json::json!({ + "operation": "agent_clear_user_data", + "userId": "user-a" + }); + assert!(serde_json::from_value::(request).is_err()); + } +} diff --git a/frontend/src-tauri/src/remote_protocol.rs b/frontend/src-tauri/src/remote_protocol.rs new file mode 100644 index 000000000..7f02ae35a --- /dev/null +++ b/frontend/src-tauri/src/remote_protocol.rs @@ -0,0 +1,4253 @@ +//! Maple-owned wire types for remote Agent Mode. +//! +//! These types intentionally contain no Goose or Tauri values. They form the +//! stable seam shared by every Maple Tauri platform and the desktop host. +#![allow( + dead_code, + reason = "bounded foundation is wired in later vertical slices" +)] + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + marker::PhantomData, +}; + +use serde::{ + de::{self, IgnoredAny, SeqAccess, Visitor}, + Deserialize, Deserializer, Serialize, +}; + +pub const PROTOCOL_VERSION: u16 = 1; +pub const ALPN: &[u8] = b"cloud.opensecret.maple/agent/1"; +pub const MAX_FRAME_BYTES: u32 = 1_048_576; +/// Presentation cap shared by embedded and remote history adapters. The +/// reserved envelope margin ensures any record accepted locally also fits in +/// one correlated remote response frame. +pub const MAX_HISTORY_RECORD_PRESENTATION_BYTES: usize = MAX_FRAME_BYTES as usize - 8_192; +/// A live item is framed independently inside a correlated response envelope. +/// This mirrors the native 192 KiB presentation bound while retaining ample +/// headroom for IDs, labels, cursor metadata, and encoding overhead. +pub const MAX_LIVE_ITEM_PRESENTATION_BYTES: usize = 192 * 1_024; +pub const DEFAULT_PAGE_SIZE: u16 = 25; +pub const MAX_PAGE_SIZE: u16 = 50; +pub const MAX_ID_BYTES: usize = 128; +pub const MAX_CURSOR_BYTES: usize = 512; +pub const MAX_ERROR_MESSAGE_BYTES: usize = 512; +pub const MAX_PROJECT_ROOT_BYTES: usize = 4_096; +pub const MAX_MODEL_ID_BYTES: usize = 256; +pub const MAX_AGENT_MODE_BYTES: usize = 64; +pub const MAX_SESSION_TITLE_BYTES: usize = 1_024; +pub const MAX_ACTIVE_RUNS: usize = 64; +pub const MAX_HISTORY_ITEMS_PER_RECORD: usize = 200; +pub const MAX_LIVE_SESSIONS_PER_ACCOUNT: usize = 64; +pub const MAX_LIVE_ITEMS_PER_SESSION: usize = 200; +pub const MAX_LIVE_ITEMS_PER_ACCOUNT: usize = 512; +/// Matches the native coordinator's account projection checkpoint bound. C0 +/// consumers charge this conservative bound incrementally so a malicious but +/// authenticated host cannot turn individually valid item frames into an +/// unexpectedly large retained mobile snapshot. +pub const MAX_LIVE_PROJECTION_BYTES_PER_ACCOUNT: usize = 8 * 1_024 * 1_024; +pub(crate) const LIVE_PROJECTION_OUTER_OVERHEAD_BYTES: usize = 4 * 1_024; +const LIVE_PROJECTION_SESSION_OVERHEAD_BYTES: usize = 256; +const LIVE_PROJECTION_ITEM_OVERHEAD_BYTES: usize = 256; +pub const MAX_HISTORY_ITEM_LABEL_BYTES: usize = 1_024; +pub(crate) const SAFE_REMOTE_SETUP_WARNING: &str = + "Some Agent integrations could not connect. Review Agent settings on the host."; +pub(crate) const SAFE_REMOTE_AGENT_ERROR: &str = + "The Agent task failed. Open the host for additional diagnostic details."; +pub(crate) const SAFE_REMOTE_TOOL_TITLE: &str = "Tool activity"; +pub(crate) const SAFE_REMOTE_TOOL_FAILED: &str = + "The tool failed. Open the host for additional diagnostic details."; +pub(crate) const SAFE_REMOTE_TOOL_CANCELLED: &str = "The tool was cancelled."; +pub(crate) const SAFE_REMOTE_PERMISSION_TITLE: &str = "Tool permission"; +const MAX_JAVASCRIPT_SAFE_INTEGER: i64 = 9_007_199_254_740_991; +const MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER: u64 = 9_007_199_254_740_991; + +/// Identifies one live host epoch and one connection attempt within it. +/// `host_epoch` increases whenever the host process starts a new resumability +/// epoch; `generation` increases for every replacement connection in that +/// epoch. Zero is reserved as an invalid/uninitialized value for both fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectionStamp { + host_epoch: u64, + generation: u64, +} + +impl ConnectionStamp { + pub fn new(host_epoch: u64, generation: u64) -> Result { + let stamp = Self { + host_epoch, + generation, + }; + stamp.validate()?; + Ok(stamp) + } + + pub const fn host_epoch(self) -> u64 { + self.host_epoch + } + + pub const fn generation(self) -> u64 { + self.generation + } + + pub fn validate(self) -> Result<(), ProtocolError> { + if self.host_epoch == 0 || self.generation == 0 { + Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "connection stamp epoch and generation must be positive", + true, + )) + } else { + Ok(()) + } + } +} + +/// Every request/response body must opt into validation. This prevents a new +/// DTO from being placed on the wire without an explicit boundedness review. +pub trait WireBody { + fn stream_kind(&self) -> StreamKind; + + fn validate_body(&self) -> Result<(), ProtocolError>; + + /// Validate only fields whose meaning depends on the envelope's current + /// connection stamp. Envelopes always call [`WireBody::validate_body`] + /// first, so an override cannot accidentally bypass ordinary bounds. + fn validate_body_for_stamp( + &self, + _connection_stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + Ok(()) + } +} + +/// Explicitly admits a DTO as a request and fixes the one direction in which +/// that operation may be initiated. There is deliberately no blanket impl: +/// adding a request operation requires a direction review. +pub trait RequestBody: WireBody { + fn allowed_direction(&self) -> PeerDirection; +} + +/// Explicitly pairs a successful response DTO with the request DTO whose +/// context it must validate against. There is deliberately no blanket impl: +/// adding a new request/response operation requires a pairing review. +pub trait ResponseBody: WireBody { + fn validate_response_to(&self, request: &TRequest) -> Result<(), ProtocolError>; +} + +/// Explicitly admits a bounded DTO as an item in a paged response. There is no +/// blanket implementation: each concrete page item requires an operation and +/// pagination review before `Page` can satisfy a response pairing. +pub trait PageItem: WireBody {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PeerDirection { + ControllerToHost, + HostToController, +} + +impl PeerDirection { + pub fn opposite(self) -> Self { + match self { + Self::ControllerToHost => Self::HostToController, + Self::HostToController => Self::ControllerToHost, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamKind { + Control, + Events, + Bulk, +} + +impl StreamKind { + /// Iroh/noq sends larger values first. Keep interactive control work ahead + /// of events, and both ahead of paged history or attachment transfer. + pub const fn priority(self) -> i32 { + match self { + Self::Control => 100, + Self::Events => 50, + Self::Bulk => 0, + } + } +} + +/// The operation marker is deliberately closed and unrelated to Tauri command +/// names. Adding another remote operation requires a new reviewed wire body and +/// host adapter; callers cannot submit an arbitrary native command string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeStatusOperation { + GetRuntimeStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GetRuntimeStatusRequest { + pub operation: RuntimeStatusOperation, +} + +impl GetRuntimeStatusRequest { + pub const fn new() -> Self { + Self { + operation: RuntimeStatusOperation::GetRuntimeStatus, + } + } +} + +impl Default for GetRuntimeStatusRequest { + fn default() -> Self { + Self::new() + } +} + +impl WireBody for GetRuntimeStatusRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + match self.operation { + RuntimeStatusOperation::GetRuntimeStatus => Ok(()), + } + } +} + +impl RequestBody for GetRuntimeStatusRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +/// Bounded, transport-neutral projection of Maple's local runtime status. +/// +/// The desktop adapter converts `AgentRuntimeStatus` into this type. Keeping +/// this DTO in the shared protocol module lets mobile controllers compile the +/// wire contract without compiling Goose or Maple's desktop Agent runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteAgentRuntimeStatus { + pub running: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default)] + pub active_runs: BTreeMap, +} + +impl RemoteAgentRuntimeStatus { + pub fn validate(&self) -> Result<(), ProtocolError> { + let runtime_fields = [ + self.project_root.is_some(), + self.model.is_some(), + self.mode.is_some(), + ]; + let fields_match_state = if self.running { + runtime_fields.into_iter().all(|present| present) + } else { + runtime_fields.into_iter().all(|present| !present) + }; + if !fields_match_state { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "runtime status fields do not match the running state", + false, + )); + } + if !self.running && !self.active_runs.is_empty() { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "a stopped runtime cannot contain active runs", + false, + )); + } + validate_optional_status_field( + "project root", + self.project_root.as_deref(), + MAX_PROJECT_ROOT_BYTES, + )?; + validate_optional_status_field("model", self.model.as_deref(), MAX_MODEL_ID_BYTES)?; + validate_optional_status_field("mode", self.mode.as_deref(), MAX_AGENT_MODE_BYTES)?; + if self.active_runs.len() > MAX_ACTIVE_RUNS { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "runtime status contains too many active runs", + false, + )); + } + for (session_id, run_id) in &self.active_runs { + validate_id("active run session id", session_id)?; + validate_id("active run id", run_id)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GetRuntimeStatusResponse { + pub status: RemoteAgentRuntimeStatus, +} + +impl GetRuntimeStatusResponse { + pub fn new(status: RemoteAgentRuntimeStatus) -> Result { + status.validate()?; + Ok(Self { status }) + } +} + +impl WireBody for GetRuntimeStatusResponse { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.status.validate() + } +} + +impl ResponseBody for GetRuntimeStatusResponse { + fn validate_response_to(&self, request: &GetRuntimeStatusRequest) -> Result<(), ProtocolError> { + request.validate_body()?; + self.validate_body() + } +} + +impl ResponseBody for GetRuntimeStatusResponse { + fn validate_response_to( + &self, + request: &RemoteAgentControlRequest, + ) -> Result<(), ProtocolError> { + match request { + RemoteAgentControlRequest::GetRuntimeStatus => self.validate_body(), + _ => Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "runtime status response was sent for another Control operation", + false, + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentHistoryOperation { + ListSessionRecords, +} + +/// Concrete count-based request for Goose's native persisted message records. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListAgentHistoryRecordsRequest { + pub operation: AgentHistoryOperation, + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default = "default_page_size")] + pub limit: u16, +} + +impl ListAgentHistoryRecordsRequest { + pub fn new( + session_id: impl Into, + cursor: Option, + limit: u16, + ) -> Result { + let request = Self { + operation: AgentHistoryOperation::ListSessionRecords, + session_id: session_id.into(), + cursor, + limit, + }; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), ProtocolError> { + match self.operation { + AgentHistoryOperation::ListSessionRecords => {} + } + validate_id("Agent session id", &self.session_id)?; + validate_page_limit_and_cursor(self.limit, self.cursor.as_deref()) + } +} + +impl WireBody for ListAgentHistoryRecordsRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl RequestBody for ListAgentHistoryRecordsRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +/// Mobile-compilable projection of Maple's existing timeline item contract. +/// Goose/provider values never appear directly on the wire. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteAgentTimelineItem { + pub id: String, + pub item_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + pub created_ms: u64, + pub merge: String, +} + +impl RemoteAgentTimelineItem { + pub fn validate(&self) -> Result<(), ProtocolError> { + validate_id("timeline item id", &self.id)?; + if self.created_ms > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER { + return Err(invalid_history_record( + "timeline item timestamp is outside the safe wire range", + )); + } + if !matches!( + self.item_type.as_str(), + "message" | "thinking" | "tool" | "permission" | "system" | "error" + ) { + return Err(invalid_history_record("invalid timeline item type")); + } + if self + .role + .as_deref() + .is_some_and(|role| !matches!(role, "user" | "assistant" | "thought" | "system")) + { + return Err(invalid_history_record("invalid timeline item role")); + } + validate_optional_history_label( + "timeline item title", + self.title.as_deref(), + MAX_HISTORY_ITEM_LABEL_BYTES, + )?; + validate_optional_content_text("timeline item text", self.text.as_deref())?; + validate_optional_history_label("timeline item status", self.status.as_deref(), 64)?; + if !matches!(self.merge.as_str(), "append" | "replace") { + return Err(invalid_history_record("invalid timeline merge mode")); + } + match self.item_type.as_str() { + "tool" => { + let expected_text = match self.status.as_deref() { + None | Some("pending" | "running" | "completed") => None, + Some("failed" | "error") => Some(SAFE_REMOTE_TOOL_FAILED), + Some("cancelled") => Some(SAFE_REMOTE_TOOL_CANCELLED), + Some(_) => return Err(invalid_history_record("invalid safe tool status")), + }; + if self.role.as_deref() != Some("assistant") + || self.title.as_deref() != Some(SAFE_REMOTE_TOOL_TITLE) + || self.text.as_deref() != expected_text + { + return Err(invalid_history_record("unsafe tool presentation")); + } + } + "permission" => { + if self.role.as_deref() != Some("system") + || self.title.as_deref() != Some(SAFE_REMOTE_PERMISSION_TITLE) + || self.text.is_some() + || !matches!( + self.status.as_deref(), + Some("allow_once" | "deny_once" | "completed" | "cancelled") + ) + { + return Err(invalid_history_record("unsafe permission presentation")); + } + } + "error" => { + if self.role.as_deref() != Some("system") + || self.title.as_deref() != Some("Agent error") + || self.text.as_deref() != Some(SAFE_REMOTE_AGENT_ERROR) + || self.status.as_deref() != Some("failed") + { + return Err(invalid_history_record("unsafe error presentation")); + } + } + _ => {} + } + Ok(()) + } + + fn validate_live_presentation(&self) -> Result<(), ProtocolError> { + self.validate()?; + validate_optional_content_text_bounded( + "live timeline item text", + self.text.as_deref(), + MAX_LIVE_ITEM_PRESENTATION_BYTES, + ) + } +} + +/// Conservative JSON checkpoint charge shared with the native projection +/// owner. Fixed enum/numeric/object syntax is covered by the per-item +/// overhead; attacker-controlled strings are charged at their escaped size. +pub(crate) fn remote_live_projection_item_wire_bytes( + item: &RemoteAgentTimelineItem, +) -> Result { + [ + Some(item.id.as_str()), + item.title.as_deref(), + item.text.as_deref(), + item.status.as_deref(), + ] + .into_iter() + .flatten() + .try_fold(LIVE_PROJECTION_ITEM_OVERHEAD_BYTES, |bytes, value| { + bytes + .checked_add(json_string_wire_bytes(value)?) + .ok_or_else(|| invalid_live_frame("live projection byte count overflow")) + }) +} + +pub(crate) fn remote_live_projection_session_wire_bytes( + session_id: &str, +) -> Result { + LIVE_PROJECTION_SESSION_OVERHEAD_BYTES + .checked_add(json_string_wire_bytes(session_id)?) + .ok_or_else(|| invalid_live_frame("live projection byte count overflow")) +} + +/// Upper-bound a JSON string without allocating. ASCII controls may use the +/// six-byte `\u00XX` form; quotes and backslashes use two bytes; every other +/// scalar uses its UTF-8 width. The surrounding quote bytes are included. +fn json_string_wire_bytes(value: &str) -> Result { + value.chars().try_fold(2usize, |bytes, character| { + let encoded = if character.is_ascii_control() { + 6 + } else if matches!(character, '"' | '\\') { + 2 + } else { + character.len_utf8() + }; + bytes + .checked_add(encoded) + .ok_or_else(|| invalid_live_frame("live projection byte count overflow")) + }) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteAgentHistoryRecord { + pub record_id: String, + pub role: String, + pub created_ms: u64, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteLiveEventCursor { + pub journal_id: String, + pub sequence: u64, +} + +impl RemoteLiveEventCursor { + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.journal_id.len() != 32 + || !self + .journal_id + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "live event cursor journal ID is invalid", + false, + )); + } + if self.sequence > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "live event cursor sequence is outside the safe wire range", + false, + )); + } + Ok(()) + } +} + +impl RemoteAgentHistoryRecord { + pub fn validate(&self) -> Result<(), ProtocolError> { + if !is_safe_cursor(&self.record_id) { + return Err(invalid_history_record("invalid Agent history record id")); + } + // Goose owns its persisted Message role vocabulary. Maple renders the + // safe projected items and treats this source-row label as opaque + // metadata, so a future native role must not make local and remote + // paging diverge. Keep the label bounded printable ASCII rather than + // hard-coding today's user/assistant subset. + if self.role.is_empty() + || self.role.len() > MAX_ID_BYTES + || !self + .role + .bytes() + .all(|byte| byte.is_ascii_graphic() || byte == b' ') + { + return Err(invalid_history_record("invalid Agent history record role")); + } + if self.created_ms > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER { + return Err(invalid_history_record( + "Agent history record timestamp is outside the safe wire range", + )); + } + if self.items.len() > MAX_HISTORY_ITEMS_PER_RECORD { + return Err(invalid_history_record( + "Agent history record contains too many timeline items", + )); + } + for item in &self.items { + item.validate()?; + } + if serialized_cbor_len(self)? > MAX_HISTORY_RECORD_PRESENTATION_BYTES { + return Err(ProtocolError::new( + ErrorCode::HistoryRecordTooLarge, + "one Agent history record exceeds Maple's presentation limit", + false, + )); + } + Ok(()) + } +} + +/// Multi-frame persisted-only Bulk response. A page is Start, exactly +/// `record_count` Record frames, then End. Synchronized live state has no +/// representation in this operation and can be disclosed only through the +/// separately authorized Events-lane attach protocol below. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "frame", rename_all = "snake_case", deny_unknown_fields)] +pub enum AgentHistoryPageFrame { + Start { + record_count: u16, + }, + Record { + index: u16, + record: RemoteAgentHistoryRecord, + }, + End { + #[serde(default, skip_serializing_if = "Option::is_none")] + next_cursor: Option, + history_revision: String, + }, +} + +impl AgentHistoryPageFrame { + pub fn validate(&self) -> Result<(), ProtocolError> { + match self { + Self::Start { record_count } => { + if *record_count > MAX_PAGE_SIZE { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history page contains too many records", + false, + )); + } + } + Self::Record { index, record } => { + if *index >= MAX_PAGE_SIZE { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history record index is out of range", + false, + )); + } + record.validate()?; + } + Self::End { + next_cursor, + history_revision, + } => { + validate_optional_cursor(next_cursor.as_deref())?; + if !is_safe_cursor(history_revision) { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history revision is empty, unsafe, or too large", + false, + )); + } + } + } + Ok(()) + } +} + +impl WireBody for AgentHistoryPageFrame { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl ResponseBody for AgentHistoryPageFrame { + fn validate_response_to( + &self, + request: &ListAgentHistoryRecordsRequest, + ) -> Result<(), ProtocolError> { + request.validate()?; + self.validate()?; + match self { + Self::Start { record_count, .. } if *record_count > request.limit => { + Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history page contains more records than requested", + false, + )) + } + Self::Record { index, .. } if *index >= request.limit => Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history record index exceeds the requested limit", + false, + )), + Self::End { next_cursor, .. } + if next_cursor.is_some() && next_cursor.as_ref() == request.cursor.as_ref() => + { + Err(ProtocolError::new( + ErrorCode::InvalidPage, + "history continuation cursor did not advance", + false, + )) + } + _ => Ok(()), + } + } +} + +/// Fully assembled controller result. It is intentionally not a WireBody: its +/// records travel as individually bounded frames above. +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteAgentHistoryPage { + pub records: Vec, + pub next_cursor: Option, + pub history_revision: String, +} + +/// Remote synchronized-history operations are deliberately separate from +/// persisted-only paging. Begin and resume own long-lived Events streams; +/// activation and cancellation are correlated Control operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentLiveStreamOperation { + BeginAttach, + Resume, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BeginAgentLiveAttachRequest { + pub operation: AgentLiveStreamOperation, + pub session_id: String, + #[serde(default = "default_page_size")] + pub limit: u16, +} + +impl BeginAgentLiveAttachRequest { + pub fn new(session_id: impl Into, limit: u16) -> Result { + let request = Self { + operation: AgentLiveStreamOperation::BeginAttach, + session_id: session_id.into(), + limit, + }; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.operation != AgentLiveStreamOperation::BeginAttach { + return Err(invalid_live_frame("invalid live begin operation")); + } + validate_id("Agent session id", &self.session_id)?; + validate_page_limit_and_cursor(self.limit, None) + } +} + +impl WireBody for BeginAgentLiveAttachRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Events + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl RequestBody for BeginAgentLiveAttachRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResumeAgentLiveEventsRequest { + pub operation: AgentLiveStreamOperation, + pub cursor: RemoteLiveEventCursor, + /// Host restart epoch which minted `cursor`. Reconnect generations may + /// change within this epoch, but a cursor must never cross a host restart. + pub origin_host_epoch: u64, +} + +impl ResumeAgentLiveEventsRequest { + pub fn new( + cursor: RemoteLiveEventCursor, + origin_host_epoch: u64, + ) -> Result { + let request = Self { + operation: AgentLiveStreamOperation::Resume, + cursor, + origin_host_epoch, + }; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.operation != AgentLiveStreamOperation::Resume { + return Err(invalid_live_frame("invalid live resume operation")); + } + self.cursor.validate()?; + if self.origin_host_epoch == 0 { + return Err(invalid_live_frame( + "live resume origin host epoch must be positive", + )); + } + Ok(()) + } + + /// A live cursor is scoped to the host epoch which minted it. Reconnects + /// within that epoch may advance the connection generation, but a host + /// restart must force an authoritative head reload before any replay is + /// attempted. + pub fn validate_for_connection_stamp( + &self, + connection_stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + self.validate()?; + connection_stamp.validate()?; + if self.origin_host_epoch != connection_stamp.host_epoch() { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "live resume cursor belongs to another host epoch", + true, + )); + } + Ok(()) + } +} + +impl WireBody for ResumeAgentLiveEventsRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Events + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } + + fn validate_body_for_stamp( + &self, + connection_stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + self.validate_for_connection_stamp(connection_stamp) + } +} + +impl RequestBody for ResumeAgentLiveEventsRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +/// The Events lane has exactly one decoder. A central dispatcher accepts the +/// stream once and then routes by this closed operation union, preventing two +/// independent handlers from consuming each other's queued requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum RemoteAgentLiveEventsRequest { + BeginAttach { + session_id: String, + #[serde(default = "default_page_size")] + limit: u16, + }, + Resume { + cursor: RemoteLiveEventCursor, + origin_host_epoch: u64, + }, +} + +impl From for RemoteAgentLiveEventsRequest { + fn from(request: BeginAgentLiveAttachRequest) -> Self { + Self::BeginAttach { + session_id: request.session_id, + limit: request.limit, + } + } +} + +impl From for RemoteAgentLiveEventsRequest { + fn from(request: ResumeAgentLiveEventsRequest) -> Self { + Self::Resume { + cursor: request.cursor, + origin_host_epoch: request.origin_host_epoch, + } + } +} + +impl WireBody for RemoteAgentLiveEventsRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Events + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + match self { + Self::BeginAttach { session_id, limit } => { + BeginAgentLiveAttachRequest::new(session_id.clone(), *limit).map(|_| ()) + } + Self::Resume { + cursor, + origin_host_epoch, + } => ResumeAgentLiveEventsRequest::new(cursor.clone(), *origin_host_epoch).map(|_| ()), + } + } + + fn validate_body_for_stamp( + &self, + connection_stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + match self { + Self::BeginAttach { .. } => Ok(()), + Self::Resume { + cursor, + origin_host_epoch, + } => ResumeAgentLiveEventsRequest::new(cursor.clone(), *origin_host_epoch)? + .validate_for_connection_stamp(connection_stamp), + } + } +} + +impl RequestBody for RemoteAgentLiveEventsRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentLiveControlOperation { + ActivateAttach, + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActivateAgentLiveAttachRequest { + pub operation: AgentLiveControlOperation, + pub attach_id: String, +} + +impl ActivateAgentLiveAttachRequest { + pub fn new(attach_id: impl Into) -> Result { + let request = Self { + operation: AgentLiveControlOperation::ActivateAttach, + attach_id: attach_id.into(), + }; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.operation != AgentLiveControlOperation::ActivateAttach { + return Err(invalid_live_frame("invalid live activation operation")); + } + validate_id("Agent live attachment id", &self.attach_id) + } +} + +impl WireBody for ActivateAgentLiveAttachRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl RequestBody for ActivateAgentLiveAttachRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentLiveCancelKind { + PendingAttach, + ActiveStream, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CancelAgentLiveRequest { + pub operation: AgentLiveControlOperation, + pub kind: AgentLiveCancelKind, + pub live_id: String, +} + +impl CancelAgentLiveRequest { + pub fn new( + kind: AgentLiveCancelKind, + live_id: impl Into, + ) -> Result { + let request = Self { + operation: AgentLiveControlOperation::Cancel, + kind, + live_id: live_id.into(), + }; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.operation != AgentLiveControlOperation::Cancel { + return Err(invalid_live_frame("invalid live cancellation operation")); + } + validate_id("Agent live lifecycle id", &self.live_id) + } +} + +impl WireBody for CancelAgentLiveRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl RequestBody for CancelAgentLiveRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +/// The live Control subset has one explicitly tagged decoder. The peer-wide +/// Control union below additionally includes ordinary runtime status. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum RemoteAgentLiveControlRequest { + ActivateAttach { + attach_id: String, + }, + Cancel { + kind: AgentLiveCancelKind, + live_id: String, + }, +} + +impl From for RemoteAgentLiveControlRequest { + fn from(request: ActivateAgentLiveAttachRequest) -> Self { + Self::ActivateAttach { + attach_id: request.attach_id, + } + } +} + +impl From for RemoteAgentLiveControlRequest { + fn from(request: CancelAgentLiveRequest) -> Self { + Self::Cancel { + kind: request.kind, + live_id: request.live_id, + } + } +} + +impl WireBody for RemoteAgentLiveControlRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + match self { + Self::ActivateAttach { attach_id } => { + ActivateAgentLiveAttachRequest::new(attach_id.clone()).map(|_| ()) + } + Self::Cancel { kind, live_id } => { + CancelAgentLiveRequest::new(*kind, live_id.clone()).map(|_| ()) + } + } + } +} + +impl RequestBody for RemoteAgentLiveControlRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +/// Closed peer-wide Control lane request set. Every host worker decodes this +/// union after accepting one authenticated Control stream, so runtime-status +/// and live lifecycle handlers cannot steal one another's requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum RemoteAgentControlRequest { + GetRuntimeStatus, + ActivateAttach { + attach_id: String, + }, + Cancel { + kind: AgentLiveCancelKind, + live_id: String, + }, +} + +impl WireBody for RemoteAgentControlRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + match self { + Self::GetRuntimeStatus => GetRuntimeStatusRequest::new().validate_body(), + Self::ActivateAttach { attach_id } => { + ActivateAgentLiveAttachRequest::new(attach_id.clone()).map(|_| ()) + } + Self::Cancel { kind, live_id } => { + CancelAgentLiveRequest::new(*kind, live_id.clone()).map(|_| ()) + } + } + } +} + +impl RequestBody for RemoteAgentControlRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteAgentLiveSnapshotReason { + PausedSubscriberOverflow, + SlowSubscriber, + JournalReplaced, + RetentionGap, + CursorAhead, + OwnerChanged, + OrderingLost, + JournalUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteAgentLiveRunTerminal { + Completed, + Cancelled, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteAgentLiveClearReason { + RunStarted, + HistoryReplaced, + ExplicitReload, +} + +/// Closed v1 presentation set. It has no tool input/output, raw diagnostic, +/// provider JSON, prompt, credential, or actionable permission variant. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "eventType", rename_all = "camelCase", deny_unknown_fields)] +pub enum RemoteAgentPresentedLiveEvent { + RunStarted, + TimelineUpsert { + item: RemoteAgentTimelineItem, + }, + TimelineCleared { + reason: RemoteAgentLiveClearReason, + }, + HistoryReplaced, + CursorAdvanced, + SessionUpdated { + session: RemoteAgentSessionSummary, + }, + RunFinished { + terminal: RemoteAgentLiveRunTerminal, + }, + SessionDeleted, + UserFacingError { + item: RemoteAgentTimelineItem, + }, +} + +impl RemoteAgentPresentedLiveEvent { + fn validate(&self) -> Result<(), ProtocolError> { + match self { + // `RemoteAgentTimelineItem::validate` admits only the fixed, + // terminal permission presentation set. Pending/actionable + // controls therefore remain unrepresentable here. + Self::TimelineUpsert { item } => item.validate_live_presentation(), + Self::SessionUpdated { session } => session.validate(), + Self::UserFacingError { item } => { + item.validate_live_presentation()?; + let safe_warning = item.item_type == "system" + && item.role.as_deref() == Some("system") + && item.title.as_deref() == Some("Agent warning") + && item.text.as_deref() == Some(SAFE_REMOTE_SETUP_WARNING) + && item.status.as_deref() == Some("warning") + && item.merge == "replace"; + let safe_error = item.item_type == "error"; + if safe_warning || safe_error { + Ok(()) + } else { + Err(invalid_live_frame("unsafe live error presentation")) + } + } + Self::RunStarted + | Self::TimelineCleared { .. } + | Self::HistoryReplaced + | Self::CursorAdvanced + | Self::RunFinished { .. } + | Self::SessionDeleted => Ok(()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteAgentLiveDelivery { + pub cursor: RemoteLiveEventCursor, + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + pub event: RemoteAgentPresentedLiveEvent, +} + +impl RemoteAgentLiveDelivery { + pub fn validate(&self) -> Result<(), ProtocolError> { + self.cursor.validate()?; + validate_id("Agent live session id", &self.session_id)?; + if let Some(run_id) = self.run_id.as_deref() { + validate_id("Agent live run id", run_id)?; + } + self.event.validate()?; + match &self.event { + RemoteAgentPresentedLiveEvent::SessionUpdated { session } + if session.id != self.session_id => + { + Err(invalid_live_frame( + "live session update route is inconsistent", + )) + } + RemoteAgentPresentedLiveEvent::RunStarted + | RemoteAgentPresentedLiveEvent::RunFinished { .. } + | RemoteAgentPresentedLiveEvent::HistoryReplaced + | RemoteAgentPresentedLiveEvent::UserFacingError { .. } + if self.run_id.is_none() => + { + Err(invalid_live_frame("live run event is missing its run id")) + } + RemoteAgentPresentedLiveEvent::CursorAdvanced + | RemoteAgentPresentedLiveEvent::SessionDeleted + if self.run_id.is_some() => + { + Err(invalid_live_frame( + "session event unexpectedly contains a run id", + )) + } + RemoteAgentPresentedLiveEvent::TimelineCleared { + reason: + RemoteAgentLiveClearReason::RunStarted | RemoteAgentLiveClearReason::HistoryReplaced, + } if self.run_id.is_none() => Err(invalid_live_frame( + "run-scoped live clear is missing its run id", + )), + RemoteAgentPresentedLiveEvent::TimelineCleared { + reason: RemoteAgentLiveClearReason::ExplicitReload, + } if self.run_id.is_some() => Err(invalid_live_frame( + "session-scoped live clear unexpectedly contains a run id", + )), + _ => Ok(()), + } + } +} + +/// One complete account-wide absolute live overlay entry captured at C0. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteAgentLiveSessionSnapshot { + pub session_id: String, + pub live_items: Vec, +} + +impl RemoteAgentLiveSessionSnapshot { + pub fn validate(&self) -> Result<(), ProtocolError> { + validate_id("Agent live snapshot session id", &self.session_id)?; + if self.live_items.len() > MAX_LIVE_ITEMS_PER_SESSION { + return Err(invalid_live_frame( + "live session snapshot contains too many items", + )); + } + let mut item_ids = BTreeSet::new(); + for item in &self.live_items { + item.validate_live_presentation()?; + if item.merge != "replace" || !item_ids.insert(item.id.as_str()) { + return Err(invalid_live_frame( + "live session snapshot is not a unique absolute projection", + )); + } + } + Ok(()) + } +} + +/// Ordered frames on a Begin/Resume Events stream. A Begin snapshot is fully +/// consumed before activation. Replay and later live events remain on this +/// same correlated response stream. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "frame", rename_all = "snake_case", deny_unknown_fields)] +pub enum AgentLiveStreamFrame { + SnapshotStart { + attach_id: String, + record_count: u16, + live_session_count: u16, + live_sessions_complete: bool, + through_event_cursor: RemoteLiveEventCursor, + }, + HistoryRecord { + index: u16, + record: RemoteAgentHistoryRecord, + }, + LiveSessionStart { + index: u16, + session_id: String, + item_count: u16, + }, + LiveSessionItem { + session_index: u16, + item_index: u16, + item: RemoteAgentTimelineItem, + }, + SnapshotEnd { + #[serde(default, skip_serializing_if = "Option::is_none")] + next_cursor: Option, + history_revision: String, + }, + StreamStart { + live_stream_id: String, + from_event_cursor: RemoteLiveEventCursor, + through_event_cursor: RemoteLiveEventCursor, + }, + Event { + delivery: RemoteAgentLiveDelivery, + }, + ReplayComplete { + through_event_cursor: RemoteLiveEventCursor, + }, + SnapshotRequired { + reason: RemoteAgentLiveSnapshotReason, + last_event_cursor: RemoteLiveEventCursor, + }, +} + +impl AgentLiveStreamFrame { + pub fn validate(&self) -> Result<(), ProtocolError> { + match self { + Self::SnapshotStart { + attach_id, + record_count, + live_session_count, + live_sessions_complete, + through_event_cursor, + } => { + validate_id("Agent live attachment id", attach_id)?; + if *record_count > MAX_PAGE_SIZE + || usize::from(*live_session_count) > MAX_LIVE_SESSIONS_PER_ACCOUNT + || !live_sessions_complete + { + return Err(invalid_live_frame("invalid live snapshot header")); + } + through_event_cursor.validate() + } + Self::HistoryRecord { index, record } => { + if *index >= MAX_PAGE_SIZE { + return Err(invalid_live_frame("live history record index is invalid")); + } + record.validate() + } + Self::LiveSessionStart { + index, + session_id, + item_count, + } => { + if usize::from(*index) >= MAX_LIVE_SESSIONS_PER_ACCOUNT { + return Err(invalid_live_frame("live session index is invalid")); + } + validate_id("Agent live snapshot session id", session_id)?; + if usize::from(*item_count) > MAX_LIVE_ITEMS_PER_SESSION { + return Err(invalid_live_frame( + "live session snapshot contains too many items", + )); + } + Ok(()) + } + Self::LiveSessionItem { + session_index, + item_index, + item, + } => { + if usize::from(*session_index) >= MAX_LIVE_SESSIONS_PER_ACCOUNT + || usize::from(*item_index) >= MAX_LIVE_ITEMS_PER_SESSION + { + return Err(invalid_live_frame("live session item index is invalid")); + } + item.validate_live_presentation()?; + if item.merge != "replace" { + return Err(invalid_live_frame( + "live session snapshot item is not an absolute projection", + )); + } + Ok(()) + } + Self::SnapshotEnd { + next_cursor, + history_revision, + } => { + validate_optional_cursor(next_cursor.as_deref())?; + if !is_safe_cursor(history_revision) { + return Err(invalid_live_frame("invalid live history revision")); + } + Ok(()) + } + Self::StreamStart { + live_stream_id, + from_event_cursor, + through_event_cursor, + } => { + validate_id("Agent live stream id", live_stream_id)?; + validate_cursor_range(from_event_cursor, through_event_cursor) + } + Self::Event { delivery } => delivery.validate(), + Self::ReplayComplete { + through_event_cursor, + } => through_event_cursor.validate(), + Self::SnapshotRequired { + last_event_cursor, .. + } => last_event_cursor.validate(), + } + } +} + +impl WireBody for AgentLiveStreamFrame { + fn stream_kind(&self) -> StreamKind { + StreamKind::Events + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl ResponseBody for AgentLiveStreamFrame { + fn validate_response_to( + &self, + request: &BeginAgentLiveAttachRequest, + ) -> Result<(), ProtocolError> { + request.validate()?; + self.validate()?; + match self { + Self::SnapshotStart { record_count, .. } if *record_count > request.limit => Err( + invalid_live_frame("live history snapshot exceeds the requested row limit"), + ), + Self::HistoryRecord { index, .. } if *index >= request.limit => Err( + invalid_live_frame("live history row index exceeds the requested limit"), + ), + _ => Ok(()), + } + } +} + +impl ResponseBody for AgentLiveStreamFrame { + fn validate_response_to( + &self, + request: &ResumeAgentLiveEventsRequest, + ) -> Result<(), ProtocolError> { + request.validate()?; + self.validate()?; + match self { + Self::SnapshotStart { .. } + | Self::HistoryRecord { .. } + | Self::LiveSessionStart { .. } + | Self::LiveSessionItem { .. } + | Self::SnapshotEnd { .. } => Err(invalid_live_frame( + "resume stream cannot disclose a fresh history snapshot", + )), + Self::StreamStart { + from_event_cursor, .. + } if from_event_cursor != &request.cursor => Err(invalid_live_frame( + "resume stream does not start at the requested cursor", + )), + _ => Ok(()), + } + } +} + +impl ResponseBody for AgentLiveStreamFrame { + fn validate_response_to( + &self, + request: &RemoteAgentLiveEventsRequest, + ) -> Result<(), ProtocolError> { + match request { + RemoteAgentLiveEventsRequest::BeginAttach { session_id, limit } => { + let request = BeginAgentLiveAttachRequest::new(session_id.clone(), *limit)?; + >::validate_response_to( + self, &request, + ) + } + RemoteAgentLiveEventsRequest::Resume { + cursor, + origin_host_epoch, + } => { + let request = + ResumeAgentLiveEventsRequest::new(cursor.clone(), *origin_host_epoch)?; + >::validate_response_to( + self, &request, + ) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "disposition", rename_all = "snake_case", deny_unknown_fields)] +pub enum AgentLiveActivationDisposition { + Activated { + live_stream_id: String, + through_event_cursor: RemoteLiveEventCursor, + }, + SnapshotRequired { + reason: RemoteAgentLiveSnapshotReason, + last_event_cursor: RemoteLiveEventCursor, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActivateAgentLiveAttachResponse { + pub attach_id: String, + pub result: AgentLiveActivationDisposition, +} + +impl WireBody for ActivateAgentLiveAttachResponse { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + validate_id("Agent live attachment id", &self.attach_id)?; + match &self.result { + AgentLiveActivationDisposition::Activated { + live_stream_id, + through_event_cursor, + } => { + validate_id("Agent live stream id", live_stream_id)?; + through_event_cursor.validate() + } + AgentLiveActivationDisposition::SnapshotRequired { + last_event_cursor, .. + } => last_event_cursor.validate(), + } + } +} + +impl ResponseBody for ActivateAgentLiveAttachResponse { + fn validate_response_to( + &self, + request: &ActivateAgentLiveAttachRequest, + ) -> Result<(), ProtocolError> { + request.validate()?; + self.validate_body()?; + if self.attach_id == request.attach_id { + Ok(()) + } else { + Err(invalid_live_frame( + "live activation response names another attachment", + )) + } + } +} + +impl ResponseBody for ActivateAgentLiveAttachResponse { + fn validate_response_to( + &self, + request: &RemoteAgentLiveControlRequest, + ) -> Result<(), ProtocolError> { + match request { + RemoteAgentLiveControlRequest::ActivateAttach { attach_id } => { + let request = ActivateAgentLiveAttachRequest::new(attach_id.clone())?; + >::validate_response_to( + self, &request, + ) + } + RemoteAgentLiveControlRequest::Cancel { .. } => Err(invalid_live_frame( + "live activation response was sent for a cancellation request", + )), + } + } +} + +impl ResponseBody for ActivateAgentLiveAttachResponse { + fn validate_response_to( + &self, + request: &RemoteAgentControlRequest, + ) -> Result<(), ProtocolError> { + match request { + RemoteAgentControlRequest::ActivateAttach { attach_id } => { + let request = ActivateAgentLiveAttachRequest::new(attach_id.clone())?; + >::validate_response_to( + self, &request, + ) + } + _ => Err(invalid_live_frame( + "live activation response was sent for another Control operation", + )), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CancelAgentLiveResponse { + pub kind: AgentLiveCancelKind, + pub live_id: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteAgentLiveHeadSnapshot { + pub attach_id: String, + pub records: Vec, + pub next_cursor: Option, + pub history_revision: String, + pub live_sessions: Vec, + pub through_event_cursor: RemoteLiveEventCursor, + pub origin_host_epoch: u64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteAgentLiveStreamStart { + pub live_stream_id: String, + pub from_event_cursor: RemoteLiveEventCursor, + pub through_event_cursor: RemoteLiveEventCursor, +} + +impl WireBody for CancelAgentLiveResponse { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + validate_id("Agent live lifecycle id", &self.live_id) + } +} + +impl ResponseBody for CancelAgentLiveResponse { + fn validate_response_to(&self, request: &CancelAgentLiveRequest) -> Result<(), ProtocolError> { + request.validate()?; + self.validate_body()?; + if self.kind == request.kind && self.live_id == request.live_id { + Ok(()) + } else { + Err(invalid_live_frame( + "live cancellation response does not match its request", + )) + } + } +} + +impl ResponseBody for CancelAgentLiveResponse { + fn validate_response_to( + &self, + request: &RemoteAgentLiveControlRequest, + ) -> Result<(), ProtocolError> { + match request { + RemoteAgentLiveControlRequest::Cancel { kind, live_id } => { + let request = CancelAgentLiveRequest::new(*kind, live_id.clone())?; + >::validate_response_to(self, &request) + } + RemoteAgentLiveControlRequest::ActivateAttach { .. } => Err(invalid_live_frame( + "live cancellation response was sent for an activation request", + )), + } + } +} + +impl ResponseBody for CancelAgentLiveResponse { + fn validate_response_to( + &self, + request: &RemoteAgentControlRequest, + ) -> Result<(), ProtocolError> { + match request { + RemoteAgentControlRequest::Cancel { kind, live_id } => { + let request = CancelAgentLiveRequest::new(*kind, live_id.clone())?; + >::validate_response_to(self, &request) + } + _ => Err(invalid_live_frame( + "live cancellation response was sent for another Control operation", + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSessionListOperation { + ListSessions, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListAgentSessionsRequest { + pub operation: AgentSessionListOperation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default = "default_page_size")] + pub limit: u16, +} + +impl ListAgentSessionsRequest { + pub fn validate(&self) -> Result<(), ProtocolError> { + match self.operation { + AgentSessionListOperation::ListSessions => {} + } + validate_optional_status_field( + "project root", + self.project_root.as_deref(), + MAX_PROJECT_ROOT_BYTES, + )?; + validate_page_limit_and_cursor(self.limit, self.cursor.as_deref()) + } +} + +impl WireBody for ListAgentSessionsRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl RequestBody for ListAgentSessionsRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteAgentSessionSummary { + pub id: String, + pub title: String, + pub project_root: String, + pub created_ms: i64, + pub updated_ms: i64, + pub page_sort_ms: i64, + pub message_count: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + pub mode: String, +} + +impl RemoteAgentSessionSummary { + pub fn validate(&self) -> Result<(), ProtocolError> { + validate_id("Agent session id", &self.id)?; + validate_bounded_display_text( + "Agent session title", + &self.title, + MAX_SESSION_TITLE_BYTES, + false, + )?; + validate_bounded_display_text( + "Agent project root", + &self.project_root, + MAX_PROJECT_ROOT_BYTES, + false, + )?; + validate_optional_status_field("model", self.model.as_deref(), MAX_MODEL_ID_BYTES)?; + validate_bounded_display_text("Agent mode", &self.mode, MAX_AGENT_MODE_BYTES, false)?; + for (field, timestamp) in [ + ("created timestamp", self.created_ms), + ("updated timestamp", self.updated_ms), + ("page sort timestamp", self.page_sort_ms), + ] { + if !(0..=MAX_JAVASCRIPT_SAFE_INTEGER).contains(×tamp) { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + format!("Agent session {field} is outside the safe wire range"), + false, + )); + } + } + if self.message_count > MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "Agent session message count is outside the safe wire range", + false, + )); + } + Ok(()) + } +} + +impl WireBody for RemoteAgentSessionSummary { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl PageItem for RemoteAgentSessionSummary {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListAgentSessionsResponse { + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl ListAgentSessionsResponse { + pub fn validate_for(&self, request: &ListAgentSessionsRequest) -> Result<(), ProtocolError> { + request.validate()?; + if self.items.len() > usize::from(request.limit) { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "session page contains more items than requested", + false, + )); + } + if self.items.is_empty() && self.next_cursor.is_some() { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "empty session page cannot contain a continuation cursor", + false, + )); + } + if self.next_cursor.as_ref() == request.cursor.as_ref() && self.next_cursor.is_some() { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "session continuation cursor did not advance", + false, + )); + } + validate_optional_cursor(self.next_cursor.as_deref())?; + for item in &self.items { + item.validate()?; + } + Ok(()) + } +} + +impl WireBody for ListAgentSessionsResponse { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + if self.items.len() > usize::from(MAX_PAGE_SIZE) { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "session page contains too many items", + false, + )); + } + validate_optional_cursor(self.next_cursor.as_deref())?; + for item in &self.items { + item.validate()?; + } + Ok(()) + } +} + +impl ResponseBody for ListAgentSessionsResponse { + fn validate_response_to( + &self, + request: &ListAgentSessionsRequest, + ) -> Result<(), ProtocolError> { + self.validate_for(request) + } +} + +/// Closed peer-wide Bulk lane request set. History and task-list workers all +/// decode this union, preventing one Bulk operation from being consumed by a +/// handler for the other. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "snake_case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum RemoteAgentBulkRequest { + ListSessionRecords { + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cursor: Option, + #[serde(default = "default_page_size")] + limit: u16, + }, + ListSessions { + #[serde(default, skip_serializing_if = "Option::is_none")] + project_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cursor: Option, + #[serde(default = "default_page_size")] + limit: u16, + }, +} + +impl WireBody for RemoteAgentBulkRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + match self { + Self::ListSessionRecords { + session_id, + cursor, + limit, + } => ListAgentHistoryRecordsRequest::new(session_id.clone(), cursor.clone(), *limit) + .map(|_| ()), + Self::ListSessions { + project_root, + cursor, + limit, + } => ListAgentSessionsRequest { + operation: AgentSessionListOperation::ListSessions, + project_root: project_root.clone(), + cursor: cursor.clone(), + limit: *limit, + } + .validate(), + } + } +} + +impl RequestBody for RemoteAgentBulkRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +impl ResponseBody for AgentHistoryPageFrame { + fn validate_response_to(&self, request: &RemoteAgentBulkRequest) -> Result<(), ProtocolError> { + match request { + RemoteAgentBulkRequest::ListSessionRecords { + session_id, + cursor, + limit, + } => { + let request = ListAgentHistoryRecordsRequest::new( + session_id.clone(), + cursor.clone(), + *limit, + )?; + >::validate_response_to( + self, &request, + ) + } + RemoteAgentBulkRequest::ListSessions { .. } => Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "history page frame was sent for the task-list operation", + false, + )), + } + } +} + +impl ResponseBody for ListAgentSessionsResponse { + fn validate_response_to(&self, request: &RemoteAgentBulkRequest) -> Result<(), ProtocolError> { + match request { + RemoteAgentBulkRequest::ListSessions { + project_root, + cursor, + limit, + } => { + let request = ListAgentSessionsRequest { + operation: AgentSessionListOperation::ListSessions, + project_root: project_root.clone(), + cursor: cursor.clone(), + limit: *limit, + }; + >::validate_response_to( + self, &request, + ) + } + RemoteAgentBulkRequest::ListSessionRecords { .. } => Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "task-list response was sent for the history operation", + false, + )), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestEnvelope { + pub protocol_version: u16, + pub request_id: String, + pub execution_target_id: String, + pub direction: PeerDirection, + pub connection_stamp: ConnectionStamp, + pub body: T, +} + +impl RequestEnvelope { + pub fn validate( + &self, + expected_direction: PeerDirection, + expected_execution_target: &str, + expected_connection_stamp: ConnectionStamp, + expected_stream_kind: StreamKind, + ) -> Result<(), ProtocolError> { + validate_version(self.protocol_version)?; + validate_id("request_id", &self.request_id)?; + validate_id("execution_target_id", &self.execution_target_id)?; + if self.execution_target_id != expected_execution_target { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "request execution target does not match this host", + false, + )); + } + if self.direction != expected_direction { + return Err(ProtocolError::new( + ErrorCode::WrongDirection, + "request direction is not allowed on this peer", + false, + )); + } + validate_connection_stamp(self.connection_stamp, expected_connection_stamp)?; + validate_stream_kind(self.body.stream_kind(), expected_stream_kind)?; + self.body.validate_body()?; + self.body.validate_body_for_stamp(self.connection_stamp) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResponseEnvelope { + pub protocol_version: u16, + pub request_id: String, + pub execution_target_id: String, + pub connection_stamp: ConnectionStamp, + pub result: Result, +} + +impl ResponseEnvelope { + pub fn validate( + &self, + expected_request_id: &str, + expected_execution_target: &str, + expected_connection_stamp: ConnectionStamp, + expected_stream_kind: StreamKind, + ) -> Result<(), ProtocolError> { + validate_version(self.protocol_version)?; + validate_id("request_id", &self.request_id)?; + validate_id("execution_target_id", &self.execution_target_id)?; + if self.request_id != expected_request_id { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "response request id does not match the outstanding request", + false, + )); + } + if self.execution_target_id != expected_execution_target { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "response execution target does not match the selected host", + false, + )); + } + validate_connection_stamp(self.connection_stamp, expected_connection_stamp)?; + match &self.result { + Ok(body) => { + validate_stream_kind(body.stream_kind(), expected_stream_kind)?; + body.validate_body()?; + body.validate_body_for_stamp(self.connection_stamp)?; + } + // An error has no success body from which to derive a lane. Its + // request id and the already-validated stream header bind it to + // the outstanding operation instead. + Err(error) => error.validate()?, + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StreamHeader { + pub protocol_version: u16, + pub stream_kind: StreamKind, + pub direction: PeerDirection, + pub connection_stamp: ConnectionStamp, +} + +impl StreamHeader { + pub fn validate(&self, expected_direction: PeerDirection) -> Result<(), ProtocolError> { + validate_version(self.protocol_version)?; + self.connection_stamp.validate()?; + if self.direction != expected_direction { + return Err(ProtocolError::new( + ErrorCode::WrongDirection, + "stream direction is not allowed on this peer", + false, + )); + } + Ok(()) + } +} + +/// Bounded paging foundation used by the transport harness. This is not a +/// product operation: session, timeline, and other resources must introduce +/// concrete resource-discriminated request/response pairs before going live. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PageRequest { + #[serde(default)] + pub cursor: Option, + #[serde(default = "default_page_size")] + pub limit: u16, +} + +impl Default for PageRequest { + fn default() -> Self { + Self { + cursor: None, + limit: DEFAULT_PAGE_SIZE, + } + } +} + +impl PageRequest { + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.limit == 0 || self.limit > MAX_PAGE_SIZE { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + format!("page limit must be between 1 and {MAX_PAGE_SIZE}"), + false, + )); + } + if self + .cursor + .as_ref() + .is_some_and(|cursor| !is_safe_cursor(cursor)) + { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "cursor is empty, unsafe, or too large", + false, + )); + } + Ok(()) + } +} + +impl WireBody for PageRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate() + } +} + +impl RequestBody for PageRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +/// Generic bounded page used only with an explicitly reviewed [`PageItem`]. +/// Real resources still require concrete, resource-discriminated operations; +/// bare `PageRequest` must not ambiguously route multiple page kinds. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, bound(deserialize = "T: Deserialize<'de>"))] +pub struct Page { + #[serde(deserialize_with = "deserialize_page_items")] + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl Page { + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.items.len() > usize::from(MAX_PAGE_SIZE) { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "page contains too many items", + false, + )); + } + if self + .next_cursor + .as_ref() + .is_some_and(|cursor| !is_safe_cursor(cursor)) + { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "next cursor is empty, unsafe, or too large", + false, + )); + } + Ok(()) + } + + /// Validate progress and sizing against the request that produced this + /// page. Global wire bounds alone cannot prove that a continuation makes + /// progress or that the host honored the caller's requested limit. + pub fn validate_for_request(&self, request: &PageRequest) -> Result<(), ProtocolError> { + request.validate()?; + self.validate()?; + if self.items.len() > usize::from(request.limit) { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "page contains more items than requested", + false, + )); + } + if self.items.is_empty() && self.next_cursor.is_some() { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "empty page cannot contain a continuation cursor", + false, + )); + } + if self.next_cursor.as_ref() == request.cursor.as_ref() && self.next_cursor.is_some() { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + "page continuation cursor did not advance", + false, + )); + } + Ok(()) + } +} + +fn deserialize_page_items<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct PageItemsVisitor(PhantomData T>); + + impl<'de, T> Visitor<'de> for PageItemsVisitor + where + T: Deserialize<'de>, + { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "a sequence containing at most {MAX_PAGE_SIZE} page items" + ) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let maximum = usize::from(MAX_PAGE_SIZE); + if sequence.size_hint().is_some_and(|length| length > maximum) { + return Err(de::Error::custom("page contains too many items")); + } + let mut items = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(maximum)); + while items.len() < maximum { + match sequence.next_element()? { + Some(item) => items.push(item), + None => return Ok(items), + } + } + // A sequence with no trustworthy size hint needs one non-allocating + // look-ahead to distinguish exactly MAX_PAGE_SIZE items from an + // oversized page. Never deserialize an extra `T` or grow the Vec. + if sequence.next_element::()?.is_some() { + Err(de::Error::custom("page contains too many items")) + } else { + Ok(items) + } + } + } + + deserializer.deserialize_seq(PageItemsVisitor(PhantomData)) +} + +impl WireBody for Page { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.validate()?; + for item in &self.items { + validate_stream_kind(item.stream_kind(), StreamKind::Bulk)?; + item.validate_body()?; + } + Ok(()) + } + + fn validate_body_for_stamp( + &self, + connection_stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + for item in &self.items { + item.validate_body_for_stamp(connection_stamp)?; + } + Ok(()) + } +} + +impl ResponseBody for Page { + fn validate_response_to(&self, request: &PageRequest) -> Result<(), ProtocolError> { + self.validate_for_request(request) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ResumeRequest { + Fresh, + Resume { + previous_connection_stamp: ConnectionStamp, + last_received_event_sequence: u64, + }, +} + +impl WireBody for ResumeRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + match self { + Self::Fresh => Ok(()), + Self::Resume { + previous_connection_stamp, + .. + } => previous_connection_stamp.validate(), + } + } + + fn validate_body_for_stamp( + &self, + connection_stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + if let Self::Resume { + previous_connection_stamp, + .. + } = self + { + // A controller may have missed multiple generations or even a + // whole host epoch while suspended. A strictly older stamp remains + // a valid resume request; the host can answer SnapshotRequired if + // the referenced event window no longer exists. + if *previous_connection_stamp >= connection_stamp { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "resume source stamp must precede the current connection", + true, + )); + } + } + Ok(()) + } +} + +impl RequestBody for ResumeRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResumeDisposition { + Fresh, + Resumed, + SnapshotRequired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResumeResponse { + pub connection_stamp: ConnectionStamp, + pub disposition: ResumeDisposition, + pub first_available_event_sequence: u64, +} + +impl WireBody for ResumeResponse { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + self.connection_stamp.validate() + } + + fn validate_body_for_stamp( + &self, + connection_stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + validate_connection_stamp(self.connection_stamp, connection_stamp) + } +} + +impl ResponseBody for ResumeResponse { + fn validate_response_to(&self, request: &ResumeRequest) -> Result<(), ProtocolError> { + match (request, &self.disposition) { + (ResumeRequest::Fresh, ResumeDisposition::Fresh) + | (ResumeRequest::Resume { .. }, ResumeDisposition::SnapshotRequired) => Ok(()), + ( + ResumeRequest::Resume { + last_received_event_sequence, + .. + }, + ResumeDisposition::Resumed, + ) => { + let next_expected = last_received_event_sequence + .checked_add(1) + .unwrap_or(u64::MAX); + if self.first_available_event_sequence <= next_expected { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "resumed response would skip unavailable events", + false, + )) + } + } + _ => Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "resume response disposition does not match its request", + false, + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + InvalidFrame, + FrameTooLarge, + UnsupportedVersion, + WrongEndpoint, + WrongDirection, + Unauthorized, + Revoked, + InvalidPage, + HistoryRecordTooLarge, + StaleHistory, + AgentLiveUnavailable, + SnapshotRequired, + StaleGeneration, + SecureStorageUnavailable, + TransportUnavailable, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProtocolError { + pub code: ErrorCode, + pub message: String, + pub retryable: bool, +} + +impl ProtocolError { + pub fn new(code: ErrorCode, message: impl Into, retryable: bool) -> Self { + let mut message = sanitize_error_message(&message.into()); + if message.is_empty() { + message = "remote protocol error".into(); + } + truncate_utf8(&mut message, MAX_ERROR_MESSAGE_BYTES); + Self { + code, + message, + retryable, + } + } + + /// Validate a structured error received from the wire. `ErrorCode` and + /// `retryable` are fixed-size serde values; the human-readable message is + /// the only variable-size field. + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.message.is_empty() + || self.message.len() > MAX_ERROR_MESSAGE_BYTES + || self.message != sanitize_error_message(&self.message) + { + Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "protocol error message is empty or too large", + false, + )) + } else { + Ok(()) + } + } +} + +impl std::fmt::Display for ProtocolError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{:?}: {}", self.code, self.message) + } +} + +impl std::error::Error for ProtocolError {} + +pub fn validate_version(version: u16) -> Result<(), ProtocolError> { + if version == PROTOCOL_VERSION { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::UnsupportedVersion, + format!("unsupported protocol version {version}"), + false, + )) + } +} + +pub fn validate_frame_len(len: usize) -> Result<(), ProtocolError> { + if len <= MAX_FRAME_BYTES as usize { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::FrameTooLarge, + format!("frame exceeds {MAX_FRAME_BYTES} bytes"), + false, + )) + } +} + +fn validate_connection_stamp( + actual: ConnectionStamp, + expected: ConnectionStamp, +) -> Result<(), ProtocolError> { + actual.validate()?; + expected.validate()?; + if actual == expected { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "message belongs to a stale connection stamp", + true, + )) + } +} + +fn validate_stream_kind(actual: StreamKind, expected: StreamKind) -> Result<(), ProtocolError> { + if actual == expected { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "message body is not allowed on this stream kind", + false, + )) + } +} + +fn validate_id(field: &str, value: &str) -> Result<(), ProtocolError> { + if !value.is_empty() + && value.len() <= MAX_ID_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::InvalidFrame, + format!("invalid {field}"), + false, + )) + } +} + +fn validate_page_limit_and_cursor(limit: u16, cursor: Option<&str>) -> Result<(), ProtocolError> { + if limit == 0 || limit > MAX_PAGE_SIZE { + return Err(ProtocolError::new( + ErrorCode::InvalidPage, + format!("page limit must be between 1 and {MAX_PAGE_SIZE}"), + false, + )); + } + validate_optional_cursor(cursor) +} + +fn validate_optional_cursor(cursor: Option<&str>) -> Result<(), ProtocolError> { + if cursor.is_some_and(|cursor| !is_safe_cursor(cursor)) { + Err(ProtocolError::new( + ErrorCode::InvalidPage, + "cursor is empty, unsafe, or too large", + false, + )) + } else { + Ok(()) + } +} + +fn validate_bounded_display_text( + field: &str, + value: &str, + max_bytes: usize, + allow_empty: bool, +) -> Result<(), ProtocolError> { + if (allow_empty || !value.is_empty()) + && value.len() <= max_bytes + && !value + .chars() + .any(|character| character.is_control() || is_bidi_control(character)) + { + Ok(()) + } else { + Err(invalid_history_record(format!("invalid {field}"))) + } +} + +fn validate_optional_history_label( + field: &str, + value: Option<&str>, + max_bytes: usize, +) -> Result<(), ProtocolError> { + value.map_or(Ok(()), |value| { + validate_bounded_display_text(field, value, max_bytes, true) + }) +} + +fn validate_optional_content_text(field: &str, value: Option<&str>) -> Result<(), ProtocolError> { + validate_optional_content_text_bounded(field, value, MAX_FRAME_BYTES as usize) +} + +fn validate_optional_content_text_bounded( + field: &str, + value: Option<&str>, + max_bytes: usize, +) -> Result<(), ProtocolError> { + let Some(value) = value else { + return Ok(()); + }; + if value.len() <= max_bytes && !value.contains('\0') { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::HistoryRecordTooLarge, + format!("{field} exceeds Maple's history record limit"), + false, + )) + } +} + +fn invalid_history_record(message: impl Into) -> ProtocolError { + ProtocolError::new(ErrorCode::InvalidFrame, message, false) +} + +fn invalid_live_frame(message: impl Into) -> ProtocolError { + ProtocolError::new(ErrorCode::InvalidFrame, message, false) +} + +fn validate_cursor_range( + from: &RemoteLiveEventCursor, + through: &RemoteLiveEventCursor, +) -> Result<(), ProtocolError> { + from.validate()?; + through.validate()?; + if from.journal_id != through.journal_id || from.sequence > through.sequence { + Err(ProtocolError::new( + ErrorCode::SnapshotRequired, + "live event cursor range requires an authoritative snapshot", + true, + )) + } else { + Ok(()) + } +} + +fn serialized_cbor_len(value: &T) -> Result { + #[derive(Default)] + struct Counter(usize); + + impl std::io::Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self + .0 + .checked_add(bytes.len()) + .ok_or_else(|| std::io::Error::other("CBOR length overflow"))?; + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let mut counter = Counter::default(); + ciborium::ser::into_writer(value, &mut counter).map_err(|_| { + ProtocolError::new( + ErrorCode::InvalidFrame, + "failed to measure Agent history record", + false, + ) + })?; + Ok(counter.0) +} + +fn validate_optional_status_field( + field: &str, + value: Option<&str>, + max_bytes: usize, +) -> Result<(), ProtocolError> { + let Some(value) = value else { + return Ok(()); + }; + if !value.is_empty() + && value.len() <= max_bytes + && !value + .chars() + .any(|character| character.is_control() || is_bidi_control(character)) + { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::InvalidFrame, + format!("invalid runtime status {field}"), + false, + )) + } +} + +fn is_safe_cursor(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CURSOR_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +fn truncate_utf8(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + let mut end = max_bytes; + while !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); +} + +fn sanitize_error_message(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_control() + || is_bidi_control(character) + || matches!(character, '\u{2028}' | '\u{2029}') + { + ' ' + } else { + character + } + }) + .collect::() + .trim() + .to_owned() +} + +fn is_bidi_control(character: char) -> bool { + matches!( + character, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) +} + +const fn default_page_size() -> u16 { + DEFAULT_PAGE_SIZE +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stamp(host_epoch: u64, generation: u64) -> ConnectionStamp { + ConnectionStamp::new(host_epoch, generation).unwrap() + } + + #[test] + fn stream_priorities_keep_control_ahead_of_events_and_bulk() { + assert!(StreamKind::Control.priority() > StreamKind::Events.priority()); + assert!(StreamKind::Events.priority() > StreamKind::Bulk.priority()); + } + + #[test] + fn request_operations_are_explicitly_controller_to_host() { + assert_eq!( + GetRuntimeStatusRequest::new().allowed_direction(), + PeerDirection::ControllerToHost + ); + assert_eq!( + GetRuntimeStatusRequest::new().stream_kind(), + StreamKind::Control + ); + assert_eq!( + PageRequest::default().allowed_direction(), + PeerDirection::ControllerToHost + ); + assert_eq!( + ResumeRequest::Fresh.allowed_direction(), + PeerDirection::ControllerToHost + ); + } + + #[test] + fn runtime_status_wire_body_is_bounded_and_state_consistent() { + let running = RemoteAgentRuntimeStatus { + running: true, + project_root: Some("/tmp/maple-project".into()), + model: Some("glm-5-2".into()), + mode: Some("smart_approve".into()), + active_runs: BTreeMap::from([("session-01".into(), "run-01".into())]), + }; + GetRuntimeStatusResponse::new(running.clone()).expect("valid running status"); + + let mut inconsistent = running.clone(); + inconsistent.model = None; + assert_eq!( + inconsistent.validate().unwrap_err().code, + ErrorCode::InvalidFrame + ); + + let stopped_with_run = RemoteAgentRuntimeStatus { + running: false, + project_root: None, + model: None, + mode: None, + active_runs: BTreeMap::from([("session-01".into(), "run-01".into())]), + }; + assert_eq!( + stopped_with_run.validate().unwrap_err().code, + ErrorCode::InvalidFrame + ); + + let stopped_with_one_field = RemoteAgentRuntimeStatus { + running: false, + project_root: Some("/tmp/stale-project".into()), + model: None, + mode: None, + active_runs: BTreeMap::new(), + }; + assert_eq!( + stopped_with_one_field.validate().unwrap_err().code, + ErrorCode::InvalidFrame + ); + + let stopped_with_two_fields = RemoteAgentRuntimeStatus { + running: false, + project_root: Some("/tmp/stale-project".into()), + model: Some("stale-model".into()), + mode: None, + active_runs: BTreeMap::new(), + }; + assert_eq!( + stopped_with_two_fields.validate().unwrap_err().code, + ErrorCode::InvalidFrame + ); + + let mut oversized = running; + oversized.project_root = Some("p".repeat(MAX_PROJECT_ROOT_BYTES + 1)); + assert_eq!( + oversized.validate().unwrap_err().code, + ErrorCode::InvalidFrame + ); + } + + #[test] + fn runtime_status_request_does_not_accept_a_command_name() { + let value = serde_json::json!({ + "operation": "agent_clear_user_data", + "command": "agent_clear_user_data" + }); + assert!(serde_json::from_value::(value).is_err()); + } + + const fn invalid_stamp(host_epoch: u64, generation: u64) -> ConnectionStamp { + ConnectionStamp { + host_epoch, + generation, + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct BoundedTestItem { + value: String, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + struct ContextOnlyTestItem { + base_is_valid: bool, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + struct NonBulkPageItem; + + impl BoundedTestItem { + const MAX_VALUE_BYTES: usize = 8; + } + + impl WireBody for BoundedTestItem { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + if !self.value.is_empty() && self.value.len() <= Self::MAX_VALUE_BYTES { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "test item value is empty or too large", + false, + )) + } + } + } + + impl PageItem for BoundedTestItem {} + + impl WireBody for NonBulkPageItem { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + Ok(()) + } + } + + impl PageItem for NonBulkPageItem {} + + impl WireBody for ContextOnlyTestItem { + fn stream_kind(&self) -> StreamKind { + StreamKind::Control + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + if self.base_is_valid { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "base validation was enforced", + false, + )) + } + } + + fn validate_body_for_stamp( + &self, + _connection_stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + // Deliberately does not call validate_body. Envelope validation must + // enforce the base invariant independently of this override. + Ok(()) + } + } + + #[test] + fn request_requires_current_version_and_direction() { + let mut request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-01".into(), + execution_target_id: "macbook-pro".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: stamp(2, 9), + body: PageRequest::default(), + }; + request + .validate( + PeerDirection::ControllerToHost, + "macbook-pro", + stamp(2, 9), + StreamKind::Bulk, + ) + .expect("valid request"); + assert_eq!( + request + .validate( + PeerDirection::ControllerToHost, + "macbook-pro", + stamp(2, 9), + StreamKind::Control, + ) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + + request.protocol_version += 1; + assert_eq!( + request + .validate( + PeerDirection::ControllerToHost, + "macbook-pro", + stamp(2, 9), + StreamKind::Bulk, + ) + .unwrap_err() + .code, + ErrorCode::UnsupportedVersion + ); + request.protocol_version = PROTOCOL_VERSION; + assert_eq!( + request + .validate( + PeerDirection::HostToController, + "macbook-pro", + stamp(2, 9), + StreamKind::Bulk, + ) + .unwrap_err() + .code, + ErrorCode::WrongDirection + ); + request.direction = PeerDirection::ControllerToHost; + assert_eq!( + request + .validate( + PeerDirection::ControllerToHost, + "other-host", + stamp(2, 9), + StreamKind::Bulk, + ) + .unwrap_err() + .code, + ErrorCode::WrongEndpoint + ); + assert_eq!( + request + .validate( + PeerDirection::ControllerToHost, + "macbook-pro", + stamp(2, 10), + StreamKind::Bulk, + ) + .unwrap_err() + .code, + ErrorCode::StaleGeneration + ); + } + + #[test] + fn contextual_validation_cannot_bypass_base_body_validation() { + let current = stamp(3, 7); + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-contextual".into(), + execution_target_id: "host-01".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: current, + body: ContextOnlyTestItem { + base_is_valid: false, + }, + }; + assert_eq!( + request + .validate( + PeerDirection::ControllerToHost, + "host-01", + current, + StreamKind::Control, + ) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + + let response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-contextual".into(), + execution_target_id: "host-01".into(), + connection_stamp: current, + result: Ok(ContextOnlyTestItem { + base_is_valid: false, + }), + }; + assert_eq!( + response + .validate( + "request-contextual", + "host-01", + current, + StreamKind::Control, + ) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + + let page = Page { + items: vec![ContextOnlyTestItem { + base_is_valid: false, + }], + next_cursor: None, + }; + assert_eq!( + page.validate_body().unwrap_err().code, + ErrorCode::InvalidFrame + ); + } + + #[test] + fn envelope_connection_stamp_rejects_zero_epoch_or_generation() { + assert_eq!( + ConnectionStamp::new(0, 1).unwrap_err().code, + ErrorCode::StaleGeneration + ); + assert_eq!( + ConnectionStamp::new(1, 0).unwrap_err().code, + ErrorCode::StaleGeneration + ); + for connection_stamp in [invalid_stamp(0, 1), invalid_stamp(1, 0)] { + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-01".into(), + execution_target_id: "host-01".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp, + body: PageRequest::default(), + }; + assert_eq!( + request + .validate( + PeerDirection::ControllerToHost, + "host-01", + stamp(1, 1), + StreamKind::Bulk, + ) + .unwrap_err() + .code, + ErrorCode::StaleGeneration + ); + } + } + + #[test] + fn page_request_and_response_validation_are_bound_to_request_context() { + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-01".into(), + execution_target_id: "host-01".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: stamp(1, 3), + body: PageRequest { + cursor: None, + limit: MAX_PAGE_SIZE + 1, + }, + }; + assert_eq!( + request + .validate( + PeerDirection::ControllerToHost, + "host-01", + stamp(1, 3), + StreamKind::Bulk, + ) + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + + let response: ResponseEnvelope> = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-01".into(), + execution_target_id: "host-01".into(), + connection_stamp: stamp(1, 3), + result: Ok(Page { + items: vec![BoundedTestItem { + value: "bounded".into(), + }], + next_cursor: None, + }), + }; + response + .validate("request-01", "host-01", stamp(1, 3), StreamKind::Bulk) + .unwrap(); + assert_eq!( + response + .validate("request-01", "host-01", stamp(1, 3), StreamKind::Control,) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + assert_eq!( + response + .validate("other-request", "host-01", stamp(1, 3), StreamKind::Bulk) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + assert_eq!( + response + .validate("request-01", "other-host", stamp(1, 3), StreamKind::Bulk) + .unwrap_err() + .code, + ErrorCode::WrongEndpoint + ); + assert_eq!( + response + .validate("request-01", "host-01", stamp(1, 4), StreamKind::Bulk) + .unwrap_err() + .code, + ErrorCode::StaleGeneration + ); + + let invalid_item_response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-01".into(), + execution_target_id: "host-01".into(), + connection_stamp: stamp(1, 3), + result: Ok(Page { + items: vec![BoundedTestItem { + value: "too-large".into(), + }], + next_cursor: None, + }), + }; + assert_eq!( + invalid_item_response + .validate("request-01", "host-01", stamp(1, 3), StreamKind::Bulk) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + + let non_bulk_item_response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-01".into(), + execution_target_id: "host-01".into(), + connection_stamp: stamp(1, 3), + result: Ok(Page { + items: vec![NonBulkPageItem], + next_cursor: None, + }), + }; + assert_eq!( + non_bulk_item_response + .validate("request-01", "host-01", stamp(1, 3), StreamKind::Bulk) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + + let page_request = PageRequest { + cursor: Some("cursor-1".into()), + limit: 2, + }; + let valid_page = Page { + items: vec![ + BoundedTestItem { + value: "one".into(), + }, + BoundedTestItem { + value: "two".into(), + }, + ], + next_cursor: Some("cursor-2".into()), + }; + ResponseBody::::validate_response_to(&valid_page, &page_request).unwrap(); + assert_eq!( + Page { + items: vec![ + BoundedTestItem { + value: "one".into(), + }, + BoundedTestItem { + value: "two".into(), + }, + BoundedTestItem { + value: "three".into(), + }, + ], + next_cursor: None, + } + .validate_for_request(&page_request) + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + assert_eq!( + Page:: { + items: Vec::new(), + next_cursor: Some("cursor-2".into()), + } + .validate_for_request(&page_request) + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + assert_eq!( + Page { + items: vec![BoundedTestItem { + value: "one".into(), + }], + next_cursor: Some("cursor-1".into()), + } + .validate_for_request(&page_request) + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + Page:: { + items: Vec::new(), + next_cursor: None, + } + .validate_for_request(&page_request) + .unwrap(); + } + + #[test] + fn page_cursors_reject_log_control_characters() { + for cursor in ["line\nbreak", "terminal\u{1b}escape", "space cursor"] { + assert_eq!( + PageRequest { + cursor: Some(cursor.into()), + limit: 1, + } + .validate() + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + assert_eq!( + Page:: { + items: Vec::new(), + next_cursor: Some(cursor.into()), + } + .validate() + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + } + PageRequest { + cursor: Some("base64url-safe:value_01.test".into()), + limit: 1, + } + .validate() + .unwrap(); + } + + #[test] + fn page_item_limit_is_enforced_during_deserialization() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static DECODED_ITEMS: AtomicUsize = AtomicUsize::new(0); + + #[derive(Debug)] + struct CountedItem; + + impl<'de> Deserialize<'de> for CountedItem { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + DECODED_ITEMS.fetch_add(1, Ordering::SeqCst); + let _ = u16::deserialize(deserializer)?; + Ok(Self) + } + } + + #[derive(Serialize)] + struct EncodedPage { + items: Vec, + next_cursor: Option, + } + + DECODED_ITEMS.store(0, Ordering::SeqCst); + let mut encoded = Vec::new(); + ciborium::ser::into_writer( + &EncodedPage { + items: vec![0; usize::from(MAX_PAGE_SIZE) + 1], + next_cursor: None, + }, + &mut encoded, + ) + .unwrap(); + assert!(ciborium::de::from_reader::, _>(encoded.as_slice()).is_err()); + assert_eq!( + DECODED_ITEMS.load(Ordering::SeqCst), + 0, + "a declared oversized sequence must fail before decoding page items" + ); + } + + #[test] + fn response_errors_are_validated_as_untrusted_wire_data() { + let response = |error| ResponseEnvelope:: { + protocol_version: PROTOCOL_VERSION, + request_id: "request-01".into(), + execution_target_id: "host-01".into(), + connection_stamp: stamp(1, 3), + result: Err(error), + }; + + response(ProtocolError::new( + ErrorCode::TransportUnavailable, + "host is reconnecting", + true, + )) + .validate("request-01", "host-01", stamp(1, 3), StreamKind::Bulk) + .unwrap(); + + for message in [String::new(), "x".repeat(MAX_ERROR_MESSAGE_BYTES + 1)] { + let error = ProtocolError { + code: ErrorCode::Internal, + message, + retryable: false, + }; + assert_eq!( + response(error) + .validate("request-01", "host-01", stamp(1, 3), StreamKind::Bulk) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + } + + assert!(serde_json::from_str::( + r#"{"code":"future_code","message":"bounded","retryable":false}"#, + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"code":"internal","message":"bounded","retryable":false,"future":true}"#, + ) + .is_err()); + } + + #[test] + fn protocol_error_constructor_truncates_at_utf8_boundary() { + let error = ProtocolError::new( + ErrorCode::Internal, + format!("{}💥", "x".repeat(MAX_ERROR_MESSAGE_BYTES - 2)), + false, + ); + assert!(error.message.len() <= MAX_ERROR_MESSAGE_BYTES); + assert_eq!(error.message, "x".repeat(MAX_ERROR_MESSAGE_BYTES - 2)); + error.validate().unwrap(); + } + + #[test] + fn protocol_error_messages_reject_log_and_bidi_injection() { + let constructed = + ProtocolError::new(ErrorCode::Internal, "first\r\nforged\u{202e}entry", false); + assert_eq!(constructed.message, "first forged entry"); + constructed.validate().unwrap(); + + let unicode_separators = ProtocolError::new( + ErrorCode::Internal, + "first\u{2028}second\u{2029}third", + false, + ); + assert_eq!(unicode_separators.message, "first second third"); + unicode_separators.validate().unwrap(); + + for message in [ + "first\nforged", + "first\u{202e}forged", + "first\u{2028}forged", + "first\u{2029}forged", + ] { + let inbound = ProtocolError { + code: ErrorCode::Internal, + message: message.into(), + retryable: false, + }; + assert_eq!( + inbound.validate().unwrap_err().code, + ErrorCode::InvalidFrame + ); + } + } + + #[test] + fn resume_stamps_are_bound_to_the_current_envelope_stamp() { + let current = stamp(7, 5); + let request = |previous_connection_stamp| RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "resume-request".into(), + execution_target_id: "host-01".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: current, + body: ResumeRequest::Resume { + previous_connection_stamp, + last_received_event_sequence: 41, + }, + }; + request(stamp(7, 4)) + .validate( + PeerDirection::ControllerToHost, + "host-01", + current, + StreamKind::Control, + ) + .unwrap(); + // A suspended controller may skip connection generations. + request(stamp(7, 2)) + .validate( + PeerDirection::ControllerToHost, + "host-01", + current, + StreamKind::Control, + ) + .unwrap(); + // A prior host epoch is a valid request, although the host may answer + // SnapshotRequired when its event window no longer exists. + request(stamp(6, 99)) + .validate( + PeerDirection::ControllerToHost, + "host-01", + current, + StreamKind::Control, + ) + .unwrap(); + for previous_connection_stamp in [ + invalid_stamp(0, 4), + invalid_stamp(7, 0), + current, + stamp(7, 6), + stamp(8, 1), + ] { + assert_eq!( + request(previous_connection_stamp) + .validate( + PeerDirection::ControllerToHost, + "host-01", + current, + StreamKind::Control, + ) + .unwrap_err() + .code, + ErrorCode::StaleGeneration + ); + } + + let response = |body_stamp| ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "resume-request".into(), + execution_target_id: "host-01".into(), + connection_stamp: current, + result: Ok(ResumeResponse { + connection_stamp: body_stamp, + disposition: ResumeDisposition::Resumed, + first_available_event_sequence: 42, + }), + }; + response(current) + .validate("resume-request", "host-01", current, StreamKind::Control) + .unwrap(); + + let fresh_response = ResumeResponse { + connection_stamp: current, + disposition: ResumeDisposition::Fresh, + first_available_event_sequence: 0, + }; + fresh_response + .validate_response_to(&ResumeRequest::Fresh) + .unwrap(); + assert_eq!( + fresh_response + .validate_response_to(&ResumeRequest::Resume { + previous_connection_stamp: stamp(7, 4), + last_received_event_sequence: 41, + }) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + let resumed_response = ResumeResponse { + connection_stamp: current, + disposition: ResumeDisposition::Resumed, + first_available_event_sequence: 42, + }; + let resume_request = ResumeRequest::Resume { + previous_connection_stamp: stamp(7, 4), + last_received_event_sequence: 41, + }; + resumed_response + .validate_response_to(&resume_request) + .unwrap(); + let replay_gap = ResumeResponse { + first_available_event_sequence: 43, + ..resumed_response.clone() + }; + assert_eq!( + replay_gap + .validate_response_to(&resume_request) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + let max_sequence_request = ResumeRequest::Resume { + previous_connection_stamp: stamp(7, 4), + last_received_event_sequence: u64::MAX, + }; + ResumeResponse { + first_available_event_sequence: u64::MAX, + ..resumed_response.clone() + } + .validate_response_to(&max_sequence_request) + .unwrap(); + ResumeResponse { + disposition: ResumeDisposition::SnapshotRequired, + first_available_event_sequence: 43, + ..resumed_response.clone() + } + .validate_response_to(&resume_request) + .unwrap(); + assert_eq!( + resumed_response + .validate_response_to(&ResumeRequest::Fresh) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + for body_stamp in [ + invalid_stamp(0, 5), + invalid_stamp(7, 0), + stamp(7, 4), + stamp(7, 6), + ] { + assert_eq!( + response(body_stamp) + .validate("resume-request", "host-01", current, StreamKind::Control) + .unwrap_err() + .code, + ErrorCode::StaleGeneration + ); + } + + let paged_response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "resume-request".into(), + execution_target_id: "host-01".into(), + connection_stamp: current, + result: Ok(Page { + items: vec![ResumeResponse { + connection_stamp: stamp(7, 4), + disposition: ResumeDisposition::Resumed, + first_available_event_sequence: 42, + }], + next_cursor: None, + }), + }; + assert_eq!( + paged_response + .validate("resume-request", "host-01", current, StreamKind::Bulk) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + } + + #[test] + fn stream_header_rejects_wrong_direction_and_version() { + let mut header = StreamHeader { + protocol_version: PROTOCOL_VERSION, + stream_kind: StreamKind::Control, + direction: PeerDirection::ControllerToHost, + connection_stamp: stamp(1, 1), + }; + assert_eq!( + header + .validate(PeerDirection::HostToController) + .unwrap_err() + .code, + ErrorCode::WrongDirection + ); + header.direction = PeerDirection::HostToController; + header.protocol_version += 1; + assert_eq!( + header + .validate(PeerDirection::HostToController) + .unwrap_err() + .code, + ErrorCode::UnsupportedVersion + ); + + header.protocol_version = PROTOCOL_VERSION; + header.connection_stamp = invalid_stamp(1, 0); + assert_eq!( + header + .validate(PeerDirection::HostToController) + .unwrap_err() + .code, + ErrorCode::StaleGeneration + ); + } + + #[test] + fn page_request_is_bounded() { + assert!(PageRequest::default().validate().is_ok()); + assert_eq!( + PageRequest { + cursor: None, + limit: MAX_PAGE_SIZE + 1, + } + .validate() + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + assert_eq!( + PageRequest { + cursor: Some("x".repeat(MAX_CURSOR_BYTES + 1)), + limit: 1, + } + .validate() + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + } + + fn history_item(id: &str, text: impl Into, merge: &str) -> RemoteAgentTimelineItem { + RemoteAgentTimelineItem { + id: id.to_string(), + item_type: "message".to_string(), + role: Some("assistant".to_string()), + title: None, + text: Some(text.into()), + status: None, + created_ms: 1_700_000_000_000, + merge: merge.to_string(), + } + } + + fn history_record(items: Vec) -> RemoteAgentHistoryRecord { + RemoteAgentHistoryRecord { + record_id: "epoch-record-01".to_string(), + role: "assistant".to_string(), + created_ms: 1_700_000_000_000, + items, + } + } + + #[test] + fn native_history_record_count_is_independent_of_projected_item_count() { + let request = ListAgentHistoryRecordsRequest::new("session-01", None, 1) + .expect("valid history request"); + AgentHistoryPageFrame::Start { record_count: 1 } + .validate_response_to(&request) + .expect("one native row satisfies a one-record request"); + AgentHistoryPageFrame::Record { + index: 0, + record: history_record(vec![ + history_item("message-01", "answer", "replace"), + RemoteAgentTimelineItem { + id: "tool-01".to_string(), + item_type: "tool".to_string(), + role: Some("assistant".to_string()), + title: Some(SAFE_REMOTE_TOOL_TITLE.to_string()), + text: None, + status: Some("completed".to_string()), + created_ms: 1_700_000_000_000, + merge: "replace".to_string(), + }, + ]), + } + .validate_response_to(&request) + .expect("multiple projected cards remain inside one native record"); + + AgentHistoryPageFrame::Record { + index: 0, + record: history_record(Vec::new()), + } + .validate_response_to(&request) + .expect("a hidden native row remains an empty record container"); + } + + #[test] + fn native_history_role_is_bounded_opaque_metadata() { + let mut record = history_record(Vec::new()); + record.role = "tool_result".to_string(); + record + .validate() + .expect("a future native role stays pageable"); + + for invalid in [ + String::new(), + "role\nspoof".to_string(), + "rôle".to_string(), + "x".repeat(MAX_ID_BYTES + 1), + ] { + record.role = invalid; + assert_eq!( + record.validate().expect_err("unsafe role must fail").code, + ErrorCode::InvalidFrame + ); + } + } + + #[test] + fn history_request_defaults_and_bounds_match_goose_record_paging() { + let request: ListAgentHistoryRecordsRequest = serde_json::from_value(serde_json::json!({ + "operation": "list_session_records", + "sessionId": "session-01" + })) + .expect("request uses the record-page default"); + assert_eq!(request.limit, DEFAULT_PAGE_SIZE); + request + .validate() + .expect("default history request is valid"); + + let mut oversized = request.clone(); + oversized.limit = MAX_PAGE_SIZE + 1; + assert_eq!( + oversized.validate().unwrap_err().code, + ErrorCode::InvalidPage + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "operation": "agent_clear_user_data", + "sessionId": "session-01", + "limit": 1 + })) + .is_err() + ); + } + + #[test] + fn shared_history_presentation_cap_rejects_one_oversized_record() { + let half = MAX_HISTORY_RECORD_PRESENTATION_BYTES / 2; + let record = history_record(vec![ + history_item("message-a", "a".repeat(half), "replace"), + history_item("message-b", "b".repeat(half), "replace"), + ]); + assert_eq!( + record.validate().unwrap_err().code, + ErrorCode::HistoryRecordTooLarge + ); + } + + #[test] + fn synchronized_live_snapshot_items_are_absolute_and_cursor_is_camel_case() { + let mut item = history_item("live-01", "streaming", "append"); + assert_eq!( + RemoteAgentLiveSessionSnapshot { + session_id: "session-01".to_string(), + live_items: vec![item.clone()], + } + .validate() + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + item.merge = "replace".to_string(); + RemoteAgentLiveSessionSnapshot { + session_id: "session-01".to_string(), + live_items: vec![item], + } + .validate() + .expect("absolute live item is valid"); + + assert!( + serde_json::from_value::(serde_json::json!({ + "frame": "live_item", + "index": 0, + "item": {} + })) + .is_err() + ); + + let cursor = RemoteLiveEventCursor { + journal_id: "0123456789abcdef0123456789abcdef".to_string(), + sequence: 7, + }; + assert_eq!( + serde_json::to_value(cursor).expect("serialize live cursor"), + serde_json::json!({ + "journalId": "0123456789abcdef0123456789abcdef", + "sequence": 7 + }) + ); + + assert_eq!( + RemoteLiveEventCursor { + journal_id: "0123456789abcdef0123456789abcdef".to_string(), + sequence: MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + 1, + } + .validate() + .unwrap_err() + .code, + ErrorCode::InvalidPage + ); + } + + #[test] + fn paged_timestamps_are_bounded_to_javascript_safe_integers() { + let mut item = history_item("message-01", "answer", "replace"); + item.created_ms = MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + 1; + assert_eq!(item.validate().unwrap_err().code, ErrorCode::InvalidFrame); + + let mut record = history_record(Vec::new()); + record.created_ms = MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER + 1; + assert_eq!(record.validate().unwrap_err().code, ErrorCode::InvalidFrame); + + let mut session = RemoteAgentSessionSummary { + id: "session-01".to_string(), + title: "Task".to_string(), + project_root: "/tmp/maple".to_string(), + created_ms: MAX_JAVASCRIPT_SAFE_INTEGER + 1, + updated_ms: 1, + page_sort_ms: 1, + message_count: 0, + model: None, + mode: "smart_approve".to_string(), + }; + assert_eq!( + session.validate().unwrap_err().code, + ErrorCode::InvalidFrame + ); + session.created_ms = MAX_JAVASCRIPT_SAFE_INTEGER; + session.message_count = MAX_JAVASCRIPT_SAFE_UNSIGNED_INTEGER; + session.validate().expect("maximum JS-safe count is valid"); + session.message_count += 1; + assert_eq!( + session.validate().unwrap_err().code, + ErrorCode::InvalidFrame + ); + } + + #[test] + fn live_lane_unions_route_once_and_resume_is_host_epoch_scoped() { + let cursor = RemoteLiveEventCursor { + journal_id: "0123456789abcdef0123456789abcdef".to_string(), + sequence: 7, + }; + let current = stamp(41, 9); + let resume = ResumeAgentLiveEventsRequest::new(cursor.clone(), 41) + .expect("same-host-epoch resume request"); + resume + .validate_for_connection_stamp(current) + .expect("connection generation may advance inside one host epoch"); + assert_eq!( + resume + .validate_for_connection_stamp(stamp(42, 1)) + .expect_err("host restart must fence the old cursor") + .code, + ErrorCode::StaleGeneration + ); + + let events = RemoteAgentLiveEventsRequest::from(resume); + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "live-resume-01".to_string(), + execution_target_id: "host-01".to_string(), + direction: PeerDirection::ControllerToHost, + connection_stamp: current, + body: events.clone(), + }; + request + .validate( + PeerDirection::ControllerToHost, + "host-01", + current, + StreamKind::Events, + ) + .expect("central Events union validates the exact resume body"); + assert!(matches!( + serde_json::from_value::( + serde_json::to_value(&events).expect("serialize Events union") + ) + .expect("decode Events union"), + RemoteAgentLiveEventsRequest::Resume { .. } + )); + + let activate = RemoteAgentLiveControlRequest::from( + ActivateAgentLiveAttachRequest::new("attach-01").expect("activation request"), + ); + assert!(matches!( + serde_json::from_value::( + serde_json::to_value(&activate).expect("serialize Control union") + ) + .expect("decode Control union"), + RemoteAgentLiveControlRequest::ActivateAttach { .. } + )); + assert_eq!( + CancelAgentLiveResponse { + kind: AgentLiveCancelKind::PendingAttach, + live_id: "attach-01".to_string(), + } + .validate_response_to(&activate) + .expect_err("central dispatcher forbids a mismatched response variant") + .code, + ErrorCode::InvalidFrame + ); + } + + #[test] + fn direct_peer_wire_shapes_decode_only_through_their_exact_tagged_lane() { + let cursor = RemoteLiveEventCursor { + journal_id: "0123456789abcdef0123456789abcdef".to_string(), + sequence: 7, + }; + let cases = [ + serde_json::to_value( + BeginAgentLiveAttachRequest::new("session-01", 25).expect("begin request"), + ) + .expect("serialize begin"), + serde_json::to_value( + ResumeAgentLiveEventsRequest::new(cursor, 41).expect("resume request"), + ) + .expect("serialize resume"), + ]; + assert!(matches!( + serde_json::from_value::(cases[0].clone()) + .expect("direct Begin body decodes through Events union"), + RemoteAgentLiveEventsRequest::BeginAttach { .. } + )); + assert!(matches!( + serde_json::from_value::(cases[1].clone()) + .expect("direct Resume body decodes through Events union"), + RemoteAgentLiveEventsRequest::Resume { .. } + )); + + let activate = serde_json::to_value( + ActivateAgentLiveAttachRequest::new("attach-01").expect("activate request"), + ) + .expect("serialize activate"); + let cancel = serde_json::to_value( + CancelAgentLiveRequest::new(AgentLiveCancelKind::ActiveStream, "live-01") + .expect("cancel request"), + ) + .expect("serialize cancel"); + assert!(matches!( + serde_json::from_value::(activate) + .expect("direct Activate body decodes through peer Control union"), + RemoteAgentControlRequest::ActivateAttach { .. } + )); + assert!(matches!( + serde_json::from_value::(cancel) + .expect("direct Cancel body decodes through peer Control union"), + RemoteAgentControlRequest::Cancel { .. } + )); + assert!(matches!( + serde_json::from_value::( + serde_json::to_value(GetRuntimeStatusRequest::new()) + .expect("serialize runtime status request") + ) + .expect("direct status body decodes through peer Control union"), + RemoteAgentControlRequest::GetRuntimeStatus + )); + + let history = ListAgentHistoryRecordsRequest::new( + "session-01", + Some("history-cursor-01".to_string()), + 25, + ) + .expect("history request"); + assert!(matches!( + serde_json::from_value::( + serde_json::to_value(history).expect("serialize history request") + ) + .expect("direct history body decodes through peer Bulk union"), + RemoteAgentBulkRequest::ListSessionRecords { .. } + )); + let sessions = ListAgentSessionsRequest { + operation: AgentSessionListOperation::ListSessions, + project_root: Some("/tmp/maple".to_string()), + cursor: Some("session-cursor-01".to_string()), + limit: 25, + }; + assert!(matches!( + serde_json::from_value::( + serde_json::to_value(sessions).expect("serialize task-list request") + ) + .expect("direct task-list body decodes through peer Bulk union"), + RemoteAgentBulkRequest::ListSessions { .. } + )); + + for wrong in [ + serde_json::json!({ + "operation": "resume", + "sessionId": "session-01", + "limit": 25 + }), + serde_json::json!({ + "operation": "begin_attach", + "cursor": {"journalId": "0123456789abcdef0123456789abcdef", "sequence": 7}, + "originHostEpoch": 41 + }), + ] { + assert!(serde_json::from_value::(wrong).is_err()); + } + for wrong in [ + serde_json::json!({"operation": "cancel", "attachId": "attach-01"}), + serde_json::json!({ + "operation": "activate_attach", + "kind": "active_stream", + "liveId": "live-01" + }), + ] { + assert!(serde_json::from_value::(wrong).is_err()); + } + } + + #[test] + fn terminal_permission_presentations_are_safe_but_actionable_state_is_not() { + let mut permission = RemoteAgentTimelineItem { + id: "permission-01".to_string(), + item_type: "permission".to_string(), + role: Some("system".to_string()), + title: Some(SAFE_REMOTE_PERMISSION_TITLE.to_string()), + text: None, + status: Some("allow_once".to_string()), + created_ms: 1_700_000_000_000, + merge: "replace".to_string(), + }; + permission + .validate() + .expect("resolved safe permission audit row is displayable"); + RemoteAgentPresentedLiveEvent::TimelineUpsert { + item: permission.clone(), + } + .validate() + .expect("terminal permission upsert remains in the closed live presentation"); + + for actionable in [ + None, + Some("pending"), + Some("running"), + Some("requested"), + Some("requires_approval"), + Some("allow_always"), + Some("future_state"), + ] { + permission.status = actionable.map(str::to_string); + assert_eq!( + permission + .validate() + .expect_err("actionable permission state must stay off the wire") + .code, + ErrorCode::InvalidFrame + ); + } + } + + #[test] + fn serde_rejects_unknown_fields() { + let input = r#"{ + "protocol_version":1, + "request_id":"request-01", + "execution_target_id":"host-01", + "direction":"controller_to_host", + "connection_stamp":{"host_epoch":1,"generation":1}, + "body":{"limit":10}, + "future_privilege":true + }"#; + assert!(serde_json::from_str::>(input).is_err()); + + #[derive(Serialize)] + struct FutureEnvelope<'a> { + protocol_version: u16, + request_id: &'a str, + execution_target_id: &'a str, + direction: PeerDirection, + connection_stamp: ConnectionStamp, + body: PageRequest, + future_privilege: bool, + } + let mut wire = Vec::new(); + ciborium::ser::into_writer( + &FutureEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "request-01", + execution_target_id: "host-01", + direction: PeerDirection::ControllerToHost, + connection_stamp: stamp(1, 1), + body: PageRequest::default(), + future_privilege: true, + }, + &mut wire, + ) + .unwrap(); + assert!( + ciborium::de::from_reader::, _>(wire.as_slice()).is_err() + ); + } + + #[test] + fn frame_limit_is_enforced_before_allocation() { + assert!(validate_frame_len(MAX_FRAME_BYTES as usize).is_ok()); + assert_eq!( + validate_frame_len(MAX_FRAME_BYTES as usize + 1) + .unwrap_err() + .code, + ErrorCode::FrameTooLarge + ); + } +} diff --git a/frontend/src-tauri/src/remote_transport.rs b/frontend/src-tauri/src/remote_transport.rs new file mode 100644 index 000000000..04042bb9e --- /dev/null +++ b/frontend/src-tauri/src/remote_transport.rs @@ -0,0 +1,9965 @@ +//! Iroh transport harness for the Maple-owned protocol. +//! +//! Address discovery is intentionally absent. A caller supplies the exact, +//! cached [`iroh::EndpointAddr`] obtained through Maple's authenticated pairing +//! and endpoint-refresh control plane. The normal reconnect path never waits on +//! that control plane. +//! +//! The POC pins Iroh with only `tls-ring`; its portmapper and fast Apple +//! datapath features are deliberately compiled out. Direct-path success, +//! battery/wake behavior, App Store/private-API policy, and whether either +//! feature should be enabled remain explicit device benchmarks before mobile +//! runtime enablement. +#![allow( + dead_code, + reason = "bounded foundation is wired in later vertical slices" +)] + +use std::{ + collections::{HashMap, HashSet, VecDeque}, + future::Future, + io::{self, Cursor, Write}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, RwLock, + }, + time::Duration, +}; + +use futures_util::StreamExt; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::{oneshot, Notify, OwnedSemaphorePermit, Semaphore}; + +use crate::{ + durable_host_epoch::reserve_next_host_epoch, + remote_protocol::{ + validate_frame_len, ConnectionStamp, ErrorCode, PeerDirection, ProtocolError, RequestBody, + RequestEnvelope, ResponseBody, ResponseEnvelope, StreamHeader, StreamKind, ALPN, + MAX_FRAME_BYTES, PROTOCOL_VERSION, + }, + secure_storage::{platform_store, DeviceIdentity, DeviceSecretSlot, DeviceSecretStore}, +}; + +const MAX_CACHED_ADDRESSES: usize = 16; +const MAX_CACHED_IP_ADDRESSES: usize = 8; +const MAX_CACHED_RELAY_ADDRESSES: usize = 4; +const MAX_RELAY_URL_BYTES: usize = 512; +const MAX_CONFIGURED_RELAYS: usize = 8; +const MAX_AUTHORIZED_PEERS_PER_DIRECTION: usize = 64; +const MAX_BULK_STREAM_TASKS: usize = 6; +const MAX_EVENT_STREAM_TASKS: usize = 2; +const MAX_CONTROL_STREAM_TASKS: usize = 2; +const MAX_APPLICATION_STREAM_TASKS: usize = + MAX_BULK_STREAM_TASKS + MAX_EVENT_STREAM_TASKS + MAX_CONTROL_STREAM_TASKS; +const MAX_INCOMING_BI_STREAMS: u32 = MAX_APPLICATION_STREAM_TASKS as u32; +const MAX_PENDING_HANDSHAKES: usize = 8; +const MAX_ACCEPTED_CONNECTION_QUEUE: usize = 16; +const MAX_ACTIVE_CONNECTIONS_PER_PEER_SIDE: usize = 4; +const MAX_ACTIVE_CONNECTIONS_GLOBAL: usize = 64; +const STREAM_RECEIVE_WINDOW_BYTES: u32 = 256 * 1024; +const CONNECTION_RECEIVE_WINDOW_BYTES: u32 = 4 * 1024 * 1024; +const CONNECTION_SEND_WINDOW_BYTES: u64 = 4 * 1024 * 1024; +const MAX_POLICY_DEADLINE: Duration = Duration::from_secs(60); +const BULK_STREAMING_OPERATION_DEADLINE: Duration = Duration::from_secs(60); +const EVENT_FRAME_WRITE_DEADLINE: Duration = Duration::from_secs(10); +const MAX_STREAM_HEADER_DEADLINE: Duration = Duration::from_secs(1); +const MAX_PREPARED_STREAM_ERRORS: usize = 2; +const MAX_CBOR_RECURSION: usize = 32; +const MAX_CBOR_CONTAINER_ITEMS: u64 = 256; + +#[derive(Clone)] +pub struct RelayPolicy { + mode: iroh::RelayMode, + allowed_relays: HashSet, +} + +impl std::fmt::Debug for RelayPolicy { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RelayPolicy") + .field("relay_count", &self.allowed_relays.len()) + .finish_non_exhaustive() + } +} + +impl RelayPolicy { + pub fn disabled() -> Self { + Self { + mode: iroh::RelayMode::Disabled, + allowed_relays: HashSet::new(), + } + } + + /// Production relay configuration is always an explicit Maple allowlist. + /// Iroh's mutable Default/Staging maps are deliberately unavailable here. + pub fn custom(relay_map: iroh::RelayMap) -> Result { + let urls = relay_map.urls::>(); + let configs = relay_map.relays::>(); + validate_relay_urls(&urls, false)?; + let config_urls = configs + .iter() + .map(|config| config.url.clone()) + .collect::>(); + let key_urls = urls.iter().cloned().collect::>(); + if configs.len() != urls.len() + || config_urls != key_urls + || configs.iter().any(|config| config.auth_token.is_some()) + { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "relay configuration does not match Maple's allowlist policy", + false, + )); + } + // RelayMap::Clone aliases its mutable backing map. Copy each config by + // value so a caller retaining the source map cannot mutate the policy + // after validation. Auth tokens are intentionally unsupported in this + // POC, avoiding an unbounded header-bearing secret field. + let owned_map: iroh::RelayMap = configs + .iter() + .map(|config| config.as_ref().clone()) + .collect(); + Ok(Self { + mode: iroh::RelayMode::Custom(owned_map), + allowed_relays: urls.into_iter().collect(), + }) + } + + #[cfg(test)] + fn ignored_public_smoke() -> Result { + let mode = iroh::RelayMode::Default; + let urls = mode.relay_map().urls::>(); + validate_relay_urls(&urls, false)?; + Ok(Self { + mode, + allowed_relays: urls.into_iter().collect(), + }) + } + + fn mode(&self) -> iroh::RelayMode { + self.mode.clone() + } + + fn validate_endpoint_addr(&self, addr: &iroh::EndpointAddr) -> Result<(), ProtocolError> { + if addr.addrs.is_empty() || addr.addrs.len() > MAX_CACHED_ADDRESSES { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "cached endpoint address count is outside Maple bounds", + false, + )); + } + let mut ip_count = 0; + let mut relay_count = 0; + for transport in &addr.addrs { + match transport { + iroh::TransportAddr::Ip(_) => { + ip_count += 1; + if ip_count > MAX_CACHED_IP_ADDRESSES { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "cached endpoint has too many IP addresses", + false, + )); + } + } + iroh::TransportAddr::Relay(url) => { + relay_count += 1; + if relay_count > MAX_CACHED_RELAY_ADDRESSES + || url.as_str().len() > MAX_RELAY_URL_BYTES + || !self.allowed_relays.contains(url) + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "cached endpoint contains a relay outside Maple's allowlist", + false, + )); + } + } + iroh::TransportAddr::Custom(_) => { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "custom endpoint transports are not enabled", + false, + )); + } + _ => { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "unsupported endpoint transport", + false, + )); + } + } + } + Ok(()) + } +} + +fn validate_relay_urls(urls: &[iroh::RelayUrl], allow_empty: bool) -> Result<(), ProtocolError> { + if (!allow_empty && urls.is_empty()) || urls.len() > MAX_CONFIGURED_RELAYS { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "relay allowlist size is outside Maple bounds", + false, + )); + } + if urls.iter().any(|url| { + url.as_str().len() > MAX_RELAY_URL_BYTES + || url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + }) { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "relay allowlist contains an unsupported URL", + false, + )); + } + Ok(()) +} + +#[derive(Clone)] +pub struct CachedEndpointAddr { + addr: iroh::EndpointAddr, +} + +impl std::fmt::Debug for CachedEndpointAddr { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CachedEndpointAddr") + .field("endpoint_id", &self.addr.id) + .field("address_count", &self.addr.addrs.len()) + .finish() + } +} + +impl CachedEndpointAddr { + pub fn new( + addr: iroh::EndpointAddr, + relay_policy: &RelayPolicy, + ) -> Result { + relay_policy.validate_endpoint_addr(&addr)?; + Ok(Self { addr }) + } + + pub fn endpoint_id(&self) -> iroh::EndpointId { + self.addr.id + } + + pub fn as_iroh(&self) -> &iroh::EndpointAddr { + &self.addr + } +} + +#[derive(Clone)] +pub struct ConnectedPeer { + connection: iroh::endpoint::Connection, + connection_stamp: ConnectionStamp, + pairing_fence: PairingFence, + execution_target_id: Arc, + outbound_direction: PeerDirection, + frame_deadline: Duration, + outbound_requests: Arc, + incoming_streams: Arc, +} + +#[derive(Debug)] +struct LaneSemaphores { + control: Arc, + events: Arc, + bulk: Arc, +} + +struct IncomingStreamDispatcher { + queue: Arc, + shutdown: Mutex>>, +} + +#[derive(Debug, Default)] +struct PreparedStreamQueue { + state: Mutex, + ready: Notify, +} + +#[derive(Debug, Default)] +struct PreparedStreamQueueState { + control: VecDeque, + events: VecDeque, + bulk: VecDeque, + errors: VecDeque, + closed: bool, +} + +impl PreparedStreamQueueState { + fn len(&self) -> usize { + self.control.len() + self.events.len() + self.bulk.len() + self.errors.len() + } +} + +impl PreparedStreamQueue { + fn publish(&self, result: Result) { + let Ok(mut state) = self.state.lock() else { + return; + }; + if state.closed { + return; + } + match result { + Ok(stream) => { + let kind = stream.header.stream_kind; + if state.len() >= MAX_APPLICATION_STREAM_TASKS { + let evicted = state.errors.pop_back().is_some() + || match kind { + StreamKind::Control => state + .bulk + .pop_back() + .or_else(|| state.events.pop_back()) + .is_some(), + StreamKind::Events => state.bulk.pop_back().is_some(), + StreamKind::Bulk => false, + }; + if !evicted { + return; + } + } + match kind { + StreamKind::Control => state.control.push_back(stream), + StreamKind::Events => state.events.push_back(stream), + StreamKind::Bulk => state.bulk.push_back(stream), + } + } + Err(error) => { + if state.errors.len() >= MAX_PREPARED_STREAM_ERRORS + || state.len() >= MAX_APPLICATION_STREAM_TASKS + { + return; + } + state.errors.push_back(error); + } + } + drop(state); + self.ready.notify_one(); + } + + async fn recv(&self, deadline: tokio::time::Instant) -> Result { + loop { + let notified = self.ready.notified(); + { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + if let Some(stream) = state + .control + .pop_front() + .or_else(|| state.events.pop_front()) + .or_else(|| state.bulk.pop_front()) + { + return Ok(stream); + } + if let Some(error) = state.errors.pop_front() { + return Err(error); + } + if state.closed { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple stream dispatcher is closed", + true, + )); + } + } + tokio::time::timeout_at(deadline, notified) + .await + .map_err(|_| operation_timeout("Maple stream accept deadline elapsed"))?; + } + } + + fn close(&self) { + if let Ok(mut state) = self.state.lock() { + state.closed = true; + state.control.clear(); + state.events.clear(); + state.bulk.clear(); + state.errors.clear(); + } + self.ready.notify_waiters(); + } +} + +impl Drop for IncomingStreamDispatcher { + fn drop(&mut self) { + if let Ok(shutdown) = self.shutdown.get_mut() { + if let Some(shutdown) = shutdown.take() { + let _ = shutdown.send(()); + } + } + } +} + +impl LaneSemaphores { + fn new() -> Self { + Self { + control: Arc::new(Semaphore::new(MAX_CONTROL_STREAM_TASKS)), + events: Arc::new(Semaphore::new(MAX_EVENT_STREAM_TASKS)), + bulk: Arc::new(Semaphore::new(MAX_BULK_STREAM_TASKS)), + } + } + + fn for_kind(&self, kind: StreamKind) -> Arc { + match kind { + StreamKind::Control => self.control.clone(), + StreamKind::Events => self.events.clone(), + StreamKind::Bulk => self.bulk.clone(), + } + } +} + +impl std::fmt::Debug for ConnectedPeer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ConnectedPeer") + .field("remote_id", &self.remote_id()) + .field("connection_stamp", &self.connection_stamp) + .field("pairing_fence", &self.pairing_fence) + .field("execution_target_id", &self.execution_target_id) + .field("outbound_direction", &self.outbound_direction) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ConnectionPolicy { + connect_deadline: Duration, + handshake_deadline: Duration, + frame_deadline: Duration, +} + +impl ConnectionPolicy { + pub fn new(connect_deadline: Duration) -> Result { + if connect_deadline.is_zero() || connect_deadline > MAX_POLICY_DEADLINE { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "connection deadline is outside Maple bounds", + false, + )); + } + Ok(Self { + connect_deadline, + handshake_deadline: connect_deadline, + frame_deadline: connect_deadline, + }) + } + + pub fn with_handshake_deadline( + mut self, + handshake_deadline: Duration, + ) -> Result { + if handshake_deadline.is_zero() || handshake_deadline > MAX_POLICY_DEADLINE { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "handshake deadline is outside Maple bounds", + false, + )); + } + self.handshake_deadline = handshake_deadline; + Ok(self) + } + + pub fn with_frame_deadline(mut self, frame_deadline: Duration) -> Result { + if frame_deadline.is_zero() || frame_deadline > MAX_POLICY_DEADLINE { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "frame deadline is outside Maple bounds", + false, + )); + } + self.frame_deadline = frame_deadline; + Ok(self) + } +} + +/// Test-only raw host epoch. Production code cannot construct a host clock +/// from an integer; it must reserve one durably through +/// [`HostConnectionClock::reserve_for_runtime`]. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HostEpoch(u64); + +#[cfg(test)] +impl HostEpoch { + pub fn new(value: u64) -> Result { + if value == 0 { + Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "host epoch must be positive", + false, + )) + } else { + Ok(Self(value)) + } + } + + fn get(self) -> u64 { + self.0 + } +} + +/// Shared host-side allocator for connection stamps. Rebuilding an Iroh +/// endpoint during one host runtime must reuse the same clock so generations +/// never restart. A process restart constructs a new clock only after durably +/// reserving the next installation epoch through secure storage. +#[derive(Debug, Clone)] +pub struct HostConnectionClock { + host_epoch: u64, + next_generation: Arc, +} + +impl HostConnectionClock { + fn reserve_for_runtime( + store: &dyn DeviceSecretStore, + identity: &DeviceIdentity, + ) -> Result { + let reservation = reserve_next_host_epoch(store, identity.host_epoch_storage_key()) + .map_err(|error| { + ProtocolError::new( + ErrorCode::SecureStorageUnavailable, + format!("durable host epoch reservation failed: {error}"), + false, + ) + })?; + Ok(Self { + host_epoch: reservation.get(), + next_generation: Arc::new(AtomicU64::new(1)), + }) + } + + #[cfg(test)] + pub fn new(host_epoch: HostEpoch) -> Self { + Self { + host_epoch: host_epoch.get(), + next_generation: Arc::new(AtomicU64::new(1)), + } + } + + #[cfg(test)] + pub fn with_next_generation( + host_epoch: HostEpoch, + next_generation: u64, + ) -> Result { + if next_generation == 0 { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "next connection generation must be positive", + false, + )); + } + Ok(Self { + host_epoch: host_epoch.get(), + next_generation: Arc::new(AtomicU64::new(next_generation)), + }) + } + + fn allocate(&self) -> Result { + let generation = self + .next_generation + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| { + value.checked_add(1) + }) + .map_err(|_| { + ProtocolError::new( + ErrorCode::Internal, + "host connection generation exhausted", + false, + ) + })?; + ConnectionStamp::new(self.host_epoch, generation) + } +} + +/// Opaque native authority to bind one installation identity during one host +/// runtime. Identity loading and epoch reservation share the same secure-store +/// object and slot in [`Self::load_and_reserve`]; callers cannot separate a +/// clock from identity A and bind it with identity B. Renderer values are not +/// part of this construction path. +pub struct DurableHostRuntime { + identity: DeviceIdentity, + host_clock: HostConnectionClock, +} + +impl std::fmt::Debug for DurableHostRuntime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DurableHostRuntime") + .field("public_id", &self.identity.public_id()) + .finish_non_exhaustive() + } +} + +impl DurableHostRuntime { + /// Load/create the native installation identity and durably advance its + /// host epoch before returning any capability that can open a listener. + /// Unsupported or corrupt secure storage fails closed before endpoint bind. + pub(crate) fn load_from_platform(slot: &DeviceSecretSlot) -> Result { + let store = platform_store().map_err(host_secure_storage_error)?; + Self::load_and_reserve(store.as_ref(), slot) + } + + fn load_and_reserve( + store: &dyn DeviceSecretStore, + slot: &DeviceSecretSlot, + ) -> Result { + let identity = + DeviceIdentity::load_or_create(store, slot).map_err(host_secure_storage_error)?; + let host_clock = HostConnectionClock::reserve_for_runtime(store, &identity)?; + Ok(Self { + identity, + host_clock, + }) + } + + #[cfg(test)] + fn load_and_reserve_for_test( + store: &dyn DeviceSecretStore, + slot: &DeviceSecretSlot, + ) -> Result { + Self::load_and_reserve(store, slot) + } + + fn identity(&self) -> &DeviceIdentity { + &self.identity + } + + fn host_clock(&self) -> HostConnectionClock { + self.host_clock.clone() + } +} + +fn host_secure_storage_error(error: crate::secure_storage::SecretStoreError) -> ProtocolError { + ProtocolError::new( + ErrorCode::SecureStorageUnavailable, + format!("durable host runtime storage failed: {error}"), + false, + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PairingIncarnation(u64); + +impl PairingIncarnation { + pub fn new(value: u64) -> Result { + if value == 0 { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "pairing incarnation must be positive", + false, + )); + } + Ok(Self(value)) + } + + pub fn get(self) -> u64 { + self.0 + } +} + +/// Wire-visible authorization lineage for one explicit, directed pairing. +/// +/// The incarnation is allocated once and never reused for that directed pair. +/// Endpoint identity is authenticated by Iroh and bound by each endpoint's +/// local authorization map. Installation-local account epochs and snapshot +/// revisions never cross the wire: independent devices cannot coordinate +/// those anti-replay counters. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PairingFence { + pairing_incarnation: PairingIncarnation, +} + +impl PairingFence { + pub fn new(pairing_incarnation: PairingIncarnation) -> Result { + let fence = Self { + pairing_incarnation, + }; + fence.validate()?; + Ok(fence) + } + + pub fn pairing_incarnation(self) -> PairingIncarnation { + self.pairing_incarnation + } + + fn validate(self) -> Result<(), ProtocolError> { + if self.pairing_incarnation.get() == 0 { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "pairing fence is invalid", + false, + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizationSnapshot { + /// Durable, monotonically increasing account/authentication context. An + /// account switch must advance this even when device IDs overlap. + pub account_epoch: u64, + /// Durable, monotonically increasing revision within `account_epoch`. + /// Every pairing/revocation snapshot advances it, preventing replay. + pub snapshot_revision: u64, + /// Direction-specific pairing incarnation. Retaining the same entry over + /// an unrelated snapshot revision preserves connection lineage; removing + /// and later re-adding an endpoint must allocate a new incarnation. + pub incoming_controllers: HashMap, + pub outgoing_execution_targets: HashMap, +} + +impl AuthorizationSnapshot { + pub fn new(account_epoch: u64, snapshot_revision: u64) -> Result { + if account_epoch == 0 || snapshot_revision == 0 { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "authorization snapshot version must be positive", + false, + )); + } + Ok(Self { + account_epoch, + snapshot_revision, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::new(), + }) + } +} + +fn authorization_snapshot_digest( + account_epoch: u64, + snapshot_revision: u64, + incoming_controllers: &HashMap, + outgoing_execution_targets: &HashMap, +) -> [u8; 32] { + fn hash_direction( + hasher: &mut Sha256, + direction: u8, + peers: &HashMap, + ) { + let mut entries = peers.iter().collect::>(); + entries.sort_unstable_by_key(|(endpoint, _)| **endpoint); + hasher.update([direction]); + hasher.update((entries.len() as u64).to_be_bytes()); + for (endpoint, incarnation) in entries { + hasher.update(endpoint.as_bytes()); + hasher.update(incarnation.get().to_be_bytes()); + } + } + + let mut hasher = Sha256::new(); + hasher.update(b"maple-authorization-snapshot-v1\0"); + hasher.update(account_epoch.to_be_bytes()); + hasher.update(snapshot_revision.to_be_bytes()); + hash_direction(&mut hasher, 0, incoming_controllers); + hash_direction(&mut hasher, 1, outgoing_execution_targets); + hasher.finalize().into() +} + +fn digest_authorization_snapshot(snapshot: &AuthorizationSnapshot) -> [u8; 32] { + authorization_snapshot_digest( + snapshot.account_epoch, + snapshot.snapshot_revision, + &snapshot.incoming_controllers, + &snapshot.outgoing_execution_targets, + ) +} + +/// Opaque proof of the authorization snapshot currently installed in one +/// endpoint admission table. +/// +/// Callers cannot construct this from wire or pairing-status fields. The only +/// production constructor is the endpoint's current-peer verifier below, +/// which captures the version and digest under the admission lock while also +/// proving the exact directed pairing grant remains installed. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct InstalledAuthorizationContext { + account_epoch: u64, + snapshot_revision: u64, + snapshot_digest: [u8; 32], +} + +impl InstalledAuthorizationContext { + pub(crate) const fn account_epoch(&self) -> u64 { + self.account_epoch + } + + pub(crate) const fn snapshot_revision(&self) -> u64 { + self.snapshot_revision + } + + pub(crate) const fn snapshot_digest(&self) -> [u8; 32] { + self.snapshot_digest + } + + #[cfg(test)] + pub(crate) const fn for_test( + account_epoch: u64, + snapshot_revision: u64, + snapshot_digest: [u8; 32], + ) -> Self { + Self { + account_epoch, + snapshot_revision, + snapshot_digest, + } + } +} + +/// Native-only proof that one complete authorization snapshot atomically +/// replaced another in this endpoint admission table. +/// +/// This is deliberately non-Clone and has no public constructor. It is minted +/// only after admission has installed the new snapshot and collected every +/// removed peer under the same write lock, allowing downstream live-state +/// revocation even when the last admitted controller no longer has a current +/// peer capability. +#[derive(Debug)] +pub struct AuthorizationTransitionReceipt { + authorization_domain: InstalledAuthorizationDomain, + previous: Option, + current: InstalledAuthorizationContext, + removed_incoming_controllers: Vec, + account_epoch_changed: bool, +} + +impl AuthorizationTransitionReceipt { + pub(crate) fn authorization_domain(&self) -> &InstalledAuthorizationDomain { + &self.authorization_domain + } + + pub(crate) fn previous(&self) -> Option<&InstalledAuthorizationContext> { + self.previous.as_ref() + } + + pub(crate) fn current(&self) -> &InstalledAuthorizationContext { + &self.current + } + + pub(crate) fn removed_incoming_controllers(&self) -> &[iroh::EndpointId] { + &self.removed_incoming_controllers + } + + pub(crate) const fn account_epoch_changed(&self) -> bool { + self.account_epoch_changed + } + + pub(crate) fn into_parts( + self, + ) -> ( + InstalledAuthorizationDomain, + Option, + InstalledAuthorizationContext, + Vec, + bool, + ) { + ( + self.authorization_domain, + self.previous, + self.current, + self.removed_incoming_controllers, + self.account_epoch_changed, + ) + } + + #[cfg(test)] + pub(crate) fn for_test( + previous: Option, + current: InstalledAuthorizationContext, + removed_incoming_controllers: Vec, + account_epoch_changed: bool, + ) -> Self { + Self::for_test_in_domain( + InstalledAuthorizationDomain(PeerAdmission::default()), + previous, + current, + removed_incoming_controllers, + account_epoch_changed, + ) + } + + #[cfg(test)] + pub(crate) fn for_test_in_domain( + authorization_domain: InstalledAuthorizationDomain, + previous: Option, + current: InstalledAuthorizationContext, + removed_incoming_controllers: Vec, + account_epoch_changed: bool, + ) -> Self { + Self { + authorization_domain, + previous, + current, + removed_incoming_controllers, + account_epoch_changed, + } + } +} + +/// Opaque process-local identity of one endpoint admission/revocation domain. +/// Equal scalar grants from another endpoint runtime are never interchangeable. +#[derive(Debug, Clone)] +pub(crate) struct InstalledAuthorizationDomain(PeerAdmission); + +impl InstalledAuthorizationDomain { + pub(crate) fn same_instance(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0.state, &other.0.state) + } + + #[cfg(test)] + pub(crate) fn for_test() -> Self { + Self(PeerAdmission::default()) + } +} + +/// Exact, currently installed authority for one authenticated controller. +/// +/// The product execution target is the host registration ID configured on the +/// endpoint. Endpoint identity is intentionally retained only for later +/// revalidation and is never substituted for that product ID. +#[derive(Debug, Clone)] +pub(crate) struct VerifiedIncomingPeerAuthorization { + admission: PeerAdmission, + authorization: InstalledAuthorizationContext, + controller_endpoint: iroh::EndpointId, + execution_target_id: Arc, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, +} + +impl VerifiedIncomingPeerAuthorization { + pub(crate) fn revalidate_current(&self) -> Result<(), ProtocolError> { + self.with_current(|| ()) + } + + /// Execute one non-blocking authority transition while retaining the + /// admission read guard which proves this exact controller, pairing, and + /// connection stamp are still current. Callers must not await inside the + /// closure. + pub(crate) fn with_current( + &self, + operation: impl FnOnce() -> R, + ) -> Result { + let state = self + .admission + .state + .read() + .map_err(|_| internal_state_error())?; + let current = PeerAdmission::current_incoming_authorization_in_state( + &state, + &self.controller_endpoint, + self.connection_stamp, + self.pairing_fence, + )?; + if current != self.authorization { + return Err(ProtocolError::new( + ErrorCode::Revoked, + "remote controller authorization changed after verification", + false, + )); + } + Ok(operation()) + } + + pub(crate) fn authorization(&self) -> &InstalledAuthorizationContext { + &self.authorization + } + + pub(crate) fn controller_endpoint(&self) -> iroh::EndpointId { + self.controller_endpoint + } + + pub(crate) fn execution_target_id(&self) -> &str { + &self.execution_target_id + } + + pub(crate) const fn pairing_fence(&self) -> PairingFence { + self.pairing_fence + } + + pub(crate) const fn connection_stamp(&self) -> ConnectionStamp { + self.connection_stamp + } + + /// Native identity of the endpoint admission table which minted this + /// capability. Scalar target/pair/stamp fields are not sufficient: two + /// endpoint runtimes can legitimately contain identical values while + /// representing different revocation domains. + pub(crate) fn same_admission_instance(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.admission.state, &other.admission.state) + } + + pub(crate) fn authorization_domain(&self) -> InstalledAuthorizationDomain { + InstalledAuthorizationDomain(self.admission.clone()) + } + + #[cfg(test)] + pub(crate) fn for_admission_identity_test( + authorization: InstalledAuthorizationContext, + controller_endpoint: iroh::EndpointId, + execution_target_id: Arc, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + ) -> Self { + Self { + admission: PeerAdmission::default(), + authorization, + controller_endpoint, + execution_target_id, + pairing_fence, + connection_stamp, + } + } +} + +/// In-memory host lineage retained across an Iroh endpoint rebuild. +/// +/// This snapshot deliberately has no serialization implementation. The +/// surrounding runtime owns persistence policy in a later slice; this seam +/// only makes the security binding and quiescent handoff explicit. +#[derive(Debug, PartialEq, Eq)] +pub struct EndpointLineageSnapshot { + local_endpoint: iroh::EndpointId, + execution_target_id: Arc, + account_epoch: u64, + snapshot_revision: u64, + authorization_digest: [u8; 32], + incoming_controllers: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IncomingControllerLineage { + pairing_incarnation: PairingIncarnation, + last_committed: Option, + finalized_transition: Option, +} + +impl EndpointLineageSnapshot { + pub fn local_endpoint(&self) -> iroh::EndpointId { + self.local_endpoint + } + + pub fn execution_target_id(&self) -> &str { + &self.execution_target_id + } + + pub fn account_epoch(&self) -> u64 { + self.account_epoch + } + + pub fn authorization_revision_floor(&self) -> u64 { + self.snapshot_revision + } + + pub fn incoming_controller_count(&self) -> usize { + self.incoming_controllers.len() + } +} + +#[derive(Debug, Clone, Default)] +struct PeerAdmission { + state: Arc>, +} + +#[derive(Debug, Default)] +struct AdmissionState { + account_epoch: Option, + snapshot_revision: u64, + /// Sign-out tombstone. Once set, the current account epoch can never be + /// re-enabled; a freshly authenticated account context must advance it. + authorization_disabled: bool, + /// Process-monotonic race token. It is never keyed by peer, so repeated + /// pair/revoke churn cannot accumulate tombstones. + admission_revision: u64, + incoming_controllers: DirectionalAdmission, + outgoing_execution_targets: DirectionalAdmission, +} + +#[derive(Debug, Default)] +struct DirectionalAdmission { + allowed: HashMap, + active: HashMap>, + /// Last generation which completed Maple's application-level handover. + /// + /// This is protocol lineage, not a transport-liveness cache. A routine + /// path loss may remove `current` before the other endpoint observes the + /// close, but both sides must continue to name the same predecessor while + /// racing its replacement. + committed_lineage: HashMap, + /// Exact most recently finalized A -> B transition. This bounded record + /// lets a controller recover when the Finalized frame was lost, without + /// replaying any application command or guessing from handle liveness. + finalized_transitions: HashMap, + current: HashMap, + /// At most one incoming generation per controller may be in the + /// commit/readiness window. The stable connection ID prevents a later + /// provisional handshake from nesting over (and losing) the fallback. + activating: HashMap, + /// During the final Ready write, both the candidate and the prior current + /// generation remain router-valid. EOF confirms activation to the client; + /// failure restores this fallback without breaking the live generation. + activating_previous: + HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IncomingActivation { + stamp: ConnectionStamp, + candidate_id: usize, + pending: PendingCommit, +} + +struct IncomingCommit { + peer: iroh::EndpointId, + stamp: ConnectionStamp, + candidate_id: usize, + candidate: iroh::endpoint::WeakConnectionHandle, + previous_stamp: Option, + /// Strong host-side overlap handle. Admission ordinarily stores weak + /// handles so close-on-drop works, but a handover must guarantee A remains + /// available until CommitObserved is validated. + previous_connection: Option, + pending: PendingCommit, +} + +impl AdmissionState { + fn directional(&self, side: iroh::endpoint::Side) -> &DirectionalAdmission { + match side { + iroh::endpoint::Side::Client => &self.outgoing_execution_targets, + iroh::endpoint::Side::Server => &self.incoming_controllers, + } + } + + fn directional_mut(&mut self, side: iroh::endpoint::Side) -> &mut DirectionalAdmission { + match side { + iroh::endpoint::Side::Client => &mut self.outgoing_execution_targets, + iroh::endpoint::Side::Server => &mut self.incoming_controllers, + } + } + + fn prune_and_count_active(&mut self) -> usize { + let mut count = 0; + for directional in [ + &mut self.incoming_controllers, + &mut self.outgoing_execution_targets, + ] { + directional.active.retain(|_, handles| { + handles.retain(weak_connection_is_open); + count += handles.len(); + !handles.is_empty() + }); + directional + .current + .retain(|_, (_, handle)| weak_connection_is_open(handle)); + directional + .activating_previous + .retain(|_, (_, handle)| weak_connection_is_open(handle)); + } + count + } +} + +impl PeerAdmission { + fn allow( + &self, + side: iroh::endpoint::Side, + peer: iroh::EndpointId, + ) -> Result<(), ProtocolError> { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + if state.account_epoch.is_some() { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "versioned authorization snapshots own the active account context", + false, + )); + } + if state.authorization_disabled { + return Err(ProtocolError::new( + ErrorCode::Revoked, + "authorization is disabled until a newer account epoch", + false, + )); + } + if state.directional(side).allowed.contains_key(&peer) { + return Ok(()); + } + if state.directional(side).allowed.len() >= MAX_AUTHORIZED_PEERS_PER_DIRECTION { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "authorized peer limit reached", + false, + )); + } + bump_admission_revision(&mut state)?; + state + .directional_mut(side) + .allowed + .insert(peer, PairingIncarnation(1)); + Ok(()) + } + + fn revoke( + &self, + side: iroh::endpoint::Side, + peer: &iroh::EndpointId, + ) -> Result { + let (was_allowed, active) = { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + if state.account_epoch.is_some() { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "versioned authorization snapshots own the active account context", + false, + )); + } + let was_allowed = state.directional(side).allowed.contains_key(peer); + if was_allowed { + bump_admission_revision(&mut state)?; + } + let directional = state.directional_mut(side); + directional.allowed.remove(peer); + directional.committed_lineage.remove(peer); + directional.finalized_transitions.remove(peer); + directional.current.remove(peer); + directional.activating.remove(peer); + directional.activating_previous.remove(peer); + let active = directional.active.remove(peer).unwrap_or_default(); + (was_allowed, active) + }; + for weak in active { + if let Some(connection) = weak.upgrade() { + connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_52), + b"peer authorization revoked", + ); + } + } + Ok(was_allowed) + } + + fn is_allowed(&self, side: iroh::endpoint::Side, peer: &iroh::EndpointId) -> bool { + self.state + .read() + .map(|state| state.directional(side).allowed.contains_key(peer)) + .unwrap_or(false) + } + + fn pairing_fence( + &self, + side: iroh::endpoint::Side, + peer: &iroh::EndpointId, + ) -> Result { + let state = self.state.read().map_err(|_| internal_state_error())?; + let pairing_incarnation = state + .directional(side) + .allowed + .get(peer) + .copied() + .ok_or_else(|| { + ProtocolError::new( + ErrorCode::Unauthorized, + "paired endpoint is not admitted", + false, + ) + })?; + let fence = PairingFence::new(pairing_incarnation)?; + fence.validate()?; + Ok(fence) + } + + /// Registration and revocation are linearized under the same state lock. + /// If revoke wins, this closes instead of registering the racing handshake. + fn register( + &self, + connection: &iroh::endpoint::Connection, + ) -> Result { + let peer = connection.remote_id(); + let side = connection.side(); + let pairing_fence = { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + let pairing_incarnation = state + .directional(side) + .allowed + .get(&peer) + .copied() + .ok_or_else(|| { + connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_52), + b"peer authorization unavailable", + ); + ProtocolError::new( + ErrorCode::Revoked, + "peer authorization is unavailable", + false, + ) + })?; + if state.authorization_disabled { + connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_52), + b"peer authorization unavailable", + ); + return Err(ProtocolError::new( + ErrorCode::Revoked, + "peer authorization is unavailable", + false, + )); + } + if state.prune_and_count_active() >= MAX_ACTIVE_CONNECTIONS_GLOBAL { + connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_43), + b"connection admission capacity reached", + ); + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple connection admission is at capacity", + true, + )); + } + let fence = PairingFence::new(pairing_incarnation)?; + let directional = state.directional_mut(side); + let active = directional.active.entry(peer).or_default(); + if active.len() >= MAX_ACTIVE_CONNECTIONS_PER_PEER_SIDE { + connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_43), + b"reconnect race limit reached", + ); + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple reconnect race is at capacity", + true, + )); + } + active.push_back(connection.weak_handle()); + fence + }; + Ok(pairing_fence) + } + + /// Atomically stage a bootstrap generation and reserve its keyed router + /// slot. The host's current generation and the queue's prior entry remain + /// unchanged/hidden until the controller proves installation with the + /// final CommitObserved frame. + fn commit_and_publish_incoming( + &self, + connection: &iroh::endpoint::Connection, + stamp: ConnectionStamp, + expected_previous: Option, + expected_pairing_fence: PairingFence, + reservation: AcceptedPeerReservation, + peer: PendingConnectedPeer, + pending: PendingCommit, + ) -> Result { + debug_assert_eq!(connection.side(), iroh::endpoint::Side::Server); + let peer_id = connection.remote_id(); + let previous_connection = { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + let current_fence = state + .incoming_controllers + .allowed + .get(&peer_id) + .copied() + .and_then(|incarnation| PairingFence::new(incarnation).ok()); + if state.authorization_disabled || current_fence != Some(expected_pairing_fence) { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "bootstrap authorization changed before activation", + false, + )); + } + state.prune_and_count_active(); + let incoming = state.directional_mut(iroh::endpoint::Side::Server); + if !incoming.allowed.contains_key(&peer_id) { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "controller authorization was revoked before activation", + false, + )); + } + if incoming.activating.contains_key(&peer_id) { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "controller already has a generation awaiting readiness", + true, + )); + } + let committed_previous = incoming.committed_lineage.get(&peer_id).copied(); + if committed_previous != expected_previous { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "bootstrap previous generation does not match the host", + true, + )); + } + if committed_previous.is_some_and(|current| current >= stamp) { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "bootstrap lost the host generation race", + true, + )); + } + reservation.publish(peer)?; + incoming.activating.insert( + peer_id, + IncomingActivation { + stamp, + candidate_id: connection.stable_id(), + pending: pending.clone(), + }, + ); + if let Some(previous) = incoming.current.get(&peer_id).cloned() { + incoming.activating_previous.insert(peer_id, previous); + } + let previous_connection = incoming + .current + .get(&peer_id) + .and_then(|(_, handle)| handle.upgrade()); + previous_connection + }; + Ok(IncomingCommit { + peer: peer_id, + stamp, + candidate_id: connection.stable_id(), + candidate: connection.weak_handle(), + previous_stamp: expected_previous, + previous_connection, + pending, + }) + } + + /// Validate the staged activation before the client-visible FIN. No local + /// fallible activation step remains after that FIN is sent. + fn validate_incoming_activation(&self, commit: &IncomingCommit) -> Result<(), ProtocolError> { + let state = self.state.read().map_err(|_| internal_state_error())?; + let current_fence = state + .incoming_controllers + .allowed + .get(&commit.peer) + .copied() + .and_then(|incarnation| PairingFence::new(incarnation).ok()); + if state.authorization_disabled || current_fence != Some(commit.pending.pairing_fence) { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "incoming pairing changed before final activation", + false, + )); + } + let incoming = state.directional(iroh::endpoint::Side::Server); + if !activation_matches_commit(incoming, commit) + || !candidate_matches_commit(commit) + || committed_stamp(incoming, &commit.peer) != commit.previous_stamp + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "incoming generation changed before final activation", + true, + )); + } + Ok(()) + } + + /// Atomically publish the controller-observed candidate and replace the + /// host's prior current generation. The queue is ungated while the + /// admission lock is held, so a router cannot observe B before admission + /// names it current. No fallible state transition follows this commit. + fn finalize_observed_incoming( + &self, + accepted_connections: &AcceptedPeerQueue, + commit: &IncomingCommit, + ) -> Result< + ( + Option, + Option, + ), + ProtocolError, + > { + let mut state = self + .state + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let current_fence = state + .incoming_controllers + .allowed + .get(&commit.peer) + .copied() + .and_then(|incarnation| PairingFence::new(incarnation).ok()); + if state.authorization_disabled || current_fence != Some(commit.pending.pairing_fence) { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "incoming pairing changed before observed finalization", + false, + )); + } + let incoming = state.directional_mut(iroh::endpoint::Side::Server); + if !activation_matches_commit(incoming, commit) + || !candidate_matches_commit(commit) + || committed_stamp(incoming, &commit.peer) != commit.previous_stamp + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "incoming generation changed before observed finalization", + true, + )); + } + let queued_displaced = + accepted_connections.finalize_candidate(commit.peer, commit.stamp)?; + incoming.activating.remove(&commit.peer); + let previous = incoming.activating_previous.remove(&commit.peer); + incoming + .current + .insert(commit.peer, (commit.stamp, commit.candidate.clone())); + incoming.committed_lineage.insert(commit.peer, commit.stamp); + incoming + .finalized_transitions + .insert(commit.peer, commit.pending.clone()); + Ok((previous.map(|(_, handle)| handle), queued_displaced)) + } + + fn rollback_incoming(&self, commit: &IncomingCommit) -> Result<(), ProtocolError> { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + let incoming = state.directional_mut(iroh::endpoint::Side::Server); + if !activation_matches_commit(incoming, commit) { + return Ok(()); + } + incoming.activating.remove(&commit.peer); + incoming.activating_previous.remove(&commit.peer); + if let Some(active) = incoming.active.get_mut(&commit.peer) { + active.retain(|handle| { + handle + .upgrade() + .is_some_and(|connection| connection.stable_id() != commit.candidate_id) + }); + if active.is_empty() { + incoming.active.remove(&commit.peer); + } + } + Ok(()) + } + + /// Resolve one exact ambiguous controller handover without allocating a + /// generation or publishing an application connection. The decision is + /// linearized with normal host finalization under the admission lock. + fn reconcile_incoming( + &self, + accepted_connections: &AcceptedPeerQueue, + controller: iroh::EndpointId, + pending: &PendingCommit, + expected_pairing_fence: PairingFence, + local_host: iroh::EndpointId, + execution_target_id: &str, + ) -> Result< + ( + Option, + Vec, + ), + ProtocolError, + > { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + let pairing_incarnation = state + .incoming_controllers + .allowed + .get(&controller) + .copied() + .ok_or_else(|| { + ProtocolError::new( + ErrorCode::Revoked, + "controller authorization is unavailable", + false, + ) + })?; + let fence = PairingFence::new(pairing_incarnation)?; + if state.authorization_disabled || fence != expected_pairing_fence { + return Err(ProtocolError::new( + ErrorCode::Revoked, + "pairing authorization changed during reconciliation", + false, + )); + } + pending.validate(execution_target_id, controller, local_host, fence)?; + + let incoming = &mut state.incoming_controllers; + let committed = incoming.committed_lineage.get(&controller).copied(); + if committed == Some(pending.candidate_connection_stamp) { + if incoming.finalized_transitions.get(&controller) != Some(pending) { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "committed host transition does not match reconciliation", + true, + )); + } + return Ok((committed, Vec::new())); + } + if committed != pending.previous_connection_stamp { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "host lineage is unrelated to reconciliation", + true, + )); + } + + let activation = incoming.activating.get(&controller).cloned(); + if let Some(activation) = activation { + if activation.stamp != pending.candidate_connection_stamp { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "another controller handover is still resolving", + true, + )); + } + if activation.pending != *pending { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "active controller handover does not match reconciliation", + true, + )); + } + accepted_connections.rollback_candidate(controller, activation.stamp)?; + incoming.activating.remove(&controller); + incoming.activating_previous.remove(&controller); + let mut to_close = Vec::new(); + if let Some(active) = incoming.active.get_mut(&controller) { + active.retain(|handle| { + let matches = handle.upgrade().is_some_and(|connection| { + connection.stable_id() == activation.candidate_id + }); + if matches { + to_close.push(handle.clone()); + } + !matches + }); + if active.is_empty() { + incoming.active.remove(&controller); + } + } + return Ok((committed, to_close)); + } + Ok((committed, Vec::new())) + } + + fn is_current_incoming( + &self, + peer: &iroh::EndpointId, + stamp: ConnectionStamp, + pairing_fence: PairingFence, + ) -> bool { + self.state + .read() + .map(|state| { + !state.authorization_disabled + && state.incoming_controllers.allowed.get(peer).copied() + == Some(pairing_fence.pairing_incarnation()) + && (state.incoming_controllers.current.get(peer).is_some_and( + |(current, handle)| *current == stamp && weak_connection_is_open(handle), + ) || state + .incoming_controllers + .activating_previous + .get(peer) + .is_some_and(|(previous, handle)| { + *previous == stamp && weak_connection_is_open(handle) + })) + }) + .unwrap_or(false) + } + + fn current_incoming_authorization( + &self, + peer: &iroh::EndpointId, + stamp: ConnectionStamp, + pairing_fence: PairingFence, + ) -> Result { + let state = self.state.read().map_err(|_| internal_state_error())?; + Self::current_incoming_authorization_in_state(&state, peer, stamp, pairing_fence) + } + + fn current_incoming_authorization_in_state( + state: &AdmissionState, + peer: &iroh::EndpointId, + stamp: ConnectionStamp, + pairing_fence: PairingFence, + ) -> Result { + let account_epoch = state + .account_epoch + .filter(|_| !state.authorization_disabled) + .ok_or_else(|| { + ProtocolError::new( + ErrorCode::Revoked, + "remote controller authorization is unavailable", + false, + ) + })?; + if state.snapshot_revision == 0 + || state.incoming_controllers.allowed.get(peer).copied() + != Some(pairing_fence.pairing_incarnation()) + || !(state + .incoming_controllers + .current + .get(peer) + .is_some_and(|(current, handle)| { + *current == stamp && weak_connection_is_open(handle) + }) + || state + .incoming_controllers + .activating_previous + .get(peer) + .is_some_and(|(previous, handle)| { + *previous == stamp && weak_connection_is_open(handle) + })) + { + return Err(ProtocolError::new( + ErrorCode::Revoked, + "remote controller generation is no longer authorized", + false, + )); + } + Ok(InstalledAuthorizationContext { + account_epoch, + snapshot_revision: state.snapshot_revision, + snapshot_digest: authorization_snapshot_digest( + account_epoch, + state.snapshot_revision, + &state.incoming_controllers.allowed, + &state.outgoing_execution_targets.allowed, + ), + }) + } + + /// Capture host-side committed lineage while atomically fencing this + /// endpoint's admission state. The caller consumes and closes the endpoint + /// immediately afterwards, so no live transport handle is part of the + /// returned value. + fn capture_endpoint_lineage_and_fence( + &self, + local_endpoint: iroh::EndpointId, + execution_target_id: Arc, + ) -> Result { + let (snapshot, to_close) = { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + let account_epoch = state + .account_epoch + .filter(|_| !state.authorization_disabled) + .ok_or_else(|| { + ProtocolError::new( + ErrorCode::Unauthorized, + "host lineage capture requires an active versioned authorization snapshot", + false, + ) + })?; + let snapshot_revision = state.snapshot_revision; + if snapshot_revision == 0 { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "host lineage capture requires a versioned authorization revision", + false, + )); + } + let authorization_digest = authorization_snapshot_digest( + account_epoch, + snapshot_revision, + &state.incoming_controllers.allowed, + &state.outgoing_execution_targets.allowed, + ); + let incoming_controllers = state + .incoming_controllers + .allowed + .iter() + .map(|(peer, pairing_incarnation)| { + ( + *peer, + IncomingControllerLineage { + pairing_incarnation: *pairing_incarnation, + last_committed: state + .incoming_controllers + .committed_lineage + .get(peer) + .copied(), + finalized_transition: state + .incoming_controllers + .finalized_transitions + .get(peer) + .cloned(), + }, + ) + }) + .collect::>(); + if incoming_controllers.len() > MAX_AUTHORIZED_PEERS_PER_DIRECTION { + return Err(ProtocolError::new( + ErrorCode::Internal, + "host lineage exceeds Maple's peer bound", + false, + )); + } + for (peer, lineage) in &incoming_controllers { + validate_incoming_controller_lineage( + local_endpoint, + &execution_target_id, + *peer, + lineage, + )?; + } + let snapshot = EndpointLineageSnapshot { + local_endpoint, + execution_target_id, + account_epoch, + snapshot_revision, + authorization_digest, + incoming_controllers, + }; + + // Linearize the handoff against register/activation under the same + // lock. Advancing the process revision invalidates any handshake + // which registered before this fence but has not finalized yet. + bump_admission_revision(&mut state)?; + state.authorization_disabled = true; + let mut to_close = Vec::new(); + clear_directional_authorization(&mut state.incoming_controllers, &mut to_close); + clear_directional_authorization(&mut state.outgoing_execution_targets, &mut to_close); + (snapshot, to_close) + }; + close_weak_connections(to_close, b"Maple endpoint lineage captured"); + Ok(snapshot) + } + + /// Restore lineage only after the independently supplied current + /// authorization snapshot has been installed and before the accept pump is + /// started. A retained pairing survives unrelated authorization revisions; + /// a changed pairing incarnation is an explicit lineage fence. + fn restore_endpoint_lineage( + &self, + local_endpoint: iroh::EndpointId, + execution_target_id: &str, + snapshot: &EndpointLineageSnapshot, + ) -> Result<(), ProtocolError> { + if snapshot.local_endpoint != local_endpoint + || snapshot.execution_target_id.as_ref() != execution_target_id + { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "host lineage belongs to a different endpoint or execution target", + false, + )); + } + if snapshot.incoming_controllers.len() > MAX_AUTHORIZED_PEERS_PER_DIRECTION { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "host lineage exceeds Maple's peer bound", + false, + )); + } + if snapshot.snapshot_revision == 0 { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "host lineage authorization revision is invalid", + false, + )); + } + for (peer, lineage) in &snapshot.incoming_controllers { + validate_incoming_controller_lineage( + snapshot.local_endpoint, + &snapshot.execution_target_id, + *peer, + lineage, + )?; + } + + let mut state = self.state.write().map_err(|_| internal_state_error())?; + if state.account_epoch != Some(snapshot.account_epoch) { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "host lineage belongs to a different account authorization context", + false, + )); + } + if state.snapshot_revision < snapshot.snapshot_revision { + return Err(stale_authorization_snapshot()); + } + if state.snapshot_revision == snapshot.snapshot_revision + && authorization_snapshot_digest( + snapshot.account_epoch, + state.snapshot_revision, + &state.incoming_controllers.allowed, + &state.outgoing_execution_targets.allowed, + ) != snapshot.authorization_digest + { + return Err(stale_authorization_snapshot()); + } + if state.authorization_disabled + || state + .incoming_controllers + .active + .values() + .any(|handles| !handles.is_empty()) + || !state.incoming_controllers.current.is_empty() + || !state.incoming_controllers.activating.is_empty() + || !state.incoming_controllers.activating_previous.is_empty() + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "host lineage does not match a quiescent current authorization context", + false, + )); + } + for (peer, lineage) in &snapshot.incoming_controllers { + if state.incoming_controllers.allowed.get(peer) == Some(&lineage.pairing_incarnation) { + if let Some(last_committed) = lineage.last_committed { + state + .incoming_controllers + .committed_lineage + .insert(*peer, last_committed); + } + if let Some(transition) = &lineage.finalized_transition { + state + .incoming_controllers + .finalized_transitions + .insert(*peer, transition.clone()); + } + } + } + Ok(()) + } + + fn replace_authorizations( + &self, + snapshot: AuthorizationSnapshot, + ) -> Result { + if snapshot.account_epoch == 0 || snapshot.snapshot_revision == 0 { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "authorization snapshot version must be positive", + false, + )); + } + if snapshot.incoming_controllers.len() > MAX_AUTHORIZED_PEERS_PER_DIRECTION + || snapshot.outgoing_execution_targets.len() > MAX_AUTHORIZED_PEERS_PER_DIRECTION + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "authorization snapshot exceeds Maple's peer limit", + false, + )); + } + let (receipt, to_close) = { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + if state.authorization_disabled && state.account_epoch.is_none() { + return Err(stale_authorization_snapshot()); + } + let force_replace = match state.account_epoch { + None => true, + Some(current_account) if snapshot.account_epoch < current_account => { + return Err(stale_authorization_snapshot()); + } + Some(current_account) if snapshot.account_epoch == current_account => { + if state.authorization_disabled { + return Err(stale_authorization_snapshot()); + } + if snapshot.snapshot_revision < state.snapshot_revision { + return Err(stale_authorization_snapshot()); + } + if snapshot.snapshot_revision == state.snapshot_revision { + if state.incoming_controllers.allowed == snapshot.incoming_controllers + && state.outgoing_execution_targets.allowed + == snapshot.outgoing_execution_targets + { + let current = installed_authorization_context(&state)?; + return Ok(AuthorizationTransitionReceipt { + authorization_domain: InstalledAuthorizationDomain(self.clone()), + previous: Some(current.clone()), + current, + removed_incoming_controllers: Vec::new(), + account_epoch_changed: false, + }); + } + // Equal durable version with different canonical grants + // is control-plane equivocation. Poison admission before + // reporting it so the previously installed capability + // cannot remain live. + state.authorization_disabled = true; + let mut to_close = Vec::new(); + clear_directional_authorization( + &mut state.incoming_controllers, + &mut to_close, + ); + clear_directional_authorization( + &mut state.outgoing_execution_targets, + &mut to_close, + ); + drop(state); + close_weak_connections(to_close, b"conflicting authorization snapshot"); + return Err(authorization_snapshot_conflict()); + } + false + } + Some(_) => true, + }; + let previous = installed_authorization_context_if_enabled(&state); + let previous_incoming = state + .incoming_controllers + .allowed + .keys() + .copied() + .collect::>(); + bump_admission_revision(&mut state)?; + let mut to_close = Vec::new(); + replace_directional_authorization( + &mut state.incoming_controllers, + snapshot.incoming_controllers, + &mut to_close, + force_replace, + ); + replace_directional_authorization( + &mut state.outgoing_execution_targets, + snapshot.outgoing_execution_targets, + &mut to_close, + force_replace, + ); + state.account_epoch = Some(snapshot.account_epoch); + state.snapshot_revision = snapshot.snapshot_revision; + state.authorization_disabled = false; + let current = installed_authorization_context(&state)?; + let mut removed_incoming_controllers = previous_incoming + .into_iter() + .filter(|peer| !state.incoming_controllers.allowed.contains_key(peer)) + .collect::>(); + removed_incoming_controllers.sort_unstable(); + let receipt = AuthorizationTransitionReceipt { + authorization_domain: InstalledAuthorizationDomain(self.clone()), + account_epoch_changed: previous + .as_ref() + .is_some_and(|previous| previous.account_epoch != current.account_epoch), + previous, + current, + removed_incoming_controllers, + }; + (receipt, to_close) + }; + close_weak_connections(to_close, b"authorization snapshot replaced"); + Ok(receipt) + } + + fn clear_all_and_close(&self) -> Result<(), ProtocolError> { + let to_close = { + let mut state = self.state.write().map_err(|_| internal_state_error())?; + // Preserve the durable snapshot floor across sign-out. A delayed + // old-account snapshot must not become "initial" and reauthorize + // peers. The next account uses a larger account_epoch. + // Clearing authority is terminal even if the process-local race + // token has reached its numeric ceiling. Disable and erase the + // capability first; token exhaustion must never preserve access. + state.authorization_disabled = true; + let mut to_close = Vec::new(); + clear_directional_authorization(&mut state.incoming_controllers, &mut to_close); + clear_directional_authorization(&mut state.outgoing_execution_targets, &mut to_close); + let revision_result = bump_admission_revision(&mut state); + if revision_result.is_err() { + state.admission_revision = u64::MAX; + } + to_close + }; + close_weak_connections(to_close, b"authorization state cleared"); + Ok(()) + } +} + +fn committed_stamp( + incoming: &DirectionalAdmission, + peer: &iroh::EndpointId, +) -> Option { + incoming.committed_lineage.get(peer).copied() +} + +fn candidate_matches_commit(commit: &IncomingCommit) -> bool { + commit.candidate.upgrade().is_some_and(|connection| { + connection.stable_id() == commit.candidate_id && connection.close_reason().is_none() + }) +} + +fn activation_matches_commit(incoming: &DirectionalAdmission, commit: &IncomingCommit) -> bool { + incoming + .activating + .get(&commit.peer) + .is_some_and(|activation| { + activation.stamp == commit.stamp + && activation.candidate_id == commit.candidate_id + && activation.pending == commit.pending + }) +} + +fn replace_directional_authorization( + directional: &mut DirectionalAdmission, + allowed: HashMap, + to_close: &mut Vec, + force_replace: bool, +) { + let fenced = directional + .allowed + .iter() + .filter_map(|(peer, incarnation)| { + (force_replace || allowed.get(peer) != Some(incarnation)).then_some(*peer) + }) + .collect::>(); + directional.allowed = allowed; + if force_replace { + directional.committed_lineage.clear(); + directional.finalized_transitions.clear(); + directional.current.clear(); + directional.activating.clear(); + directional.activating_previous.clear(); + } else { + // Authorization removal is also a lineage fence even when the old + // transport handle was already pruned. Re-adding the same endpoint is + // an explicit new pairing lineage whose first bootstrap names `None`. + let allowed = directional.allowed.clone(); + directional + .committed_lineage + .retain(|peer, _| allowed.contains_key(peer) && !fenced.contains(peer)); + directional + .finalized_transitions + .retain(|peer, _| allowed.contains_key(peer) && !fenced.contains(peer)); + directional + .current + .retain(|peer, _| allowed.contains_key(peer) && !fenced.contains(peer)); + directional + .activating + .retain(|peer, _| allowed.contains_key(peer) && !fenced.contains(peer)); + directional + .activating_previous + .retain(|peer, _| allowed.contains_key(peer) && !fenced.contains(peer)); + } + let revoked = directional + .active + .keys() + .copied() + .filter(|peer| force_replace || fenced.contains(peer)) + .collect::>(); + for peer in revoked { + directional.committed_lineage.remove(&peer); + directional.finalized_transitions.remove(&peer); + directional.current.remove(&peer); + directional.activating.remove(&peer); + directional.activating_previous.remove(&peer); + if let Some(handles) = directional.active.remove(&peer) { + to_close.extend(handles); + } + } +} + +fn clear_directional_authorization( + directional: &mut DirectionalAdmission, + to_close: &mut Vec, +) { + directional.allowed.clear(); + directional.committed_lineage.clear(); + directional.finalized_transitions.clear(); + directional.current.clear(); + directional.activating.clear(); + directional.activating_previous.clear(); + for (_, handles) in directional.active.drain() { + to_close.extend(handles); + } +} + +fn bump_admission_revision(state: &mut AdmissionState) -> Result { + state.admission_revision = state.admission_revision.checked_add(1).ok_or_else(|| { + ProtocolError::new(ErrorCode::Internal, "admission revision exhausted", false) + })?; + Ok(state.admission_revision) +} + +fn installed_authorization_context_if_enabled( + state: &AdmissionState, +) -> Option { + let account_epoch = state + .account_epoch + .filter(|_| !state.authorization_disabled)?; + (state.snapshot_revision != 0).then(|| InstalledAuthorizationContext { + account_epoch, + snapshot_revision: state.snapshot_revision, + snapshot_digest: authorization_snapshot_digest( + account_epoch, + state.snapshot_revision, + &state.incoming_controllers.allowed, + &state.outgoing_execution_targets.allowed, + ), + }) +} + +fn installed_authorization_context( + state: &AdmissionState, +) -> Result { + installed_authorization_context_if_enabled(state).ok_or_else(stale_authorization_snapshot) +} + +fn authorization_snapshot_conflict() -> ProtocolError { + ProtocolError::new( + ErrorCode::Revoked, + "authorization snapshot version has conflicting canonical grants", + false, + ) +} + +fn stale_authorization_snapshot() -> ProtocolError { + ProtocolError::new( + ErrorCode::Revoked, + "authorization snapshot is stale or conflicting", + false, + ) +} + +fn weak_connection_is_open(handle: &iroh::endpoint::WeakConnectionHandle) -> bool { + handle + .upgrade() + .is_some_and(|connection| connection.close_reason().is_none()) +} + +fn close_weak_connections( + handles: impl IntoIterator, + reason: &[u8], +) { + for handle in handles { + if let Some(connection) = handle.upgrade() { + connection.close(iroh::endpoint::VarInt::from_u32(0x4d_52), reason); + } + } +} + +impl iroh::endpoint::EndpointHooks for PeerAdmission { + fn before_connect<'a>( + &'a self, + remote_addr: &'a iroh::EndpointAddr, + alpn: &'a [u8], + ) -> impl Future + Send + 'a { + let accepted = + alpn == ALPN && self.is_allowed(iroh::endpoint::Side::Client, &remote_addr.id); + async move { + if accepted { + iroh::endpoint::BeforeConnectOutcome::Accept + } else { + iroh::endpoint::BeforeConnectOutcome::Reject + } + } + } + + fn after_handshake<'a>( + &'a self, + connection: &'a iroh::endpoint::Connection, + ) -> impl Future + Send + 'a { + let accepted = connection.alpn() == ALPN + && self.is_allowed(connection.side(), &connection.remote_id()); + async move { + if accepted { + iroh::endpoint::AfterHandshakeOutcome::Accept + } else { + iroh::endpoint::AfterHandshakeOutcome::Reject { + error_code: iroh::endpoint::VarInt::from_u32(0x4d_50), + reason: b"peer not authorized".to_vec(), + } + } + } + } +} + +fn spawn_incoming_stream_dispatcher( + connection: iroh::endpoint::Connection, + connection_stamp: ConnectionStamp, + execution_target_id: Arc, + expected_direction: PeerDirection, + frame_deadline: Duration, + lane_capacity: Arc, +) -> Arc { + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let queue = Arc::new(PreparedStreamQueue::default()); + let task_queue = queue.clone(); + tokio::spawn(async move { + let mut headers = futures_util::stream::FuturesUnordered::new(); + loop { + tokio::select! { + biased; + _ = &mut shutdown_rx => break, + completed = headers.next(), if !headers.is_empty() => { + let result = match completed { + Some(Ok(result)) => result, + Some(Err(_)) => Err(internal_state_error()), + None => continue, + }; + task_queue.publish(result); + } + incoming = connection.accept_bi(), if headers.len() < MAX_APPLICATION_STREAM_TASKS => { + let streams = match incoming { + Ok(streams) => streams, + Err(_) => break, + }; + let execution_target_id = execution_target_id.clone(); + let lane_capacity = lane_capacity.clone(); + headers.push(tokio::spawn(async move { + prepare_accepted_stream( + streams, + connection_stamp, + execution_target_id, + expected_direction, + frame_deadline, + lane_capacity, + ) + .await + })); + } + } + } + for task in headers.iter() { + task.abort(); + } + while headers.next().await.is_some() {} + task_queue.close(); + }); + Arc::new(IncomingStreamDispatcher { + queue, + shutdown: Mutex::new(Some(shutdown_tx)), + }) +} + +async fn prepare_accepted_stream( + (send, mut recv): (iroh::endpoint::SendStream, iroh::endpoint::RecvStream), + connection_stamp: ConnectionStamp, + execution_target_id: Arc, + expected_direction: PeerDirection, + frame_deadline: Duration, + lane_capacity: Arc, +) -> Result { + let now = tokio::time::Instant::now(); + let operation_deadline = now + frame_deadline; + // The lane is unknown until this tiny header is decoded. Cap this + // preclassification phase independently so a peer cannot occupy every QUIC + // stream credit with partial headers for a long application-frame budget. + let header_deadline = now + frame_deadline.min(MAX_STREAM_HEADER_DEADLINE); + let header: StreamHeader = read_frame_until(&mut recv, header_deadline).await?; + header.validate(expected_direction)?; + if header.connection_stamp != connection_stamp { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "stream belongs to a stale connection generation", + true, + )); + } + // Quotas are lane-specific so Bulk work cannot consume capacity reserved + // for Control reconnect/session operations. A peer cannot gain Control + // priority by relabelling a Bulk DTO: typed validation below binds the lane. + let permit = lane_capacity + .for_kind(header.stream_kind) + .try_acquire_owned() + .map_err(|_| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple stream lane is at capacity", + true, + ) + })?; + // Until the typed operation is validated, the response half remains Bulk. + send.set_priority(StreamKind::Bulk.priority()) + .map_err(|error| { + transport_error( + "failed to prioritize accepted Maple remote stream", + error, + true, + ) + })?; + Ok(AcceptedStream { + header, + send, + recv, + execution_target_id, + connection_stamp, + operation_deadline: Some(operation_deadline), + _permit: permit, + }) +} + +impl ConnectedPeer { + fn new( + connection: iroh::endpoint::Connection, + connection_stamp: ConnectionStamp, + pairing_fence: PairingFence, + execution_target_id: Arc, + outbound_direction: PeerDirection, + frame_deadline: Duration, + ) -> Self { + let inbound_stream_tasks = Arc::new(LaneSemaphores::new()); + let incoming_streams = spawn_incoming_stream_dispatcher( + connection.clone(), + connection_stamp, + execution_target_id.clone(), + outbound_direction.opposite(), + frame_deadline, + inbound_stream_tasks, + ); + Self { + connection, + connection_stamp, + pairing_fence, + execution_target_id, + outbound_direction, + frame_deadline, + outbound_requests: Arc::new(LaneSemaphores::new()), + incoming_streams, + } + } + + pub fn remote_id(&self) -> iroh::EndpointId { + self.connection.remote_id() + } + + pub fn connection_stamp(&self) -> ConnectionStamp { + self.connection_stamp + } + + /// The directed pairing incarnation admitted during this connection's + /// bootstrap. Application adapters revalidate this token immediately + /// before dispatch so revoke-and-re-pair cannot reuse a queued stream. + pub fn pairing_fence(&self) -> PairingFence { + self.pairing_fence + } + + pub fn execution_target_id(&self) -> &str { + &self.execution_target_id + } + + /// Resolve when this generation's transport is lost or explicitly closed. + /// The underlying Iroh reason/path state is deliberately not returned: it + /// may contain network addresses. Owners race this signal with requests and + /// platform `network_change` callbacks to begin cached reconnect promptly. + pub async fn wait_closed(&self) { + let _ = self.connection.closed().await; + } + + /// Explicitly stop this connection generation and wake every cloned + /// owner's loss signal. Dropping the last Iroh handle also closes it, but + /// lifecycle owners should call this when superseding/signing out so a + /// forgotten clone cannot keep an obsolete generation alive. + pub fn close(&self) { + self.connection.close( + iroh::endpoint::VarInt::from_u32(0), + b"Maple connection generation stopped", + ); + } + + #[cfg(test)] + fn raw_connection(&self) -> &iroh::endpoint::Connection { + &self.connection + } + + /// Complete one typed request/response exchange on its operation-derived + /// stream lane. Neither QUIC stream escapes this boundary: both frames are + /// bounded and the response is correlated to the request, execution target, + /// connection stamp, and lane before it is returned. + pub async fn request( + &self, + request: &RequestEnvelope, + ) -> Result, ProtocolError> + where + TRequest: RequestBody + Serialize, + TResponse: ResponseBody + DeserializeOwned, + { + let operation_deadline = tokio::time::Instant::now() + self.frame_deadline; + let kind = request.body.stream_kind(); + let _permit = tokio::time::timeout_at( + operation_deadline, + self.outbound_requests.for_kind(kind).acquire_owned(), + ) + .await + .map_err(|_| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple outbound request capacity deadline elapsed", + true, + ) + })? + .map_err(|_| internal_state_error())?; + let (mut send, mut recv) = self + .open_request_stream(request, operation_deadline) + .await?; + send.finish().map_err(|error| { + transport_error("failed to finish Maple remote request", error, true) + })?; + let response: ResponseEnvelope = + read_frame_until(&mut recv, operation_deadline).await?; + response.validate( + &request.request_id, + &self.execution_target_id, + self.connection_stamp, + kind, + )?; + if let Ok(body) = &response.result { + body.validate_response_to(&request.body)?; + } + Ok(response) + } + + /// Start one typed request whose response is a bounded sequence of frames. + /// + /// Bulk history pages use this instead of placing a count-bounded page in + /// one frame. Each native record therefore retains the universal 1 MiB + /// frame cap without making aggregate byte size a pagination primitive. + pub async fn start_streaming_request( + &self, + request: RequestEnvelope, + ) -> Result, ProtocolError> + where + TRequest: RequestBody + Serialize, + { + let request_deadline = tokio::time::Instant::now() + self.frame_deadline; + let kind = request.body.stream_kind(); + let permit = tokio::time::timeout_at( + request_deadline, + self.outbound_requests.for_kind(kind).acquire_owned(), + ) + .await + .map_err(|_| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple outbound request capacity deadline elapsed", + true, + ) + })? + .map_err(|_| internal_state_error())?; + let (mut send, recv) = self.open_request_stream(&request, request_deadline).await?; + send.finish().map_err(|error| { + transport_error("failed to finish Maple remote request", error, true) + })?; + Ok(StreamingResponse { + recv, + request, + execution_target_id: Arc::clone(&self.execution_target_id), + connection_stamp: self.connection_stamp, + stream_kind: kind, + operation_deadline: match kind { + StreamKind::Events => None, + StreamKind::Bulk => { + Some(tokio::time::Instant::now() + BULK_STREAMING_OPERATION_DEADLINE) + } + StreamKind::Control => Some(tokio::time::Instant::now() + self.frame_deadline), + }, + _permit: permit, + }) + } + + async fn open_request_stream( + &self, + request: &RequestEnvelope, + operation_deadline: tokio::time::Instant, + ) -> Result<(iroh::endpoint::SendStream, iroh::endpoint::RecvStream), ProtocolError> + where + T: RequestBody + Serialize, + { + let kind = request.body.stream_kind(); + if request.direction != request.body.allowed_direction() { + return Err(ProtocolError::new( + ErrorCode::WrongDirection, + "request operation is not allowed in this direction", + false, + )); + } + request.validate( + self.outbound_direction, + &self.execution_target_id, + self.connection_stamp, + kind, + )?; + let (mut send, recv) = + tokio::time::timeout_at(operation_deadline, self.connection.open_bi()) + .await + .map_err(|_| operation_timeout("Maple stream-open deadline elapsed"))? + .map_err(|error| { + transport_error("failed to open Maple remote stream", error, true) + })?; + send.set_priority(kind.priority()).map_err(|error| { + transport_error("failed to prioritize Maple remote stream", error, true) + })?; + write_frame_until( + &mut send, + &StreamHeader { + protocol_version: PROTOCOL_VERSION, + stream_kind: kind, + direction: self.outbound_direction, + connection_stamp: self.connection_stamp, + }, + operation_deadline, + ) + .await?; + write_frame_until(&mut send, request, operation_deadline).await?; + Ok((send, recv)) + } + + pub async fn accept_stream(&self) -> Result { + let deadline = tokio::time::Instant::now() + self.frame_deadline; + self.incoming_streams.queue.recv(deadline).await + } +} + +/// Controller half of a typed multi-frame response on one request stream. +/// Every frame is independently correlated and validated before it escapes. +pub struct StreamingResponse { + recv: iroh::endpoint::RecvStream, + request: RequestEnvelope, + execution_target_id: Arc, + connection_stamp: ConnectionStamp, + stream_kind: StreamKind, + operation_deadline: Option, + _permit: OwnedSemaphorePermit, +} + +impl StreamingResponse { + pub async fn read(&mut self) -> Result, ProtocolError> + where + TResponse: ResponseBody + DeserializeOwned, + { + let response: ResponseEnvelope = match self.operation_deadline { + Some(deadline) => read_frame_until(&mut self.recv, deadline).await?, + None => read_frame_unbounded(&mut self.recv).await?, + }; + response.validate( + &self.request.request_id, + &self.execution_target_id, + self.connection_stamp, + self.stream_kind, + )?; + if let Ok(body) = &response.result { + body.validate_response_to(&self.request.body)?; + } + Ok(response) + } + + /// Require the host to terminate immediately after the protocol footer. + pub async fn finish(mut self) -> Result<(), ProtocolError> { + match self.operation_deadline { + Some(deadline) => expect_stream_end(&mut self.recv, deadline).await, + None => expect_stream_end_unbounded(&mut self.recv).await, + } + } +} + +pub struct AcceptedStream { + header: StreamHeader, + send: iroh::endpoint::SendStream, + recv: iroh::endpoint::RecvStream, + execution_target_id: Arc, + connection_stamp: ConnectionStamp, + operation_deadline: Option, + _permit: OwnedSemaphorePermit, +} + +impl std::fmt::Debug for AcceptedStream { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AcceptedStream") + .field("stream_kind", &self.header.stream_kind) + .field("connection_stamp", &self.connection_stamp) + .finish_non_exhaustive() + } +} + +impl AcceptedStream { + pub fn header(&self) -> &StreamHeader { + &self.header + } + + #[cfg(test)] + fn send_stream(&mut self) -> &mut iroh::endpoint::SendStream { + &mut self.send + } + + pub async fn read_request(mut self) -> Result, ProtocolError> + where + T: RequestBody + DeserializeOwned, + { + let request_deadline = self + .operation_deadline + .expect("accepted streams retain a deadline until the request is decoded"); + let request: RequestEnvelope = + read_frame_until(&mut self.recv, request_deadline).await?; + request.validate( + self.header.direction, + &self.execution_target_id, + self.connection_stamp, + self.header.stream_kind, + )?; + if request.direction != request.body.allowed_direction() { + return Err(ProtocolError::new( + ErrorCode::WrongDirection, + "request operation is not allowed in this direction", + false, + )); + } + self.send + .set_priority(self.header.stream_kind.priority()) + .map_err(|error| { + transport_error( + "failed to prioritize validated Maple remote stream", + error, + true, + ) + })?; + match request.body.stream_kind() { + StreamKind::Bulk => { + self.operation_deadline = + Some(tokio::time::Instant::now() + BULK_STREAMING_OPERATION_DEADLINE); + } + StreamKind::Events => { + // An Events stream is bounded by its authenticated connection, + // explicit RPC cancellation/revocation, and lane permit. It has + // no wall-clock or idle-read lifetime deadline. + self.operation_deadline = None; + } + StreamKind::Control => {} + } + Ok(AcceptedRequest { + stream: self, + request, + }) + } +} + +pub struct AcceptedRequest { + stream: AcceptedStream, + request: RequestEnvelope, +} + +impl std::fmt::Debug for AcceptedRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AcceptedRequest") + .field("request_id", &self.request.request_id) + .field("stream_kind", &self.stream.header.stream_kind) + .field("connection_stamp", &self.stream.connection_stamp) + .finish_non_exhaustive() + } +} + +impl AcceptedRequest { + pub fn request(&self) -> &RequestEnvelope { + &self.request + } + + /// Deadline for one finite provider/adapter phase. Bulk and Control inherit + /// their absolute operation deadline. Events deliberately has no lifetime + /// deadline, so callers receive a fresh bounded phase deadline instead; + /// they must never use this value as an Events read/stream lifetime. + pub(crate) fn operation_deadline(&self) -> tokio::time::Instant { + self.stream + .operation_deadline + .unwrap_or_else(|| tokio::time::Instant::now() + EVENT_FRAME_WRITE_DEADLINE) + } + + /// Resolves when the controller abandons the response stream or the + /// connection is lost. The returned future owns the QUIC stop waiter, so a + /// host adapter can race it against injected work without borrowing this + /// request and can still consume `self` to write a successful response. + pub(crate) fn response_cancelled( + &self, + ) -> impl Future> + Send + 'static { + let stopped = self.stream.send.stopped(); + async move { + match stopped.await { + Ok(Some(_)) => Ok(()), + Ok(None) => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "remote response stream ended before host dispatch", + true, + )), + Err(_) => Ok(()), + } + } + } + + #[cfg(test)] + fn send_stream(&mut self) -> &mut iroh::endpoint::SendStream { + &mut self.stream.send + } + + pub async fn write_response( + mut self, + response: &ResponseEnvelope, + ) -> Result<(), ProtocolError> + where + TResponse: ResponseBody + Serialize, + { + self.write_response_frame(response).await?; + self.finish_response() + } + + /// Write one independently bounded and correlated response frame while + /// retaining the stream for a subsequent record or footer. + pub async fn write_response_frame( + &mut self, + response: &ResponseEnvelope, + ) -> Result<(), ProtocolError> + where + TResponse: ResponseBody + Serialize, + { + self.validate_response_frame(response)?; + let deadline = self + .stream + .operation_deadline + .unwrap_or_else(|| tokio::time::Instant::now() + EVENT_FRAME_WRITE_DEADLINE); + let result = write_frame_until(&mut self.stream.send, response, deadline).await; + if result.is_err() && self.stream.header.stream_kind == StreamKind::Events { + // A blocked controller cannot retain an Events lane or its native + // subscription indefinitely. The RPC owner observes this error and + // acknowledges subscription cancellation; resetting this exact + // QUIC response stream fences any late frame bytes immediately. + let _ = self + .stream + .send + .reset(iroh::endpoint::VarInt::from_u32(0x4d_57)); + } + result + } + + /// Apply the exact semantic/correlation checks used by a response write + /// without emitting bytes. Multi-frame adapters preflight every frame with + /// this before Start so a malformed late record cannot create a partial + /// page on the wire. + pub(crate) fn validate_response_frame( + &self, + response: &ResponseEnvelope, + ) -> Result<(), ProtocolError> + where + TResponse: ResponseBody, + { + response.validate( + &self.request.request_id, + &self.stream.execution_target_id, + self.stream.connection_stamp, + self.stream.header.stream_kind, + )?; + if let Ok(body) = &response.result { + body.validate_response_to(&self.request.body)?; + } + Ok(()) + } + + pub fn finish_response(mut self) -> Result<(), ProtocolError> { + self.stream + .send + .finish() + .map_err(|error| transport_error("failed to finish Maple remote response", error, true)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PendingCommit { + pairing_fence: PairingFence, + original_request_id: String, + execution_target_id: String, + controller_id: String, + host_id: String, + previous_connection_stamp: Option, + candidate_connection_stamp: ConnectionStamp, +} + +impl PendingCommit { + fn new( + pairing_fence: PairingFence, + original_request_id: &str, + execution_target_id: &str, + controller_id: iroh::EndpointId, + host_id: iroh::EndpointId, + previous_connection_stamp: Option, + candidate_connection_stamp: ConnectionStamp, + ) -> Result { + let pending = Self { + pairing_fence, + original_request_id: original_request_id.into(), + execution_target_id: execution_target_id.into(), + controller_id: controller_id.to_string(), + host_id: host_id.to_string(), + previous_connection_stamp, + candidate_connection_stamp, + }; + pending.validate(execution_target_id, controller_id, host_id, pairing_fence)?; + Ok(pending) + } + + fn validate( + &self, + expected_target: &str, + expected_controller: iroh::EndpointId, + expected_host: iroh::EndpointId, + expected_fence: PairingFence, + ) -> Result<(), ProtocolError> { + self.pairing_fence.validate()?; + validate_bootstrap_id("original_request_id", &self.original_request_id)?; + validate_bootstrap_id("execution_target_id", &self.execution_target_id)?; + validate_bootstrap_id("controller_id", &self.controller_id)?; + validate_bootstrap_id("host_id", &self.host_id)?; + self.candidate_connection_stamp.validate()?; + if let Some(previous) = self.previous_connection_stamp { + previous.validate()?; + if previous >= self.candidate_connection_stamp { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "pending handover does not advance its predecessor", + true, + )); + } + } + if self.execution_target_id != expected_target + || self.controller_id != expected_controller.to_string() + || self.host_id != expected_host.to_string() + || self.pairing_fence != expected_fence + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "pending handover does not match the current pairing", + false, + )); + } + Ok(()) + } +} + +fn validate_incoming_controller_lineage( + local_endpoint: iroh::EndpointId, + execution_target_id: &str, + controller: iroh::EndpointId, + lineage: &IncomingControllerLineage, +) -> Result<(), ProtocolError> { + match (lineage.last_committed, &lineage.finalized_transition) { + (Some(last_committed), Some(transition)) => { + last_committed.validate()?; + if transition.candidate_connection_stamp != last_committed { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "host lineage transition does not match its committed stamp", + false, + )); + } + let pairing_fence = PairingFence::new(lineage.pairing_incarnation)?; + transition.validate( + execution_target_id, + controller, + local_endpoint, + pairing_fence, + )?; + } + (Some(_), None) => { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "committed host lineage is missing its exact transition", + false, + )); + } + (None, Some(_)) => { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "genesis host lineage cannot contain a finalized transition", + false, + )); + } + (None, None) => {} + } + Ok(()) +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapRequest { + protocol_version: u16, + request_id: String, + execution_target_id: String, + bootstrap_generation: u8, + pairing_fence: PairingFence, + previous_connection_stamp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reconciliation: Option, +} + +impl BootstrapRequest { + fn validate( + &self, + expected_target: &str, + expected_fence: PairingFence, + ) -> Result<(), ProtocolError> { + if self.protocol_version != PROTOCOL_VERSION { + return Err(ProtocolError::new( + ErrorCode::UnsupportedVersion, + "bootstrap protocol version is unsupported", + false, + )); + } + validate_bootstrap_id("request_id", &self.request_id)?; + validate_bootstrap_id("execution_target_id", &self.execution_target_id)?; + if self.execution_target_id != expected_target { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "bootstrap execution target does not match this host", + false, + )); + } + if self.bootstrap_generation != 0 { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "bootstrap lane must use generation zero", + false, + )); + } + validate_bootstrap_pairing_fence(self.pairing_fence, expected_fence)?; + if let Some(previous) = self.previous_connection_stamp { + previous.validate()?; + } + if let Some(pending) = &self.reconciliation { + if pending.pairing_fence != self.pairing_fence { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "reconciliation pairing fence does not match bootstrap", + false, + )); + } + if self.previous_connection_stamp != pending.previous_connection_stamp { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "reconciliation predecessor does not match its pending handover", + true, + )); + } + } + Ok(()) + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapAccepted { + connection_stamp: ConnectionStamp, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapResponse { + protocol_version: u16, + request_id: String, + execution_target_id: String, + pairing_fence: PairingFence, + result: Result, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapReady { + protocol_version: u16, + request_id: String, + execution_target_id: String, + controller_id: String, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + previous_connection_stamp: Option, +} + +impl BootstrapReady { + fn validate( + &self, + expected_request_id: &str, + expected_target: &str, + expected_controller: iroh::EndpointId, + expected_fence: PairingFence, + expected_stamp: ConnectionStamp, + expected_previous: Option, + ) -> Result<(), ProtocolError> { + validate_bootstrap_pairing_fence(self.pairing_fence, expected_fence)?; + if self.protocol_version != PROTOCOL_VERSION + || self.request_id != expected_request_id + || self.execution_target_id != expected_target + || self.controller_id != expected_controller.to_string() + || self.connection_stamp != expected_stamp + || self.previous_connection_stamp != expected_previous + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "bootstrap readiness did not match the committed connection", + true, + )); + } + validate_bootstrap_id("request_id", &self.request_id)?; + validate_bootstrap_id("execution_target_id", &self.execution_target_id)?; + self.connection_stamp.validate() + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapInstalled { + protocol_version: u16, + request_id: String, + execution_target_id: String, + controller_id: String, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + previous_connection_stamp: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapCommitted { + protocol_version: u16, + request_id: String, + execution_target_id: String, + controller_id: String, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + previous_connection_stamp: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapCommitObserved { + protocol_version: u16, + request_id: String, + execution_target_id: String, + controller_id: String, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + previous_connection_stamp: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapFinalized { + protocol_version: u16, + request_id: String, + execution_target_id: String, + controller_id: String, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + previous_connection_stamp: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct BootstrapReconciled { + protocol_version: u16, + request_id: String, + execution_target_id: String, + controller_id: String, + host_id: String, + pending: PendingCommit, + committed_connection_stamp: Option, +} + +impl BootstrapReconciled { + fn validate( + &self, + expected_request_id: &str, + expected_target: &str, + expected_controller: iroh::EndpointId, + expected_host: iroh::EndpointId, + expected_pending: &PendingCommit, + expected_fence: PairingFence, + ) -> Result, ProtocolError> { + validate_bootstrap_id("request_id", &self.request_id)?; + self.pending.validate( + expected_target, + expected_controller, + expected_host, + expected_fence, + )?; + if self.protocol_version != PROTOCOL_VERSION + || self.request_id != expected_request_id + || self.execution_target_id != expected_target + || self.controller_id != expected_controller.to_string() + || self.host_id != expected_host.to_string() + || &self.pending != expected_pending + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "bootstrap reconciliation correlation failed", + true, + )); + } + if let Some(committed) = self.committed_connection_stamp { + committed.validate()?; + } + if self.committed_connection_stamp != expected_pending.previous_connection_stamp + && self.committed_connection_stamp != Some(expected_pending.candidate_connection_stamp) + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "host reconciliation reported an unrelated generation", + true, + )); + } + Ok(self.committed_connection_stamp) + } +} + +fn validate_bootstrap_handover( + protocol_version: u16, + request_id: &str, + execution_target_id: &str, + controller_id: &str, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + previous_connection_stamp: Option, + expected_request_id: &str, + expected_target: &str, + expected_controller: iroh::EndpointId, + expected_fence: PairingFence, + expected_stamp: ConnectionStamp, + expected_previous: Option, + phase: &str, +) -> Result<(), ProtocolError> { + validate_bootstrap_id("request_id", request_id)?; + validate_bootstrap_id("execution_target_id", execution_target_id)?; + validate_bootstrap_pairing_fence(pairing_fence, expected_fence)?; + connection_stamp.validate()?; + if let Some(previous) = previous_connection_stamp { + previous.validate()?; + } + if protocol_version != PROTOCOL_VERSION + || request_id != expected_request_id + || execution_target_id != expected_target + || controller_id != expected_controller.to_string() + || connection_stamp != expected_stamp + || previous_connection_stamp != expected_previous + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + format!("bootstrap {phase} correlation failed"), + true, + )); + } + Ok(()) +} + +macro_rules! impl_bootstrap_handover_validation { + ($type:ty, $phase:literal) => { + impl $type { + fn validate( + &self, + expected_request_id: &str, + expected_target: &str, + expected_controller: iroh::EndpointId, + expected_fence: PairingFence, + expected_stamp: ConnectionStamp, + expected_previous: Option, + ) -> Result<(), ProtocolError> { + validate_bootstrap_handover( + self.protocol_version, + &self.request_id, + &self.execution_target_id, + &self.controller_id, + self.pairing_fence, + self.connection_stamp, + self.previous_connection_stamp, + expected_request_id, + expected_target, + expected_controller, + expected_fence, + expected_stamp, + expected_previous, + $phase, + ) + } + } + }; +} + +impl_bootstrap_handover_validation!(BootstrapInstalled, "installed acknowledgment"); +impl_bootstrap_handover_validation!(BootstrapCommitted, "commit marker"); +impl_bootstrap_handover_validation!(BootstrapCommitObserved, "commit observation"); +impl_bootstrap_handover_validation!(BootstrapFinalized, "finalization marker"); + +impl BootstrapResponse { + fn validate( + &self, + expected_request_id: &str, + expected_target: &str, + expected_fence: PairingFence, + ) -> Result { + if self.protocol_version != PROTOCOL_VERSION { + return Err(ProtocolError::new( + ErrorCode::UnsupportedVersion, + "bootstrap response version is unsupported", + false, + )); + } + validate_bootstrap_id("request_id", &self.request_id)?; + validate_bootstrap_id("execution_target_id", &self.execution_target_id)?; + validate_bootstrap_pairing_fence(self.pairing_fence, expected_fence)?; + if self.request_id != expected_request_id || self.execution_target_id != expected_target { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "bootstrap response correlation failed", + false, + )); + } + match &self.result { + Ok(accepted) => { + accepted.connection_stamp.validate()?; + Ok(accepted.connection_stamp) + } + Err(error) => { + error.validate()?; + Err(error.clone()) + } + } + } +} + +fn validate_bootstrap_pairing_fence( + actual: PairingFence, + expected: PairingFence, +) -> Result<(), ProtocolError> { + actual.validate()?; + expected.validate()?; + if actual != expected { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "bootstrap pairing fence does not match current authorization", + false, + )); + } + Ok(()) +} + +fn validate_bootstrap_id(field: &str, value: &str) -> Result<(), ProtocolError> { + if !value.is_empty() + && value.len() <= crate::remote_protocol::MAX_ID_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + Ok(()) + } else { + Err(ProtocolError::new( + ErrorCode::InvalidFrame, + format!("invalid bootstrap {field}"), + false, + )) + } +} + +pub struct MapleIrohEndpoint { + endpoint: iroh::Endpoint, + admission: PeerAdmission, + connection_policy: ConnectionPolicy, + relay_policy: RelayPolicy, + execution_target_id: Arc, + accepted_connections: Arc, + shutdown: Mutex>>, +} + +/// Exclusive, retryable authority to move one host's committed connection +/// lineage to a rebuilt endpoint. +/// +/// The capability owns both the fenced source endpoint and its non-Clone +/// lineage snapshot. Async close or bind cancellation cannot destroy the +/// snapshot because restore attempts borrow this value mutably; a successful +/// endpoint/pump installation consumes it synchronously. +#[must_use = "a lineage handoff must be restored or deliberately dropped"] +pub struct EndpointLineageHandoff { + source: Option, + snapshot: Option, + authorization_floor_revision: u64, + authorization_floor_digest: [u8; 32], + source_closed: bool, + consumed: bool, + #[cfg(test)] + close_gate: Option>, + #[cfg(test)] + fail_next_bind: bool, +} + +impl std::fmt::Debug for EndpointLineageHandoff { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EndpointLineageHandoff") + .field("source_closed", &self.source_closed) + .field("consumed", &self.consumed) + .finish_non_exhaustive() + } +} + +impl EndpointLineageHandoff { + /// Monotonically record the newest independently supplied authorization + /// truth before any cancellable close/bind work. A failed attempt may be + /// retried with the exact same snapshot or a newer one, but can never roll + /// this handoff back or fork one revision into different peer maps. + fn authorize_restore_attempt( + &mut self, + authorization: &AuthorizationSnapshot, + ) -> Result<(), ProtocolError> { + let expected_account = self.snapshot()?.account_epoch; + if authorization.account_epoch == 0 + || authorization.snapshot_revision == 0 + || authorization.incoming_controllers.len() > MAX_AUTHORIZED_PEERS_PER_DIRECTION + || authorization.outgoing_execution_targets.len() > MAX_AUTHORIZED_PEERS_PER_DIRECTION + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "authorization snapshot is invalid for lineage restore", + false, + )); + } + if authorization.account_epoch < expected_account { + return Err(stale_authorization_snapshot()); + } + if authorization.account_epoch > expected_account { + // A newer local account context is authoritative. Permanently + // destroy this old-account reconstruction capability before any + // cancellable close/bind work so a failed attempt cannot retry an + // older account snapshot and resurrect its authorization. + self.snapshot.take(); + self.source.take(); + self.source_closed = true; + self.consumed = true; + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "host lineage was invalidated by a newer account authorization context", + false, + )); + } + if authorization.snapshot_revision < self.authorization_floor_revision { + return Err(stale_authorization_snapshot()); + } + let digest = digest_authorization_snapshot(authorization); + if authorization.snapshot_revision == self.authorization_floor_revision { + if digest != self.authorization_floor_digest { + return Err(stale_authorization_snapshot()); + } + } else { + self.authorization_floor_revision = authorization.snapshot_revision; + self.authorization_floor_digest = digest; + } + Ok(()) + } + + async fn ensure_source_closed(&mut self) -> Result<(), ProtocolError> { + if self.consumed { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "host lineage handoff was already consumed", + false, + )); + } + if !self.source_closed { + #[cfg(test)] + if let Some(gate) = &self.close_gate { + gate.notified().await; + } + let source = self.source.as_ref().ok_or_else(internal_state_error)?; + source.endpoint.close().await; + self.source_closed = true; + } + Ok(()) + } + + fn snapshot(&self) -> Result<&EndpointLineageSnapshot, ProtocolError> { + if self.consumed { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "host lineage handoff was already consumed", + false, + )); + } + self.snapshot.as_ref().ok_or_else(internal_state_error) + } + + fn consume_after_commit(&mut self) { + debug_assert!(self.source_closed); + self.snapshot.take(); + self.source.take(); + self.consumed = true; + } + + #[cfg(test)] + fn is_consumed(&self) -> bool { + self.consumed + } + + #[cfg(test)] + fn gate_source_close(&mut self, gate: Arc) { + self.close_gate = Some(gate); + } + + #[cfg(test)] + fn ungate_source_close(&mut self) { + self.close_gate = None; + } + + #[cfg(test)] + fn fail_next_bind(&mut self) { + self.fail_next_bind = true; + } + + #[cfg(test)] + fn take_fail_next_bind(&mut self) -> bool { + std::mem::take(&mut self.fail_next_bind) + } + + #[cfg(test)] + fn from_snapshot_fixture(snapshot: EndpointLineageSnapshot) -> Self { + let authorization_floor_revision = snapshot.snapshot_revision; + let authorization_floor_digest = snapshot.authorization_digest; + Self { + source: None, + snapshot: Some(snapshot), + authorization_floor_revision, + authorization_floor_digest, + source_closed: true, + consumed: false, + close_gate: None, + fail_next_bind: false, + } + } +} + +/// A host-side QUIC generation that has completed bootstrap transport auth but +/// is not yet application-routable. Delaying `ConnectedPeer::new` also delays +/// the application stream dispatcher, so B cannot prefill command queues while +/// A is still the committed generation. +struct PendingConnectedPeer { + connection: iroh::endpoint::Connection, + connection_stamp: ConnectionStamp, + pairing_fence: PairingFence, + execution_target_id: Arc, + outbound_direction: PeerDirection, + frame_deadline: Duration, +} + +impl PendingConnectedPeer { + fn remote_id(&self) -> iroh::EndpointId { + self.connection.remote_id() + } + + fn connection_stamp(&self) -> ConnectionStamp { + self.connection_stamp + } + + fn finalize(self) -> ConnectedPeer { + ConnectedPeer::new( + self.connection, + self.connection_stamp, + self.pairing_fence, + self.execution_target_id, + self.outbound_direction, + self.frame_deadline, + ) + } + + fn close(&self) { + self.connection.close( + iroh::endpoint::VarInt::from_u32(0), + b"Maple pending connection generation stopped", + ); + } +} + +impl std::fmt::Debug for PendingConnectedPeer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PendingConnectedPeer") + .field("remote_id", &self.remote_id()) + .field("connection_stamp", &self.connection_stamp) + .field("pairing_fence", &self.pairing_fence) + .field("execution_target_id", &self.execution_target_id) + .field("outbound_direction", &self.outbound_direction) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Default)] +struct AcceptedPeerQueue { + state: Mutex, + ready: Notify, +} + +#[derive(Debug, Default)] +struct AcceptedPeerQueueState { + /// At most one queued generation per controller. Superseded same-peer + /// generations replace rather than consume global queue capacity. + latest: HashMap, + /// One non-routable handover candidate per controller. Staging B never + /// replaces, removes, or gates queued A. + pending: HashMap, + ready_order: VecDeque, + reservations: HashMap, + closed: bool, +} + +struct AcceptedPeerReservation { + queue: Arc, + peer: iroh::EndpointId, + active: bool, +} + +impl AcceptedPeerQueue { + fn reserve( + self: &Arc, + peer: iroh::EndpointId, + ) -> Result { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + if state.closed { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple authenticated-controller queue is closed", + true, + )); + } + let already_counted = state.latest.contains_key(&peer) + || state.pending.contains_key(&peer) + || state.reservations.contains_key(&peer); + let mut unique = state.latest.keys().copied().collect::>(); + unique.extend(state.pending.keys().copied()); + unique.extend(state.reservations.keys().copied()); + let unique_count = unique.len(); + if !already_counted && unique_count >= MAX_ACCEPTED_CONNECTION_QUEUE { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple authenticated-controller queue is full", + true, + )); + } + *state.reservations.entry(peer).or_insert(0) += 1; + Ok(AcceptedPeerReservation { + queue: self.clone(), + peer, + active: true, + }) + } + + async fn recv(&self) -> Result { + loop { + let notified = self.ready.notified(); + { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + let ready_count = state.ready_order.len(); + for _ in 0..ready_count { + let Some(peer) = state.ready_order.pop_front() else { + break; + }; + if let Some(candidate) = state.latest.remove(&peer) { + if candidate.connection.close_reason().is_none() { + return Ok(candidate); + } + } + } + if state.closed { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple authenticated-controller queue is closed", + true, + )); + } + } + notified.await; + } + } + + fn close(&self) { + let candidates = match self.state.lock() { + Ok(mut state) => { + state.closed = true; + state.ready_order.clear(); + state.reservations.clear(); + let candidates = state + .latest + .drain() + .map(|(_, peer)| peer) + .collect::>(); + for (_, pending) in state.pending.drain() { + pending.close(); + } + candidates + } + Err(_) => Vec::new(), + }; + for candidate in candidates { + candidate.close(); + } + self.ready.notify_waiters(); + } +} + +impl AcceptedPeerReservation { + fn publish(mut self, candidate: PendingConnectedPeer) -> Result<(), ProtocolError> { + if candidate.remote_id() != self.peer { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "accepted queue reservation belongs to another controller", + false, + )); + } + { + let mut state = self + .queue + .state + .lock() + .map_err(|_| internal_state_error())?; + if state.closed || candidate.connection.close_reason().is_some() { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple authenticated-controller candidate is no longer usable", + true, + )); + } + release_queue_reservation(&mut state, self.peer); + self.active = false; + if state.pending.contains_key(&self.peer) + || state + .latest + .get(&self.peer) + .is_some_and(|queued| queued.connection_stamp() >= candidate.connection_stamp()) + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "accepted controller queue already contains a newer generation", + true, + )); + } + state.pending.insert(self.peer, candidate); + } + Ok(()) + } +} + +impl AcceptedPeerQueue { + fn finalize_candidate( + &self, + peer: iroh::EndpointId, + stamp: ConnectionStamp, + ) -> Result, ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + if state.closed + || !state + .pending + .get(&peer) + .is_some_and(|candidate| candidate.connection_stamp() == stamp) + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "authenticated-controller candidate changed before finalization", + true, + )); + } + let candidate = state + .pending + .remove(&peer) + .expect("validated pending controller candidate") + .finalize(); + let displaced = state.latest.insert(peer, candidate); + if !state.ready_order.contains(&peer) { + state.ready_order.push_back(peer); + } + self.ready.notify_one(); + Ok(displaced) + } + + fn rollback_candidate( + &self, + peer: iroh::EndpointId, + stamp: ConnectionStamp, + ) -> Result<(), ProtocolError> { + let candidate = { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + if state + .pending + .get(&peer) + .is_some_and(|candidate| candidate.connection_stamp() == stamp) + { + state.pending.remove(&peer) + } else { + None + } + }; + if let Some(candidate) = candidate { + candidate.close(); + } + Ok(()) + } +} + +fn rollback_staged_incoming( + admission: &PeerAdmission, + accepted_connections: &AcceptedPeerQueue, + commit: &mut IncomingCommit, +) -> Result<(), ProtocolError> { + // Keep the admission activation token while restoring the keyed queue, so + // a racing generation cannot publish between these two state machines. + accepted_connections.rollback_candidate(commit.peer, commit.stamp)?; + admission.rollback_incoming(commit) +} + +impl Drop for AcceptedPeerReservation { + fn drop(&mut self) { + if !self.active { + return; + } + if let Ok(mut state) = self.queue.state.lock() { + release_queue_reservation(&mut state, self.peer); + } + } +} + +fn release_queue_reservation(state: &mut AcceptedPeerQueueState, peer: iroh::EndpointId) { + if let Some(count) = state.reservations.get_mut(&peer) { + *count -= 1; + if *count == 0 { + state.reservations.remove(&peer); + } + } +} + +impl std::fmt::Debug for MapleIrohEndpoint { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MapleIrohEndpoint") + .field("endpoint_id", &self.endpoint.id()) + .field("execution_target_id", &self.execution_target_id) + .field("relay_policy", &self.relay_policy) + .finish_non_exhaustive() + } +} + +/// Transport-handle-free controller lineage for reconstructing a generation +/// manager after a runtime or endpoint rebuild. +/// +/// This is intentionally an in-memory typed handoff rather than a persistence +/// format. In particular, an ambiguous post-Observed handover carries the +/// exact [`PendingCommit`] that must be reconciled before a normal dial. +#[derive(Debug, PartialEq, Eq)] +pub struct GenerationLineageSnapshot { + expected_controller: Option, + expected_remote: iroh::EndpointId, + expected_execution_target_id: Arc, + expected_direction: PeerDirection, + pairing_fence: PairingFence, + minimum_accepted: Option, + last_committed: Option, + pending_reconciliation: Option, +} + +impl GenerationLineageSnapshot { + pub fn expected_remote(&self) -> iroh::EndpointId { + self.expected_remote + } + + pub fn execution_target_id(&self) -> &str { + &self.expected_execution_target_id + } + + pub fn pairing_fence(&self) -> PairingFence { + self.pairing_fence + } + + pub fn replay_floor(&self) -> Option { + self.minimum_accepted + } + + pub fn last_committed(&self) -> Option { + self.last_committed + } + + pub fn requires_reconciliation(&self) -> bool { + self.pending_reconciliation.is_some() + } +} + +fn validate_generation_lineage( + expected_controller: Option, + expected_remote: iroh::EndpointId, + expected_execution_target_id: &str, + expected_direction: PeerDirection, + pairing_fence: PairingFence, + minimum_accepted: Option, + last_committed: Option, + pending_reconciliation: Option<&PendingCommit>, +) -> Result<(), ProtocolError> { + validate_bootstrap_id("execution_target_id", expected_execution_target_id)?; + pairing_fence.validate()?; + if expected_direction != PeerDirection::ControllerToHost { + return Err(ProtocolError::new( + ErrorCode::WrongDirection, + "controller lineage has the wrong connection direction", + false, + )); + } + if let Some(minimum) = minimum_accepted { + minimum.validate()?; + } + if let Some(committed) = last_committed { + committed.validate()?; + if minimum_accepted != Some(committed) { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "controller replay floor does not match committed lineage", + false, + )); + } + } + if let Some(pending) = pending_reconciliation { + let controller = expected_controller.ok_or_else(|| { + ProtocolError::new( + ErrorCode::Unauthorized, + "pending reconciliation requires an explicit controller binding", + false, + ) + })?; + pending.validate( + expected_execution_target_id, + controller, + expected_remote, + pairing_fence, + )?; + if pending.previous_connection_stamp != last_committed + || minimum_accepted.is_some_and(|minimum| pending.candidate_connection_stamp <= minimum) + { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "pending reconciliation does not advance controller lineage", + false, + )); + } + } + Ok(()) +} + +/// Owns exactly one current connection for a peer. Reconnect attempts may run +/// concurrently; the first successful result for a newer generation becomes +/// current, and every stale/losing connection is closed immediately. +#[derive(Debug)] +pub struct GenerationConnectionManager { + expected_controller: Option, + expected_remote: iroh::EndpointId, + expected_execution_target_id: Arc, + expected_direction: PeerDirection, + pairing_fence: PairingFence, + state: Mutex, +} + +#[derive(Debug)] +struct GenerationManagerState { + minimum_accepted: Option, + /// The last generation for which this controller received a correlated + /// host Finalized marker. This is protocol lineage, not a liveness claim, + /// and therefore survives pruning a closed `current` handle. + last_committed: Option, + current: Option, + handover: Option, +} + +#[derive(Debug)] +enum ManagedHandover { + Prepared { + token: HandoverToken, + candidate: ConnectedPeer, + }, + Promoted { + token: HandoverToken, + fallback: Option, + }, + AwaitingFinalized { + token: HandoverToken, + fallback: Option, + }, + /// Reconstructed post-Observed ambiguity. No transport handle is trusted; + /// the exact pending transition must be reconciled with the host first. + ReconciliationRequired { pending: PendingCommit }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct HandoverToken { + previous_stamp: Option, + candidate_stamp: ConnectionStamp, + candidate_id: usize, + pending: PendingCommit, +} + +/// A reconnect candidate which has been validated but is not yet primary. +/// +/// Dropping this guard closes the candidate and leaves the previous connection +/// untouched. This makes cancellation before the host commit marker safe. +#[derive(Debug)] +pub struct PreparedHandover<'a> { + manager: &'a GenerationConnectionManager, + token: Option, +} + +/// A reconnect candidate which is primary while the previous live connection +/// is retained as a rollback fallback. +/// +/// Dropping this guard restores the fallback when it remains live. Once the +/// commit-observed marker may have reached the host, `mark_observed_sent` +/// consumes this guard into [`AwaitingFinalizedHandover`] instead. +#[derive(Debug)] +pub struct PromotedHandover<'a> { + manager: &'a GenerationConnectionManager, + token: Option, +} + +/// A handover whose commit-observed marker may have reached the host. +/// +/// This state is intentionally not resolved from connection liveness. Only a +/// correlated host Finalized marker may advance logical lineage to B; dropping +/// the guard leaves the manager blocked in this ambiguous state until `clear`. +#[derive(Debug)] +pub struct AwaitingFinalizedHandover<'a> { + manager: &'a GenerationConnectionManager, + token: Option, +} + +impl GenerationConnectionManager { + pub fn new( + expected_remote: iroh::EndpointId, + expected_execution_target_id: impl Into>, + minimum_accepted: Option, + ) -> Result { + Self::new_for_direction( + None, + expected_remote, + expected_execution_target_id, + PeerDirection::ControllerToHost, + PairingFence::new(PairingIncarnation::new(1)?)?, + minimum_accepted, + ) + } + + pub fn new_for_pairing( + expected_controller: iroh::EndpointId, + expected_remote: iroh::EndpointId, + expected_execution_target_id: impl Into>, + pairing_fence: PairingFence, + minimum_accepted: Option, + ) -> Result { + Self::new_for_direction( + Some(expected_controller), + expected_remote, + expected_execution_target_id, + PeerDirection::ControllerToHost, + pairing_fence, + minimum_accepted, + ) + } + + fn new_for_direction( + expected_controller: Option, + expected_remote: iroh::EndpointId, + expected_execution_target_id: impl Into>, + expected_direction: PeerDirection, + pairing_fence: PairingFence, + minimum_accepted: Option, + ) -> Result { + let expected_execution_target_id = expected_execution_target_id.into(); + validate_bootstrap_id("execution_target_id", &expected_execution_target_id)?; + if let Some(stamp) = minimum_accepted { + stamp.validate()?; + } + pairing_fence.validate()?; + Ok(Self { + expected_controller, + expected_remote, + expected_execution_target_id, + expected_direction, + pairing_fence, + state: Mutex::new(GenerationManagerState { + minimum_accepted, + last_committed: None, + current: None, + handover: None, + }), + }) + } + + /// Capture controller lineage without exporting a live QUIC handle. + /// Prepared or merely promoted candidates are cancellable local state and + /// therefore cannot cross a reconstruction boundary. Post-Observed + /// ambiguity is retained as an exact reconciliation obligation. + pub fn capture_lineage(self) -> Result { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + Self::prune_closed_current(&mut state); + let pending_reconciliation = match state.handover.as_ref() { + None => None, + Some(ManagedHandover::AwaitingFinalized { token, .. }) => Some(token.pending.clone()), + Some(ManagedHandover::ReconciliationRequired { pending }) => Some(pending.clone()), + Some(ManagedHandover::Prepared { .. } | ManagedHandover::Promoted { .. }) => { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "controller lineage cannot be captured before commit observation", + true, + )); + } + }; + if state.handover.is_none() + && state.current.as_ref().is_some_and(|current| { + Some(current.connection_stamp()) != state.last_committed + || self.validate_candidate(current).is_err() + }) + { + return Err(ProtocolError::new( + ErrorCode::Internal, + "controller manager current handle does not match committed lineage", + false, + )); + } + validate_generation_lineage( + self.expected_controller, + self.expected_remote, + &self.expected_execution_target_id, + self.expected_direction, + self.pairing_fence, + state.minimum_accepted, + state.last_committed, + pending_reconciliation.as_ref(), + )?; + let snapshot = GenerationLineageSnapshot { + expected_controller: self.expected_controller, + expected_remote: self.expected_remote, + expected_execution_target_id: self.expected_execution_target_id.clone(), + expected_direction: self.expected_direction, + pairing_fence: self.pairing_fence, + minimum_accepted: state.minimum_accepted, + last_committed: state.last_committed, + pending_reconciliation, + }; + drop(state); + // This is an ownership transfer, not a clone. Close every transport + // handle and fence the consumed manager so two coordinators cannot + // advance the same logical lineage concurrently. + self.clear()?; + Ok(snapshot) + } + + /// Reconstruct a controller manager under independently supplied current + /// pairing truth. Every identity, target, direction, account, incarnation, + /// and stamp invariant must match the captured lineage exactly. The new + /// manager intentionally starts without a live transport handle. + pub fn restore_for_pairing( + snapshot: GenerationLineageSnapshot, + expected_controller: iroh::EndpointId, + expected_remote: iroh::EndpointId, + expected_execution_target_id: impl Into>, + pairing_fence: PairingFence, + ) -> Result { + let expected_execution_target_id = expected_execution_target_id.into(); + validate_bootstrap_id("execution_target_id", &expected_execution_target_id)?; + pairing_fence.validate()?; + if snapshot.expected_controller != Some(expected_controller) + || snapshot.expected_remote != expected_remote + || snapshot.expected_execution_target_id != expected_execution_target_id + || snapshot.expected_direction != PeerDirection::ControllerToHost + || snapshot.pairing_fence != pairing_fence + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "controller lineage does not match the current pairing", + false, + )); + } + validate_generation_lineage( + Some(expected_controller), + expected_remote, + &expected_execution_target_id, + PeerDirection::ControllerToHost, + pairing_fence, + snapshot.minimum_accepted, + snapshot.last_committed, + snapshot.pending_reconciliation.as_ref(), + )?; + let handover = snapshot + .pending_reconciliation + .map(|pending| ManagedHandover::ReconciliationRequired { pending }); + Ok(Self { + expected_controller: Some(expected_controller), + expected_remote, + expected_execution_target_id, + expected_direction: PeerDirection::ControllerToHost, + pairing_fence, + state: Mutex::new(GenerationManagerState { + minimum_accepted: snapshot.minimum_accepted, + last_committed: snapshot.last_committed, + current: None, + handover, + }), + }) + } + + fn current_stamp(&self) -> Result, ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + Self::prune_closed_current(&mut state); + Ok(state.last_committed) + } + + fn pending_reconciliation(&self) -> Result, ProtocolError> { + let state = self.state.lock().map_err(|_| internal_state_error())?; + Ok(match state.handover.as_ref() { + Some(ManagedHandover::AwaitingFinalized { token, .. }) => Some(token.pending.clone()), + Some(ManagedHandover::ReconciliationRequired { pending }) => Some(pending.clone()), + _ => None, + }) + } + + fn apply_reconciliation( + &self, + pending: &PendingCommit, + committed: Option, + ) -> Result<(), ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + let matches = match state.handover.as_ref() { + Some(ManagedHandover::AwaitingFinalized { token, .. }) => &token.pending == pending, + Some(ManagedHandover::ReconciliationRequired { pending: stored }) => stored == pending, + _ => false, + }; + if !matches { + if state.handover.is_none() + && state.last_committed == Some(pending.candidate_connection_stamp) + && committed == Some(pending.candidate_connection_stamp) + { + return Ok(()); + } + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "reconciliation no longer matches the pending handover", + true, + )); + } + if committed == Some(pending.candidate_connection_stamp) { + let handover = state + .handover + .take() + .expect("validated pending reconciliation"); + if let ManagedHandover::AwaitingFinalized { fallback, .. } = handover { + if let Some(fallback) = fallback { + fallback.close(); + } + } + if let Some(candidate) = state.current.take() { + if candidate.connection_stamp() == pending.candidate_connection_stamp + && candidate.connection.close_reason().is_none() + { + state.current = Some(candidate); + } else { + candidate.close(); + } + } + // The authenticated host decision is definitive even without a + // live B handle. Apply it under this single manager lock so clear + // or another reader cannot interleave between selection and commit. + state.last_committed = Some(pending.candidate_connection_stamp); + state.minimum_accepted = Some(pending.candidate_connection_stamp); + return Ok(()); + } + if committed != pending.previous_connection_stamp { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "reconciliation returned an unrelated lineage", + true, + )); + } + match state.handover.take() { + Some(ManagedHandover::AwaitingFinalized { fallback, .. }) => { + if let Some(candidate) = state.current.take() { + candidate.close(); + } + if let Some(fallback) = fallback { + if fallback.connection.close_reason().is_none() { + state.current = Some(fallback); + } else { + fallback.close(); + } + } + } + Some(ManagedHandover::ReconciliationRequired { .. }) => {} + _ => unreachable!("validated pending reconciliation"), + } + // A remains the logical predecessor even if its transport handle is + // gone. Never advance the replay floor to the uncommitted candidate. + state.last_committed = pending.previous_connection_stamp; + Ok(()) + } + + pub fn begin_handover( + &self, + candidate: ConnectedPeer, + pending: PendingCommit, + ) -> Result, ProtocolError> { + if let Err(error) = self.validate_candidate(&candidate) { + candidate.close(); + return Err(error); + } + let mut state = match self.state.lock() { + Ok(state) => state, + Err(_) => { + candidate.close(); + return Err(internal_state_error()); + } + }; + if candidate.connection.close_reason().is_some() { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "reconnect candidate closed before staging", + true, + )); + } + Self::prune_closed_current(&mut state); + let actual_previous = state.last_committed; + let expected_controller = self.expected_controller.ok_or_else(|| { + ProtocolError::new( + ErrorCode::Unauthorized, + "managed handover requires an explicit local controller binding", + false, + ) + })?; + if let Err(error) = pending.validate( + &self.expected_execution_target_id, + expected_controller, + self.expected_remote, + self.pairing_fence, + ) { + candidate.close(); + return Err(error); + } + if pending.candidate_connection_stamp != candidate.connection_stamp() + || actual_previous != pending.previous_connection_stamp + || state.handover.is_some() + { + candidate.close(); + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "connection manager changed during handover", + true, + )); + } + if state + .minimum_accepted + .is_some_and(|minimum| candidate.connection_stamp() <= minimum) + || actual_previous.is_some_and(|current| current >= candidate.connection_stamp()) + { + candidate.close(); + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "handover candidate does not advance the host generation", + true, + )); + } + let token = HandoverToken { + previous_stamp: actual_previous, + candidate_stamp: candidate.connection_stamp(), + candidate_id: candidate.connection.stable_id(), + pending, + }; + state.handover = Some(ManagedHandover::Prepared { + token: token.clone(), + candidate, + }); + Ok(PreparedHandover { + manager: self, + token: Some(token), + }) + } + + fn promote_handover(&self, token: &HandoverToken) -> Result<(), ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + if state.last_committed != token.previous_stamp { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "committed connection lineage changed before handover promotion", + true, + )); + } + let candidate_matches = matches!( + state.handover.as_ref(), + Some(ManagedHandover::Prepared { + token: active_token, + candidate, + }) if active_token == token && candidate.connection.close_reason().is_none() + ); + if !candidate_matches { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "prepared handover candidate is no longer usable", + true, + )); + } + let Some(ManagedHandover::Prepared { candidate, .. }) = state.handover.take() else { + unreachable!("validated prepared handover"); + }; + let fallback = state.current.replace(candidate); + state.handover = Some(ManagedHandover::Promoted { + token: token.clone(), + fallback, + }); + Ok(()) + } + + fn mark_observed_sent(&self, token: &HandoverToken) -> Result<(), ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + let matches = matches!( + state.handover.as_ref(), + Some(ManagedHandover::Promoted { + token: active_token, + .. + }) if active_token == token + ) && state.current.as_ref().is_some_and(|candidate| { + candidate.connection_stamp() == token.candidate_stamp + && candidate.connection.stable_id() == token.candidate_id + && candidate.connection.close_reason().is_none() + }); + if !matches { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "promoted handover candidate cannot acknowledge commit", + true, + )); + } + let Some(ManagedHandover::Promoted { fallback, .. }) = state.handover.take() else { + unreachable!("validated promoted handover"); + }; + state.handover = Some(ManagedHandover::AwaitingFinalized { + token: token.clone(), + fallback, + }); + Ok(()) + } + + fn rollback_prepared(&self, token: &HandoverToken) -> Result<(), ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + let matches = matches!( + state.handover.as_ref(), + Some(ManagedHandover::Prepared { + token: active_token, + .. + }) if active_token == token + ); + if matches { + if let Some(ManagedHandover::Prepared { candidate, .. }) = state.handover.take() { + candidate.close(); + } + } + Ok(()) + } + + fn rollback_promoted(&self, token: &HandoverToken) -> Result<(), ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + let matches = matches!( + state.handover.as_ref(), + Some(ManagedHandover::Promoted { + token: active_token, + .. + }) if active_token == token + ); + if !matches { + return Ok(()); + } + let Some(ManagedHandover::Promoted { fallback, .. }) = state.handover.take() else { + unreachable!("validated promoted handover"); + }; + match fallback { + Some(previous) if previous.connection.close_reason().is_none() => { + if let Some(candidate) = state.current.replace(previous) { + candidate.close(); + } + } + fallback => { + if let Some(fallback) = fallback { + fallback.close(); + } + // Before the observed marker may have reached the host, B is + // not committed lineage. If A cannot be restored, retain A's + // logical stamp and reconnect from it rather than adopting B + // based on transport liveness. + if let Some(candidate) = state.current.take() { + candidate.close(); + } + } + } + Ok(()) + } + + fn finalize_awaiting(&self, token: &HandoverToken) -> Result { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + if state.last_committed == Some(token.candidate_stamp) && state.handover.is_none() { + return match state.current.as_ref() { + Some(candidate) + if candidate.connection_stamp() == token.candidate_stamp + && candidate.connection.stable_id() == token.candidate_id + && candidate.connection.close_reason().is_none() => + { + Ok(candidate.clone()) + } + _ => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "finalized handover lineage is committed without a live connection", + true, + )), + }; + } + let handover_matches = matches!( + state.handover.as_ref(), + Some(ManagedHandover::AwaitingFinalized { token: active_token, .. }) + if active_token == token + ); + let current_matches = state.current.as_ref().is_none_or(|candidate| { + candidate.connection_stamp() == token.candidate_stamp + && candidate.connection.stable_id() == token.candidate_id + }); + if !handover_matches || !current_matches { + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "finalized marker does not match the awaiting handover", + true, + )); + } + let Some(ManagedHandover::AwaitingFinalized { fallback, .. }) = state.handover.take() + else { + unreachable!("validated awaiting-finalized handover"); + }; + if let Some(previous) = fallback { + previous.close(); + } + state.last_committed = Some(token.candidate_stamp); + state.minimum_accepted = Some(token.candidate_stamp); + match state.current.take() { + Some(candidate) if candidate.connection.close_reason().is_none() => { + state.current = Some(candidate.clone()); + Ok(candidate) + } + Some(candidate) => { + candidate.close(); + Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "finalized handover committed a connection that is no longer live", + true, + )) + } + None => Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "finalized handover has no live connection handle", + true, + )), + } + } + + fn prune_closed_current(state: &mut GenerationManagerState) { + if state.handover.is_none() + && state + .current + .as_ref() + .is_some_and(|peer| peer.connection.close_reason().is_some()) + { + if let Some(peer) = state.current.take() { + peer.close(); + } + } + } + + fn validate_candidate(&self, candidate: &ConnectedPeer) -> Result<(), ProtocolError> { + if candidate.remote_id() != self.expected_remote + || candidate.execution_target_id() != self.expected_execution_target_id.as_ref() + || candidate.outbound_direction != self.expected_direction + { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "connection manager cannot migrate between execution targets", + false, + )); + } + Ok(()) + } + + pub fn current(&self) -> Result, ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + if state.handover.is_some() { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "connection handover has not reached a correlated finalized marker", + true, + )); + } + Self::prune_closed_current(&mut state); + Ok(state.current.clone()) + } + + pub fn install_first(&self, candidate: ConnectedPeer) -> Result { + if let Err(error) = self.validate_candidate(&candidate) { + candidate.close(); + return Err(error); + } + if candidate.connection.close_reason().is_some() { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "completed reconnect candidate is already closed", + true, + )); + } + let mut state = match self.state.lock() { + Ok(state) => state, + Err(_) => { + candidate.close(); + return Err(internal_state_error()); + } + }; + Self::prune_closed_current(&mut state); + if state.handover.is_some() || state.last_committed.is_some() { + candidate.close(); + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "install_first is only valid before a connection lineage is committed", + true, + )); + } + // The candidate may have closed while waiting for another racing + // installer to release the manager lock. Never let a dead, newer stamp + // displace a still-usable generation. + if candidate.connection.close_reason().is_some() { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "completed reconnect candidate closed before installation", + true, + )); + } + if state + .minimum_accepted + .is_some_and(|minimum| candidate.connection_stamp() <= minimum) + { + candidate.close(); + return Err(ProtocolError::new( + ErrorCode::StaleGeneration, + "connection stamp does not advance the persisted host floor", + true, + )); + } + if state.current.is_some() { + candidate.close(); + return Err(ProtocolError::new( + ErrorCode::Internal, + "uncommitted connection manager unexpectedly owns a current handle", + false, + )); + } + state.current = Some(candidate.clone()); + state.last_committed = Some(candidate.connection_stamp()); + state.minimum_accepted = Some(candidate.connection_stamp()); + Ok(candidate) + } + + pub fn clear(&self) -> Result<(), ProtocolError> { + let mut state = self.state.lock().map_err(|_| internal_state_error())?; + if let Some(handover) = state.handover.take() { + match handover { + ManagedHandover::Prepared { candidate, .. } => candidate.close(), + ManagedHandover::Promoted { fallback, .. } => { + if let Some(fallback) = fallback { + fallback.close(); + } + } + ManagedHandover::AwaitingFinalized { fallback, .. } => { + if let Some(fallback) = fallback { + fallback.close(); + } + } + ManagedHandover::ReconciliationRequired { .. } => {} + } + } + if let Some(connection) = state.current.take() { + connection.close(); + } + // `clear` is an explicit local fence: callers must re-establish the + // remote lineage rather than silently reconnecting from stale state. + state.last_committed = None; + state.minimum_accepted = None; + Ok(()) + } +} + +impl<'a> PreparedHandover<'a> { + /// Makes the candidate primary without releasing the previous connection. + pub fn promote(mut self) -> Result, ProtocolError> { + let token = self.token.as_ref().expect("active prepared handover"); + self.manager.promote_handover(token)?; + let token = self.token.take().expect("active prepared handover"); + Ok(PromotedHandover { + manager: self.manager, + token: Some(token), + }) + } + + /// Explicitly rolls back this candidate. Dropping the guard is equivalent. + pub fn rollback(mut self) -> Result<(), ProtocolError> { + let token = self.token.take().expect("active prepared handover"); + self.manager.rollback_prepared(&token) + } +} + +impl Drop for PreparedHandover<'_> { + fn drop(&mut self) { + if let Some(token) = self.token.take() { + let _ = self.manager.rollback_prepared(&token); + } + } +} + +impl<'a> PromotedHandover<'a> { + /// Records that the commit-observed marker has been sent to the host. + /// From this point, cancellation must not restore A blindly: the host may + /// already have committed B and started retiring A. + pub fn mark_observed_sent(mut self) -> Result, ProtocolError> { + let token = self.token.as_ref().expect("active promoted handover"); + self.manager.mark_observed_sent(token)?; + let token = self.token.take().expect("active promoted handover"); + Ok(AwaitingFinalizedHandover { + manager: self.manager, + token: Some(token), + }) + } + + /// Explicitly restores the live fallback. Dropping the guard is equivalent. + pub fn rollback(mut self) -> Result<(), ProtocolError> { + let token = self.token.take().expect("active promoted handover"); + self.manager.rollback_promoted(&token) + } +} + +impl Drop for PromotedHandover<'_> { + fn drop(&mut self) { + if let Some(token) = self.token.take() { + let _ = self.manager.rollback_promoted(&token); + } + } +} + +impl AwaitingFinalizedHandover<'_> { + /// Applies a correlated Finalized decision. Logical lineage advances to B + /// even when its transport handle died while the marker was in flight. + pub fn finalize(mut self) -> Result { + let token = self + .token + .as_ref() + .expect("active awaiting-finalized handover"); + let candidate = self.manager.finalize_awaiting(token); + // A matching Finalized decision is terminal even when B is dead and + // `finalize_awaiting` returns a retryable transport error. + self.token.take(); + candidate + } +} + +impl Drop for AwaitingFinalizedHandover<'_> { + fn drop(&mut self) { + if self.token.take().is_some() { + // Deliberately leave the manager in AwaitingFinalized. Transport + // liveness cannot resolve whether the host committed B. + } + } +} + +impl MapleIrohEndpoint { + #[cfg(test)] + fn test_policy() -> ConnectionPolicy { + ConnectionPolicy { + connect_deadline: Duration::from_secs(5), + handshake_deadline: Duration::from_secs(2), + frame_deadline: Duration::from_secs(2), + } + } + + /// Build a direct-IP endpoint without N0 discovery and without a relay. + /// This binds only the loopback interface so local tests never depend on + /// host routing, VPN interfaces, or external connectivity. Product + /// construction should call [`Self::bind_with_relay_policy`] with + /// Maple-owned relays. + #[cfg(test)] + pub async fn bind_direct( + identity: &DeviceIdentity, + execution_target_id: &str, + host_clock: HostConnectionClock, + ) -> Result { + let secret_key = identity.iroh_secret_key().map_err(|error| { + ProtocolError::new( + ErrorCode::SecureStorageUnavailable, + error.to_string(), + false, + ) + })?; + let admission = PeerAdmission::default(); + let relay_policy = RelayPolicy::disabled(); + let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .clear_address_lookup() + .relay_mode(relay_policy.mode()) + .transport_config(bounded_transport_config()?) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]) + .hooks(admission.clone()) + .clear_ip_transports() + .bind_addr_with_opts( + "127.0.0.1:0", + iroh::endpoint::BindOpts::default().set_prefix_len(8), + ) + .map_err(|error| { + transport_error( + "failed to configure local Maple Iroh endpoint", + error, + false, + ) + })? + .bind() + .await + .map_err(|error| transport_error("failed to bind Maple Iroh endpoint", error, true))?; + Self::from_bound_endpoint( + endpoint, + admission, + relay_policy, + Self::test_policy(), + execution_target_id, + host_clock, + ) + } + + /// Test-only direct-IP reconstruction path. The independent current + /// authorization snapshot is installed first; only lineage entries with + /// the same account and pairing incarnation are restored before the accept + /// pump starts. + #[cfg(test)] + async fn bind_direct_restoring_lineage( + identity: &DeviceIdentity, + execution_target_id: &str, + host_clock: HostConnectionClock, + authorization_snapshot: AuthorizationSnapshot, + handoff: &mut EndpointLineageHandoff, + ) -> Result { + handoff.authorize_restore_attempt(&authorization_snapshot)?; + validate_bootstrap_id("execution_target_id", execution_target_id)?; + let local_endpoint = identity_endpoint_id(identity)?; + let secret_key = identity.iroh_secret_key().map_err(|error| { + ProtocolError::new( + ErrorCode::SecureStorageUnavailable, + error.to_string(), + false, + ) + })?; + handoff.ensure_source_closed().await?; + let admission = PeerAdmission::default(); + admission.replace_authorizations(authorization_snapshot)?; + admission.restore_endpoint_lineage( + local_endpoint, + execution_target_id, + handoff.snapshot()?, + )?; + if handoff.take_fail_next_bind() { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "test-injected rebuilt endpoint bind failure", + true, + )); + } + let relay_policy = RelayPolicy::disabled(); + let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .clear_address_lookup() + .relay_mode(relay_policy.mode()) + .transport_config(bounded_transport_config()?) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]) + .hooks(admission.clone()) + .clear_ip_transports() + .bind_addr_with_opts( + "127.0.0.1:0", + iroh::endpoint::BindOpts::default().set_prefix_len(8), + ) + .map_err(|error| { + transport_error( + "failed to configure rebuilt local Maple Iroh endpoint", + error, + false, + ) + })? + .bind() + .await + .map_err(|error| { + transport_error("failed to rebuild Maple Iroh endpoint", error, true) + })?; + let rebuilt = Self::from_bound_endpoint( + endpoint, + admission, + relay_policy, + Self::test_policy(), + execution_target_id, + host_clock, + )?; + handoff.consume_after_commit(); + Ok(rebuilt) + } + + /// Test-only endpoint with IP transports disabled and Iroh's official + /// production public relay map enabled. This is reserved for the ignored, + /// synthetic live smoke test; product code supplies Maple's relay policy. + #[cfg(test)] + async fn bind_public_relay_only( + identity: &DeviceIdentity, + execution_target_id: &str, + host_clock: HostConnectionClock, + connection_policy: ConnectionPolicy, + ) -> Result { + let secret_key = identity.iroh_secret_key().map_err(|error| { + ProtocolError::new( + ErrorCode::SecureStorageUnavailable, + error.to_string(), + false, + ) + })?; + let admission = PeerAdmission::default(); + let relay_policy = RelayPolicy::ignored_public_smoke()?; + let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .clear_address_lookup() + .relay_mode(relay_policy.mode()) + .transport_config(bounded_transport_config()?) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]) + .hooks(admission.clone()) + .clear_ip_transports() + .bind() + .await + .map_err(|error| { + transport_error("failed to bind relay-only Maple Iroh endpoint", error, true) + })?; + Self::from_bound_endpoint( + endpoint, + admission, + relay_policy, + connection_policy, + execution_target_id, + host_clock, + ) + } + + /// Bind using an explicit relay policy. The caller may supply a custom + /// Maple relay map; this function never enables Iroh's N0 DNS discovery. + /// The opaque runtime couples the identity and already-persisted host epoch + /// before this function can reach Iroh's bind operation. + pub async fn bind_with_relay_policy( + runtime: &DurableHostRuntime, + execution_target_id: &str, + relay_policy: RelayPolicy, + connection_policy: ConnectionPolicy, + ) -> Result { + validate_bootstrap_id("execution_target_id", execution_target_id)?; + let identity = runtime.identity(); + let secret_key = identity.iroh_secret_key().map_err(|error| { + ProtocolError::new( + ErrorCode::SecureStorageUnavailable, + error.to_string(), + false, + ) + })?; + let admission = PeerAdmission::default(); + let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .clear_address_lookup() + .relay_mode(relay_policy.mode()) + .transport_config(bounded_transport_config()?) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]) + .hooks(admission.clone()) + .bind() + .await + .map_err(|error| transport_error("failed to bind Maple Iroh endpoint", error, true))?; + Self::from_bound_endpoint( + endpoint, + admission, + relay_policy, + connection_policy, + execution_target_id, + runtime.host_clock(), + ) + } + + /// Rebuild a host endpoint from a quiescent in-memory lineage handoff. + /// Authorization is deliberately supplied independently: authorization is + /// current control-plane truth, while the snapshot carries only retained + /// generation lineage for exact matching pair incarnations. + pub async fn bind_with_relay_policy_restoring_lineage( + runtime: &DurableHostRuntime, + execution_target_id: &str, + authorization_snapshot: AuthorizationSnapshot, + handoff: &mut EndpointLineageHandoff, + relay_policy: RelayPolicy, + connection_policy: ConnectionPolicy, + ) -> Result { + handoff.authorize_restore_attempt(&authorization_snapshot)?; + validate_bootstrap_id("execution_target_id", execution_target_id)?; + let identity = runtime.identity(); + let local_endpoint = identity_endpoint_id(identity)?; + let secret_key = identity.iroh_secret_key().map_err(|error| { + ProtocolError::new( + ErrorCode::SecureStorageUnavailable, + error.to_string(), + false, + ) + })?; + handoff.ensure_source_closed().await?; + let admission = PeerAdmission::default(); + admission.replace_authorizations(authorization_snapshot)?; + admission.restore_endpoint_lineage( + local_endpoint, + execution_target_id, + handoff.snapshot()?, + )?; + let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .clear_address_lookup() + .relay_mode(relay_policy.mode()) + .transport_config(bounded_transport_config()?) + .secret_key(secret_key) + .alpns(vec![ALPN.to_vec()]) + .hooks(admission.clone()) + .bind() + .await + .map_err(|error| { + transport_error("failed to rebuild Maple Iroh endpoint", error, true) + })?; + let rebuilt = Self::from_bound_endpoint( + endpoint, + admission, + relay_policy, + connection_policy, + execution_target_id, + runtime.host_clock(), + )?; + handoff.consume_after_commit(); + Ok(rebuilt) + } + + fn from_bound_endpoint( + endpoint: iroh::Endpoint, + admission: PeerAdmission, + relay_policy: RelayPolicy, + connection_policy: ConnectionPolicy, + execution_target_id: &str, + host_clock: HostConnectionClock, + ) -> Result { + validate_bootstrap_id("execution_target_id", execution_target_id)?; + let execution_target_id: Arc = Arc::from(execution_target_id); + let accepted_connections = Arc::new(AcceptedPeerQueue::default()); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + spawn_accept_pump( + endpoint.clone(), + admission.clone(), + connection_policy, + execution_target_id.clone(), + host_clock, + accepted_connections.clone(), + shutdown_rx, + ); + Ok(Self { + endpoint, + admission, + connection_policy, + relay_policy, + execution_target_id, + accepted_connections, + shutdown: Mutex::new(Some(shutdown_tx)), + }) + } + + pub fn public_id(&self) -> String { + self.endpoint.id().to_string() + } + + /// Capture current addressing for an authenticated control-plane update. + /// The private identity is never included. + pub fn endpoint_addr(&self) -> iroh::EndpointAddr { + self.endpoint.addr() + } + + pub fn cached_endpoint_addr( + &self, + addr: iroh::EndpointAddr, + ) -> Result { + CachedEndpointAddr::new(addr, &self.relay_policy) + } + + /// Bootstrap/test-only imperative admission before an account snapshot is + /// installed. Production pairing and revocation use + /// [`Self::replace_authorizations`], whose durable revisions cannot race + /// unversioned mutations. One-way pairing remains explicit: permission to + /// dial never grants the peer reverse initiation. + pub fn authorize_outgoing_execution_target( + &self, + host: iroh::EndpointId, + ) -> Result<(), ProtocolError> { + self.admission.allow(iroh::endpoint::Side::Client, host) + } + + pub fn authorize_incoming_controller( + &self, + controller: iroh::EndpointId, + ) -> Result<(), ProtocolError> { + self.admission + .allow(iroh::endpoint::Side::Server, controller) + } + + pub fn revoke_outgoing_execution_target( + &self, + host: &iroh::EndpointId, + ) -> Result { + self.admission.revoke(iroh::endpoint::Side::Client, host) + } + + pub fn revoke_incoming_controller( + &self, + controller: &iroh::EndpointId, + ) -> Result { + self.admission + .revoke(iroh::endpoint::Side::Server, controller) + } + + /// Atomically install the complete authorization snapshot for an account + /// transition. Connections removed by the new snapshot close immediately. + pub fn replace_authorizations( + &self, + snapshot: AuthorizationSnapshot, + ) -> Result { + self.admission.replace_authorizations(snapshot) + } + + pub fn clear_authorizations_and_close(&self) -> Result<(), ProtocolError> { + self.admission.clear_all_and_close() + } + + /// Notify Iroh immediately when a Tauri platform reports a network change. + /// Android/iOS lifecycle glue calls this; it never waits on a new grant. + /// If a platform reports that the native socket is no longer viable, the + /// owner rebuilds via `bind_with_relay_policy` using the same DeviceIdentity + /// and the same HostConnectionClock, reapplies locally paired peer admission, + /// then races a newer generation through `connect_and_install_cached`. + pub async fn network_change(&self) -> Result<(), ProtocolError> { + tokio::time::timeout( + self.connection_policy.frame_deadline, + self.endpoint.network_change(), + ) + .await + .map_err(|_| operation_timeout("Maple network-change notification deadline elapsed"))?; + Ok(()) + } + + /// Fast path: immediately dial the cached address. The caller may refresh + /// the address in parallel, but a routine resume is never gated on it. + /// Production reconnects must use [`Self::connect_and_install_cached`] so + /// the controller retains committed lineage independently of a live QUIC + /// handle. This unmanaged entry point exists only for isolated transport + /// tests which intentionally exercise the first-generation bootstrap. + #[cfg(test)] + pub async fn connect_cached( + &self, + cached: &CachedEndpointAddr, + expected_endpoint: iroh::EndpointId, + request_id: &str, + execution_target_id: &str, + ) -> Result { + self.connect_cached_handover( + None, + cached, + expected_endpoint, + request_id, + execution_target_id, + ) + .await + } + + async fn connect_cached_handover<'a>( + &'a self, + manager: Option<&'a GenerationConnectionManager>, + cached: &CachedEndpointAddr, + expected_endpoint: iroh::EndpointId, + request_id: &str, + execution_target_id: &str, + ) -> Result { + validate_bootstrap_id("request_id", request_id)?; + validate_bootstrap_id("execution_target_id", execution_target_id)?; + let previous_connection_stamp = match manager { + Some(manager) => manager.current_stamp()?, + None => None, + }; + if cached.endpoint_id() != expected_endpoint { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "cached address does not belong to the paired endpoint", + false, + )); + } + let active_pairing_fence = self + .admission + .pairing_fence(iroh::endpoint::Side::Client, &expected_endpoint)?; + if let Some(manager) = manager { + if manager.expected_controller != Some(self.endpoint.id()) + || manager.expected_remote != expected_endpoint + || manager.expected_execution_target_id.as_ref() != execution_target_id + || manager.expected_direction != PeerDirection::ControllerToHost + || manager.pairing_fence != active_pairing_fence + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "connection manager does not match the current pairing", + false, + )); + } + } + self.relay_policy.validate_endpoint_addr(cached.as_iroh())?; + if !self + .admission + .is_allowed(iroh::endpoint::Side::Client, &expected_endpoint) + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "paired endpoint is not admitted", + false, + )); + } + let reconnect_deadline = tokio::time::Instant::now() + + self + .connection_policy + .connect_deadline + .max(self.connection_policy.frame_deadline); + let connection = tokio::time::timeout_at( + reconnect_deadline, + self.endpoint.connect(cached.as_iroh().clone(), ALPN), + ) + .await + .map_err(|_| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple host connection deadline elapsed", + true, + ) + })? + .map_err(|error| transport_error("failed to connect to Maple host", error, true))?; + if connection.remote_id() != expected_endpoint + || connection.alpn() != ALPN + || connection.side() != iroh::endpoint::Side::Client + { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "connected peer identity or protocol did not match pairing", + false, + )); + } + let registered_fence = self.admission.register(&connection)?; + validate_bootstrap_pairing_fence(registered_fence, active_pairing_fence)?; + let bootstrap = BootstrapRequest { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: execution_target_id.into(), + bootstrap_generation: 0, + pairing_fence: active_pairing_fence, + previous_connection_stamp, + reconciliation: None, + }; + let bootstrap_deadline = reconnect_deadline; + let (mut send, mut recv) = + tokio::time::timeout_at(bootstrap_deadline, connection.open_bi()) + .await + .map_err(|_| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple bootstrap stream-open deadline elapsed", + true, + ) + })? + .map_err(|error| { + transport_error("failed to open Maple bootstrap stream", error, true) + })?; + send.set_priority(StreamKind::Control.priority()) + .map_err(|error| { + transport_error("failed to prioritize Maple bootstrap stream", error, true) + })?; + write_frame_until(&mut send, &bootstrap, bootstrap_deadline).await?; + let response: BootstrapResponse = read_frame_until(&mut recv, bootstrap_deadline).await?; + let connection_stamp = + response.validate(request_id, execution_target_id, active_pairing_fence)?; + let ready: BootstrapReady = read_frame_until(&mut recv, bootstrap_deadline).await?; + ready.validate( + request_id, + execution_target_id, + self.endpoint.id(), + active_pairing_fence, + connection_stamp, + previous_connection_stamp, + )?; + let candidate = ConnectedPeer::new( + connection, + connection_stamp, + active_pairing_fence, + Arc::from(execution_target_id), + PeerDirection::ControllerToHost, + self.connection_policy.frame_deadline, + ); + let mut prepared = match manager { + Some(manager) => { + let pending = PendingCommit::new( + manager.pairing_fence, + request_id, + execution_target_id, + self.endpoint.id(), + expected_endpoint, + previous_connection_stamp, + connection_stamp, + )?; + Some(manager.begin_handover(candidate.clone(), pending)?) + } + None => None, + }; + if candidate.connection.close_reason().is_some() { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "handover candidate closed before installation acknowledgment", + true, + )); + } + let installed = BootstrapInstalled { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: execution_target_id.into(), + controller_id: self.endpoint.id().to_string(), + pairing_fence: active_pairing_fence, + connection_stamp, + previous_connection_stamp, + }; + write_frame_until(&mut send, &installed, bootstrap_deadline).await?; + let committed: BootstrapCommitted = read_frame_until(&mut recv, bootstrap_deadline).await?; + committed.validate( + request_id, + execution_target_id, + self.endpoint.id(), + active_pairing_fence, + connection_stamp, + previous_connection_stamp, + )?; + let promoted = match prepared.take() { + Some(prepared) => Some(prepared.promote()?), + None => None, + }; + let observed = BootstrapCommitObserved { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: execution_target_id.into(), + controller_id: self.endpoint.id().to_string(), + pairing_fence: active_pairing_fence, + connection_stamp, + previous_connection_stamp, + }; + // Mark the decision before attempting the write. A timeout can occur + // after QUIC accepted part or all of the frame, so rollback to A would + // be unsafe from this point onward. + let awaiting_finalized = match promoted { + Some(promoted) => Some(promoted.mark_observed_sent()?), + None => None, + }; + write_frame_until(&mut send, &observed, bootstrap_deadline).await?; + send.finish().map_err(|error| { + transport_error( + "failed to finish Maple bootstrap commit observation", + error, + true, + ) + })?; + let finalized: BootstrapFinalized = read_frame_until(&mut recv, bootstrap_deadline).await?; + finalized.validate( + request_id, + execution_target_id, + self.endpoint.id(), + active_pairing_fence, + connection_stamp, + previous_connection_stamp, + )?; + // A fully decoded and correlated Finalized frame is the irreversible + // controller decision. EOF is only strict framing hygiene and must not + // leave the manager ambiguous if the host FIN is lost. + let installed = match awaiting_finalized { + Some(awaiting) => awaiting.finalize(), + None => Ok(candidate), + }; + if let Err(error) = expect_stream_end(&mut recv, bootstrap_deadline).await { + return Err(error); + } + installed + } + + async fn reconcile_cached_pending( + &self, + manager: &GenerationConnectionManager, + cached: &CachedEndpointAddr, + expected_endpoint: iroh::EndpointId, + request_id: &str, + execution_target_id: &str, + pending: PendingCommit, + ) -> Result<(), ProtocolError> { + validate_bootstrap_id("request_id", request_id)?; + let active_fence = self + .admission + .pairing_fence(iroh::endpoint::Side::Client, &expected_endpoint)?; + pending.validate( + execution_target_id, + self.endpoint.id(), + expected_endpoint, + active_fence, + )?; + if manager.pairing_fence != active_fence + || manager.expected_controller != Some(self.endpoint.id()) + || manager.expected_remote != expected_endpoint + { + return Err(ProtocolError::new( + ErrorCode::Unauthorized, + "pending reconciliation does not match the current pairing", + false, + )); + } + self.relay_policy.validate_endpoint_addr(cached.as_iroh())?; + let deadline = tokio::time::Instant::now() + + self + .connection_policy + .connect_deadline + .max(self.connection_policy.frame_deadline); + let connection = tokio::time::timeout_at( + deadline, + self.endpoint.connect(cached.as_iroh().clone(), ALPN), + ) + .await + .map_err(|_| operation_timeout("Maple reconciliation connection deadline elapsed"))? + .map_err(|error| { + transport_error("failed to connect for Maple reconciliation", error, true) + })?; + if connection.remote_id() != expected_endpoint + || connection.alpn() != ALPN + || connection.side() != iroh::endpoint::Side::Client + { + close_bootstrap_connection(&connection); + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "reconciliation peer identity or protocol did not match pairing", + false, + )); + } + let registered_fence = self.admission.register(&connection)?; + validate_bootstrap_pairing_fence(registered_fence, active_fence)?; + let (mut send, mut recv) = tokio::time::timeout_at(deadline, connection.open_bi()) + .await + .map_err(|_| operation_timeout("Maple reconciliation stream deadline elapsed"))? + .map_err(|error| { + transport_error("failed to open Maple reconciliation stream", error, true) + })?; + send.set_priority(StreamKind::Control.priority()) + .map_err(|error| { + transport_error("failed to prioritize Maple reconciliation", error, true) + })?; + let request = BootstrapRequest { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: execution_target_id.into(), + bootstrap_generation: 0, + pairing_fence: active_fence, + previous_connection_stamp: pending.previous_connection_stamp, + reconciliation: Some(pending.clone()), + }; + write_frame_until(&mut send, &request, deadline).await?; + send.finish().map_err(|error| { + transport_error("failed to finish Maple reconciliation request", error, true) + })?; + let response: BootstrapReconciled = read_frame_until(&mut recv, deadline).await?; + let committed = response.validate( + request_id, + execution_target_id, + self.endpoint.id(), + expected_endpoint, + &pending, + active_fence, + )?; + // The authenticated decision frame, not its trailing EOF, resolves + // ambiguity. A missing FIN therefore cannot wedge the manager. + manager.apply_reconciliation(&pending, committed)?; + let framing = expect_stream_end(&mut recv, deadline).await; + connection.close( + iroh::endpoint::VarInt::from_u32(0), + b"Maple reconciliation complete", + ); + framing + } + + /// Connect using the cached fast path and atomically offer the completed + /// connection to a generation manager. Multiple callers may race this + /// method after a network transition; the first success for a generation + /// wins without an enclave/grant round trip. + pub async fn connect_and_install_cached( + &self, + manager: &GenerationConnectionManager, + cached: &CachedEndpointAddr, + expected_endpoint: iroh::EndpointId, + request_id: &str, + execution_target_id: &str, + ) -> Result { + if let Some(pending) = manager.pending_reconciliation()? { + self.reconcile_cached_pending( + manager, + cached, + expected_endpoint, + request_id, + execution_target_id, + pending, + ) + .await?; + } + self.connect_cached_handover( + Some(manager), + cached, + expected_endpoint, + request_id, + execution_target_id, + ) + .await + } + + /// Accept any authenticated, locally admitted controller. The host router + /// decides which execution target/session owns it after identity is known; + /// this transport method never consumes another admitted controller while + /// waiting for a preselected one. + pub async fn accept_authenticated(&self) -> Result { + loop { + let peer = self.accepted_connections.recv().await?; + if self.admission.is_current_incoming( + &peer.remote_id(), + peer.connection_stamp(), + peer.pairing_fence(), + ) { + return Ok(peer); + } + peer.connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_47), + b"queued connection generation was superseded", + ); + } + } + + /// Revalidate that a host-side application adapter still owns the exact + /// authenticated controller generation returned by this endpoint. + /// + /// `accept_authenticated` performs the same check when dequeuing, but a + /// revocation or replacement can race later stream handling. Read-only + /// adapters call this again immediately before dispatch and disclosure; + /// future mutating operations will additionally need their own durable + /// authorization/idempotency admission boundary. + pub fn validate_current_incoming_peer( + &self, + peer: &ConnectedPeer, + ) -> Result<(), ProtocolError> { + if peer.connection.side() != iroh::endpoint::Side::Server + || peer.outbound_direction != PeerDirection::HostToController + || peer.execution_target_id() != self.execution_target_id.as_ref() + { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "remote request is not bound to this execution target", + false, + )); + } + if peer.connection.close_reason().is_some() + || !self.admission.is_current_incoming( + &peer.remote_id(), + peer.connection_stamp(), + peer.pairing_fence(), + ) + { + return Err(ProtocolError::new( + ErrorCode::Revoked, + "remote controller generation is no longer authorized", + false, + )); + } + Ok(()) + } + + /// Capture the exact installed authorization context for a current host- + /// side controller connection. This is the only production capability + /// accepted by synchronized Agent target binding; renderer values and + /// pairing payload lifecycle revisions cannot construct it. + pub(crate) fn verified_incoming_peer_authorization( + &self, + peer: &ConnectedPeer, + ) -> Result { + if peer.connection.side() != iroh::endpoint::Side::Server + || peer.outbound_direction != PeerDirection::HostToController + || peer.execution_target_id() != self.execution_target_id.as_ref() + || peer.connection.close_reason().is_some() + { + return Err(ProtocolError::new( + ErrorCode::WrongEndpoint, + "remote request is not bound to this execution target", + false, + )); + } + let controller_endpoint = peer.remote_id(); + let pairing_fence = peer.pairing_fence(); + let connection_stamp = peer.connection_stamp(); + let authorization = self.admission.current_incoming_authorization( + &controller_endpoint, + connection_stamp, + pairing_fence, + )?; + Ok(VerifiedIncomingPeerAuthorization { + admission: self.admission.clone(), + authorization, + controller_endpoint, + execution_target_id: Arc::clone(&self.execution_target_id), + pairing_fence, + connection_stamp, + }) + } + + pub async fn close(&self) { + if let Ok(mut shutdown) = self.shutdown.lock() { + if let Some(shutdown) = shutdown.take() { + let _ = shutdown.send(()); + } + } + self.accepted_connections.close(); + let _ = self.admission.clear_all_and_close(); + self.endpoint.close().await; + } + + /// Synchronously fence this endpoint and begin an exclusive, retryable + /// lineage handoff. No await occurs before the caller owns the capability. + /// Use a restoring constructor with `&mut` access to the returned guard; + /// cancellation or bind failure leaves it available for another attempt. + pub fn begin_lineage_handoff(self) -> Result { + let snapshot = self.admission.capture_endpoint_lineage_and_fence( + self.endpoint.id(), + self.execution_target_id.clone(), + )?; + let authorization_floor_revision = snapshot.snapshot_revision; + let authorization_floor_digest = snapshot.authorization_digest; + if let Ok(mut shutdown) = self.shutdown.lock() { + if let Some(shutdown) = shutdown.take() { + let _ = shutdown.send(()); + } + } + self.accepted_connections.close(); + Ok(EndpointLineageHandoff { + source: Some(self), + snapshot: Some(snapshot), + authorization_floor_revision, + authorization_floor_digest, + source_closed: false, + consumed: false, + #[cfg(test)] + close_gate: None, + #[cfg(test)] + fail_next_bind: false, + }) + } +} + +impl Drop for MapleIrohEndpoint { + fn drop(&mut self) { + if let Ok(shutdown) = self.shutdown.get_mut() { + if let Some(shutdown) = shutdown.take() { + let _ = shutdown.send(()); + } + } + self.accepted_connections.close(); + let _ = self.admission.clear_all_and_close(); + } +} + +fn bounded_transport_config() -> Result { + // This bounds post-accept QUIC application resources. In Iroh 1.0.3 the + // Endpoint builder does not expose replacement of noq's default + // pre-accept Incoming buffering (65,536 entries, 10 MiB each, 100 MiB + // aggregate); `Incoming::accept_with` is already too late. Maple's hook, + // handshake deadline, and eight-task accept pump limit authenticated work, + // but a fully bounded pre-auth queue needs an upstream Iroh API/change. + let idle_timeout = + iroh::endpoint::IdleTimeout::try_from(Duration::from_secs(30)).map_err(|_| { + ProtocolError::new( + ErrorCode::Internal, + "invalid Maple QUIC idle timeout", + false, + ) + })?; + Ok(iroh::endpoint::QuicTransportConfig::builder() + .max_concurrent_bidi_streams(iroh::endpoint::VarInt::from_u32(MAX_INCOMING_BI_STREAMS)) + .max_concurrent_uni_streams(iroh::endpoint::VarInt::from_u32(0)) + .stream_receive_window(iroh::endpoint::VarInt::from_u32( + STREAM_RECEIVE_WINDOW_BYTES, + )) + .receive_window(iroh::endpoint::VarInt::from_u32( + CONNECTION_RECEIVE_WINDOW_BYTES, + )) + .send_window(CONNECTION_SEND_WINDOW_BYTES) + .datagram_receive_buffer_size(None) + .datagram_send_buffer_size(0) + .max_idle_timeout(Some(idle_timeout)) + .build()) +} + +fn spawn_accept_pump( + endpoint: iroh::Endpoint, + admission: PeerAdmission, + connection_policy: ConnectionPolicy, + execution_target_id: Arc, + host_clock: HostConnectionClock, + accepted_connections: Arc, + mut shutdown: oneshot::Receiver<()>, +) { + let local_endpoint_id = endpoint.id(); + tokio::spawn(async move { + let pending = Arc::new(Semaphore::new(MAX_PENDING_HANDSHAKES)); + let mut tasks = futures_util::stream::FuturesUnordered::new(); + loop { + tokio::select! { + biased; + _ = &mut shutdown => break, + completed = tasks.next(), if !tasks.is_empty() => { + let _ = completed; + } + incoming = endpoint.accept() => { + let Some(incoming) = incoming else { break; }; + let Ok(permit) = pending.clone().try_acquire_owned() else { + incoming.refuse(); + continue; + }; + let admission = admission.clone(); + let execution_target_id = execution_target_id.clone(); + let host_clock = host_clock.clone(); + let accepted_connections = accepted_connections.clone(); + let local_endpoint_id = local_endpoint_id; + tasks.push(tokio::spawn(async move { + handle_incoming_bootstrap( + incoming, + local_endpoint_id, + admission, + connection_policy, + execution_target_id, + host_clock, + accepted_connections, + permit, + ).await + })); + } + } + } + // Ordinary MapleIrohEndpoint::drop reaches this path. Close the socket + // before draining so noq cannot retain an unattended pre-accept queue, + // then abort bounded handshake tasks instead of waiting up to the + // policy deadline. + endpoint.close().await; + accepted_connections.close(); + for task in tasks.iter() { + task.abort(); + } + while let Some(completed) = tasks.next().await { + let _ = completed; + } + }); +} + +async fn handle_incoming_bootstrap( + incoming: iroh::endpoint::Incoming, + local_endpoint_id: iroh::EndpointId, + admission: PeerAdmission, + connection_policy: ConnectionPolicy, + execution_target_id: Arc, + host_clock: HostConnectionClock, + accepted_connections: Arc, + _permit: OwnedSemaphorePermit, +) -> Result<(), ProtocolError> { + let reconnect_deadline = tokio::time::Instant::now() + + connection_policy + .handshake_deadline + .max(connection_policy.frame_deadline); + let connection = tokio::time::timeout_at(reconnect_deadline, incoming) + .await + .map_err(|_| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple controller handshake deadline elapsed", + true, + ) + })? + .map_err(|error| transport_error("failed to accept Maple controller", error, true))?; + if connection.alpn() != ALPN || connection.side() != iroh::endpoint::Side::Server { + return Err(ProtocolError::new( + ErrorCode::UnsupportedVersion, + "incoming peer protocol did not match Maple remote Agent Mode", + false, + )); + } + let pairing_fence = admission.register(&connection)?; + + let bootstrap_deadline = reconnect_deadline; + let (mut send, mut recv) = tokio::time::timeout_at(bootstrap_deadline, connection.accept_bi()) + .await + .map_err(|_| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple bootstrap stream deadline elapsed", + true, + ) + })? + .map_err(|error| transport_error("failed to accept Maple bootstrap stream", error, true))?; + let request: BootstrapRequest = read_frame_until(&mut recv, bootstrap_deadline).await?; + if let Err(error) = request.validate(&execution_target_id, pairing_fence) { + if error.code == ErrorCode::Unauthorized && !error.retryable { + // Echo the controller-supplied fence, not the host's current + // incarnation. This lets the caller authenticate/correlate the + // denial without turning the response into pairing discovery. + send.set_priority(StreamKind::Control.priority()) + .map_err(|priority_error| { + transport_error( + "failed to prioritize Maple bootstrap rejection", + priority_error, + true, + ) + })?; + let rejection = BootstrapResponse { + protocol_version: PROTOCOL_VERSION, + request_id: request.request_id.clone(), + execution_target_id: request.execution_target_id.clone(), + pairing_fence: request.pairing_fence, + result: Err(error.clone()), + }; + write_frame_until(&mut send, &rejection, bootstrap_deadline).await?; + send.finish().map_err(|finish_error| { + transport_error( + "failed to finish Maple bootstrap rejection", + finish_error, + true, + ) + })?; + // `finish` only queues FIN locally. Retain the sole strong host + // handle until the controller consumes the authenticated denial + // and drops this bootstrap-only connection, bounded by the + // existing bootstrap deadline. + if tokio::time::timeout_at(bootstrap_deadline, connection.closed()) + .await + .is_err() + { + close_bootstrap_connection(&connection); + } + } + return Err(error); + } + let controller_id = connection.remote_id(); + if let Some(pending) = request.reconciliation.as_ref() { + let local_host = local_endpoint_id; + pending.validate( + &execution_target_id, + controller_id, + local_host, + pairing_fence, + )?; + if let Err(error) = expect_stream_end(&mut recv, bootstrap_deadline).await { + close_bootstrap_connection(&connection); + return Err(error); + } + let (committed_connection_stamp, to_close) = admission.reconcile_incoming( + &accepted_connections, + controller_id, + pending, + pairing_fence, + local_host, + &execution_target_id, + )?; + close_weak_connections( + to_close, + b"pending handover reconciled to previous generation", + ); + let reconciled = BootstrapReconciled { + protocol_version: PROTOCOL_VERSION, + request_id: request.request_id, + execution_target_id: request.execution_target_id, + controller_id: controller_id.to_string(), + host_id: local_host.to_string(), + pending: pending.clone(), + committed_connection_stamp, + }; + write_frame_until(&mut send, &reconciled, bootstrap_deadline).await?; + send.finish().map_err(|error| { + transport_error( + "failed to finish Maple reconciliation response", + error, + true, + ) + })?; + // `finish` only queues FIN locally. Keep the sole strong host handle + // until the controller consumes the authenticated decision and closes + // this reconciliation-only connection, bounded by the same deadline. + // A lost response remains idempotently retryable from PendingCommit. + if tokio::time::timeout_at(bootstrap_deadline, connection.closed()) + .await + .is_err() + { + close_bootstrap_connection(&connection); + } + return Ok(()); + } + // Reserve routable queue capacity before acknowledging or committing this + // generation. Same-peer reconnects share one keyed slot; another peer can + // never be displaced by stale generations. + let queue_reservation = accepted_connections.reserve(controller_id)?; + + let connection_stamp = host_clock.allocate()?; + send.set_priority(StreamKind::Control.priority()) + .map_err(|error| { + transport_error("failed to prioritize Maple bootstrap response", error, true) + })?; + let pending_peer = PendingConnectedPeer { + connection: connection.clone(), + connection_stamp, + pairing_fence, + execution_target_id: execution_target_id.clone(), + outbound_direction: PeerDirection::HostToController, + frame_deadline: connection_policy.frame_deadline, + }; + let ready = BootstrapReady { + protocol_version: PROTOCOL_VERSION, + request_id: request.request_id.clone(), + execution_target_id: request.execution_target_id.clone(), + controller_id: controller_id.to_string(), + pairing_fence, + connection_stamp, + previous_connection_stamp: request.previous_connection_stamp, + }; + let pending = PendingCommit::new( + pairing_fence, + &request.request_id, + &request.execution_target_id, + controller_id, + local_endpoint_id, + request.previous_connection_stamp, + connection_stamp, + )?; + // Stage B before acknowledging it. Under the same admission lock, the + // controller's claimed A lineage is compared to the host's live current A. + // B remains queue-gated and A remains current through CommitObserved. + let mut commit = match admission.commit_and_publish_incoming( + &connection, + connection_stamp, + request.previous_connection_stamp, + pairing_fence, + queue_reservation, + pending_peer, + pending, + ) { + Ok(commit) => commit, + Err(error) => { + close_bootstrap_connection(&connection); + return Err(error); + } + }; + let response = BootstrapResponse { + protocol_version: PROTOCOL_VERSION, + request_id: request.request_id.clone(), + execution_target_id: request.execution_target_id.clone(), + pairing_fence, + result: Ok(BootstrapAccepted { connection_stamp }), + }; + if let Err(error) = write_frame_until(&mut send, &response, bootstrap_deadline).await { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + if let Err(error) = write_frame_until(&mut send, &ready, bootstrap_deadline).await { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + if let Err(error) = admission.validate_incoming_activation(&commit) { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + + let installed: BootstrapInstalled = match read_frame_until(&mut recv, bootstrap_deadline).await + { + Ok(installed) => installed, + Err(error) => { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + }; + if let Err(error) = installed.validate( + &request.request_id, + &request.execution_target_id, + controller_id, + pairing_fence, + connection_stamp, + request.previous_connection_stamp, + ) { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + if let Err(error) = admission.validate_incoming_activation(&commit) { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + + let committed = BootstrapCommitted { + protocol_version: PROTOCOL_VERSION, + request_id: request.request_id.clone(), + execution_target_id: request.execution_target_id.clone(), + controller_id: controller_id.to_string(), + pairing_fence, + connection_stamp, + previous_connection_stamp: request.previous_connection_stamp, + }; + if let Err(error) = write_frame_until(&mut send, &committed, bootstrap_deadline).await { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + + let observed: BootstrapCommitObserved = match read_frame_until(&mut recv, bootstrap_deadline) + .await + { + Ok(observed) => observed, + Err(error) => { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + }; + if let Err(error) = observed.validate( + &request.request_id, + &request.execution_target_id, + controller_id, + pairing_fence, + connection_stamp, + request.previous_connection_stamp, + ) { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + if let Err(error) = expect_stream_end(&mut recv, bootstrap_deadline).await { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + + // Commit point: a correlated Installed + Observed + controller FIN proves + // that the controller selected B. Atomically make B current/routable, then + // retire A. Failure to deliver Finalized after this point never restores A; + // the next reconnect must advance from B's stamp. + let (displaced_active, queued_displaced) = match admission + .finalize_observed_incoming(&accepted_connections, &commit) + { + Ok(displaced) => displaced, + Err(error) => { + let rollback = rollback_staged_incoming(&admission, &accepted_connections, &mut commit); + close_bootstrap_connection(&connection); + rollback?; + return Err(error); + } + }; + if let Some(displaced) = queued_displaced { + displaced.close(); + } + if let Some(displaced) = commit.previous_connection.take() { + displaced.close( + iroh::endpoint::VarInt::from_u32(0x4d_47), + b"superseded connection generation", + ); + } + if let Some(displaced) = displaced_active.and_then(|handle| handle.upgrade()) { + displaced.close( + iroh::endpoint::VarInt::from_u32(0x4d_47), + b"superseded connection generation", + ); + } + + let finalized = BootstrapFinalized { + protocol_version: PROTOCOL_VERSION, + request_id: request.request_id, + execution_target_id: request.execution_target_id, + controller_id: controller_id.to_string(), + pairing_fence, + connection_stamp, + previous_connection_stamp: request.previous_connection_stamp, + }; + write_frame_until(&mut send, &finalized, bootstrap_deadline).await?; + send.finish().map_err(|error| { + transport_error("failed to finish Maple bootstrap finalization", error, true) + })?; + Ok(()) +} + +fn close_bootstrap_connection(connection: &iroh::endpoint::Connection) { + connection.close( + iroh::endpoint::VarInt::from_u32(0), + b"Maple bootstrap handover failed", + ); +} + +struct BoundedWriter { + bytes: Vec, +} + +impl BoundedWriter { + fn new() -> Self { + Self { + bytes: Vec::with_capacity(4096), + } + } + + fn into_inner(self) -> Vec { + self.bytes + } +} + +impl Write for BoundedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let new_len = + self.bytes.len().checked_add(bytes.len()).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "frame length overflow") + })?; + if new_len > MAX_FRAME_BYTES as usize { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "frame exceeds Maple limit", + )); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn encode_frame_bounded(value: &T) -> Result, ProtocolError> { + let mut writer = BoundedWriter::new(); + ciborium::ser::into_writer(value, &mut writer).map_err(|error| { + let (code, message) = match error { + ciborium::ser::Error::Io(_) => ( + ErrorCode::FrameTooLarge, + "frame exceeds Maple's bounded encoder", + ), + ciborium::ser::Error::Value(_) => { + (ErrorCode::InvalidFrame, "failed to encode Maple frame") + } + }; + ProtocolError::new(code, message, false) + })?; + Ok(writer.into_inner()) +} + +/// Preflight one concrete response frame without retaining its bytes. Host +/// adapters use this before starting a streamed page so a single oversized +/// record can be returned as a typed error rather than truncating mid-page. +pub(crate) fn validate_frame_encodable(value: &T) -> Result<(), ProtocolError> { + encode_frame_bounded(value).map(|_| ()) +} + +async fn write_frame_bounded( + send: &mut iroh::endpoint::SendStream, + value: &T, + deadline: Duration, +) -> Result<(), ProtocolError> { + write_frame_until(send, value, tokio::time::Instant::now() + deadline).await +} + +async fn write_frame_until( + send: &mut iroh::endpoint::SendStream, + value: &T, + deadline: tokio::time::Instant, +) -> Result<(), ProtocolError> { + let payload = encode_frame_bounded(value)?; + let len = u32::try_from(payload.len()).map_err(|_| { + ProtocolError::new(ErrorCode::FrameTooLarge, "frame length overflow", false) + })?; + tokio::time::timeout_at(deadline, async { + send.write_all(&len.to_be_bytes()) + .await + .map_err(|error| transport_error("failed to write frame length", error, true))?; + send.write_all(&payload) + .await + .map_err(|error| transport_error("failed to write frame payload", error, true))?; + Ok(()) + }) + .await + .map_err(|_| { + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple frame write deadline elapsed", + true, + ) + })? +} + +async fn read_frame_bounded( + recv: &mut iroh::endpoint::RecvStream, + deadline: Duration, +) -> Result { + read_frame_until(recv, tokio::time::Instant::now() + deadline).await +} + +async fn read_frame_until( + recv: &mut iroh::endpoint::RecvStream, + absolute_deadline: tokio::time::Instant, +) -> Result { + let mut len = [0_u8; 4]; + read_exact_cancel_safe(recv, &mut len, absolute_deadline).await?; + let len = u32::from_be_bytes(len); + validate_frame_len(len as usize)?; + let mut payload = vec![0_u8; len as usize]; + read_exact_cancel_safe(recv, &mut payload, absolute_deadline).await?; + decode_frame_payload(&payload) +} + +/// Read one Events response frame without imposing an application lifetime or +/// idle-read timeout. The authenticated QUIC connection, explicit RPC cancel, +/// and authority revocation own liveness; allocation and CBOR bounds remain +/// identical to finite request frames. +async fn read_frame_unbounded( + recv: &mut iroh::endpoint::RecvStream, +) -> Result { + let mut len = [0_u8; 4]; + read_exact_unbounded(recv, &mut len).await?; + let len = u32::from_be_bytes(len); + validate_frame_len(len as usize)?; + let mut payload = vec![0_u8; len as usize]; + read_exact_unbounded(recv, &mut payload).await?; + decode_frame_payload(&payload) +} + +fn decode_frame_payload(payload: &[u8]) -> Result { + validate_cbor_shape(payload)?; + let mut cursor = Cursor::new(payload); + let value = ciborium::de::from_reader_with_recursion_limit(&mut cursor, MAX_CBOR_RECURSION) + .map_err(|_| { + ProtocolError::new( + ErrorCode::InvalidFrame, + "failed to decode Maple frame", + false, + ) + })?; + if cursor.position() != payload.len() as u64 { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "Maple frame contains trailing data", + false, + )); + } + Ok(value) +} + +async fn expect_stream_end( + recv: &mut iroh::endpoint::RecvStream, + deadline: tokio::time::Instant, +) -> Result<(), ProtocolError> { + let mut unexpected = [0_u8; 1]; + match tokio::time::timeout_at(deadline, recv.read(&mut unexpected)) + .await + .map_err(|_| operation_timeout("Maple bootstrap completion deadline elapsed"))? + .map_err(|error| { + transport_error("failed to finish Maple bootstrap response", error, true) + })? { + None => Ok(()), + Some(_) => Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "Maple bootstrap contains an unexpected trailing frame", + false, + )), + } +} + +async fn expect_stream_end_unbounded( + recv: &mut iroh::endpoint::RecvStream, +) -> Result<(), ProtocolError> { + let mut unexpected = [0_u8; 1]; + match recv + .read(&mut unexpected) + .await + .map_err(|error| transport_error("failed to finish Maple Events response", error, true))? + { + None => Ok(()), + Some(_) => Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "Maple Events response contains unexpected trailing data", + false, + )), + } +} + +/// Validate the allocation-relevant CBOR shape before Serde sees any size +/// hints. Ciborium otherwise propagates attacker-declared container lengths to +/// `Vec::reserve`; nested tiny frames can amplify into large heap allocations. +/// Maple emits only definite-length CBOR, so indefinite forms are rejected. +fn validate_cbor_shape(payload: &[u8]) -> Result<(), ProtocolError> { + let mut offset = 0; + parse_cbor_item(payload, &mut offset, 0)?; + if offset != payload.len() { + return Err(invalid_cbor_shape("Maple frame contains trailing data")); + } + Ok(()) +} + +fn parse_cbor_item(payload: &[u8], offset: &mut usize, depth: usize) -> Result<(), ProtocolError> { + if depth >= MAX_CBOR_RECURSION { + return Err(invalid_cbor_shape("Maple frame nesting is too deep")); + } + let initial = *payload + .get(*offset) + .ok_or_else(|| invalid_cbor_shape("Maple frame is truncated"))?; + *offset += 1; + let major = initial >> 5; + let additional = initial & 0x1f; + if additional == 31 { + return Err(invalid_cbor_shape("indefinite-length CBOR is not enabled")); + } + let argument = read_cbor_argument(payload, offset, additional)?; + match major { + 0 | 1 => Ok(()), + 2 | 3 => { + let length = usize::try_from(argument) + .map_err(|_| invalid_cbor_shape("CBOR string length is invalid"))?; + let end = offset + .checked_add(length) + .ok_or_else(|| invalid_cbor_shape("CBOR string length overflow"))?; + if end > payload.len() { + return Err(invalid_cbor_shape("CBOR string is truncated")); + } + *offset = end; + Ok(()) + } + 4 => { + if argument > MAX_CBOR_CONTAINER_ITEMS { + return Err(invalid_cbor_shape("CBOR array exceeds Maple's item bound")); + } + for _ in 0..argument { + parse_cbor_item(payload, offset, depth + 1)?; + } + Ok(()) + } + 5 => { + if argument > MAX_CBOR_CONTAINER_ITEMS { + return Err(invalid_cbor_shape("CBOR map exceeds Maple's item bound")); + } + for _ in 0..argument { + parse_cbor_item(payload, offset, depth + 1)?; + parse_cbor_item(payload, offset, depth + 1)?; + } + Ok(()) + } + 6 => parse_cbor_item(payload, offset, depth + 1), + 7 => match additional { + 0..=23 => Ok(()), + 24 => Ok(()), // one argument byte was consumed above + 25..=27 => Ok(()), + _ => Err(invalid_cbor_shape("unsupported CBOR simple value")), + }, + _ => Err(invalid_cbor_shape("unsupported CBOR major type")), + } +} + +fn read_cbor_argument( + payload: &[u8], + offset: &mut usize, + additional: u8, +) -> Result { + let width = match additional { + value @ 0..=23 => return Ok(u64::from(value)), + 24 => 1, + 25 => 2, + 26 => 4, + 27 => 8, + _ => return Err(invalid_cbor_shape("reserved CBOR argument")), + }; + let end = offset + .checked_add(width) + .ok_or_else(|| invalid_cbor_shape("CBOR argument overflow"))?; + let bytes = payload + .get(*offset..end) + .ok_or_else(|| invalid_cbor_shape("CBOR argument is truncated"))?; + *offset = end; + Ok(bytes + .iter() + .fold(0_u64, |value, byte| (value << 8) | u64::from(*byte))) +} + +fn invalid_cbor_shape(message: &str) -> ProtocolError { + ProtocolError::new(ErrorCode::InvalidFrame, message, false) +} + +async fn read_exact_cancel_safe( + recv: &mut iroh::endpoint::RecvStream, + mut remaining: &mut [u8], + deadline: tokio::time::Instant, +) -> Result<(), ProtocolError> { + while !remaining.is_empty() { + let read = tokio::time::timeout_at(deadline, recv.read(remaining)) + .await + .map_err(|_| { + let _ = recv.stop(iroh::endpoint::VarInt::from_u32(0x4d_54)); + ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple frame read deadline elapsed", + true, + ) + })? + .map_err(|error| transport_error("failed to read Maple frame", error, true))?; + let Some(read) = read else { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "Maple frame ended before its declared length", + false, + )); + }; + let (_, tail) = remaining.split_at_mut(read); + remaining = tail; + } + Ok(()) +} + +async fn read_exact_unbounded( + recv: &mut iroh::endpoint::RecvStream, + mut remaining: &mut [u8], +) -> Result<(), ProtocolError> { + while !remaining.is_empty() { + let read = recv + .read(remaining) + .await + .map_err(|error| transport_error("failed to read Maple Events frame", error, true))?; + let Some(read) = read else { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "Maple Events frame ended before its declared length", + false, + )); + }; + if read == 0 { + return Err(ProtocolError::new( + ErrorCode::TransportUnavailable, + "Maple Events frame made no read progress", + true, + )); + } + let (_, tail) = remaining.split_at_mut(read); + remaining = tail; + } + Ok(()) +} + +/// Read a length prefix without allocating its payload. Used by tests and by +/// callers that want an explicit preflight check. +pub fn validate_wire_length_prefix(prefix: [u8; 4]) -> Result { + let len = u32::from_be_bytes(prefix); + if len > MAX_FRAME_BYTES { + return Err(ProtocolError::new( + ErrorCode::FrameTooLarge, + format!("frame exceeds {MAX_FRAME_BYTES} bytes"), + false, + )); + } + Ok(len) +} + +fn transport_error(context: &str, error: impl std::fmt::Display, retryable: bool) -> ProtocolError { + // Iroh error strings can contain IP/relay addresses. Do not persist them + // in application logs; only emit a stable context and consume the detail. + let _ = error; + log::warn!("{context}"); + ProtocolError::new(ErrorCode::TransportUnavailable, context, retryable) +} + +fn internal_state_error() -> ProtocolError { + ProtocolError::new( + ErrorCode::Internal, + "remote peer admission state is unavailable", + false, + ) +} + +fn operation_timeout(message: &str) -> ProtocolError { + ProtocolError::new(ErrorCode::TransportUnavailable, message, true) +} + +fn identity_endpoint_id(identity: &DeviceIdentity) -> Result { + identity.public_id().parse().map_err(|_| { + ProtocolError::new( + ErrorCode::SecureStorageUnavailable, + "device identity contains an invalid Iroh endpoint ID", + false, + ) + }) +} + +#[cfg(test)] +mod transport_tests { + use super::*; + use crate::{ + remote_protocol::{ + Page, PageItem, PageRequest, RequestEnvelope, ResponseEnvelope, WireBody, + }, + secure_storage::{testing::InMemorySecretStore, DeviceSecretSlot}, + }; + use serde::ser::SerializeSeq; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + const TEST_TIMEOUT: Duration = Duration::from_secs(5); + + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct SyntheticPageItem { + value: String, + } + + impl WireBody for SyntheticPageItem { + fn stream_kind(&self) -> StreamKind { + StreamKind::Bulk + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + validate_bootstrap_id("page item value", &self.value) + } + } + + impl PageItem for SyntheticPageItem {} + + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct SyntheticEventsRequest { + stream_id: String, + } + + impl WireBody for SyntheticEventsRequest { + fn stream_kind(&self) -> StreamKind { + StreamKind::Events + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + validate_bootstrap_id("synthetic Events stream id", &self.stream_id) + } + } + + impl RequestBody for SyntheticEventsRequest { + fn allowed_direction(&self) -> PeerDirection { + PeerDirection::ControllerToHost + } + } + + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct SyntheticEventsFrame { + stream_id: String, + sequence: u64, + } + + impl WireBody for SyntheticEventsFrame { + fn stream_kind(&self) -> StreamKind { + StreamKind::Events + } + + fn validate_body(&self) -> Result<(), ProtocolError> { + validate_bootstrap_id("synthetic Events stream id", &self.stream_id)?; + if self.sequence == 0 { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "synthetic Events sequence must be positive", + false, + )); + } + Ok(()) + } + } + + impl ResponseBody for SyntheticEventsFrame { + fn validate_response_to( + &self, + request: &SyntheticEventsRequest, + ) -> Result<(), ProtocolError> { + if self.stream_id != request.stream_id { + return Err(ProtocolError::new( + ErrorCode::InvalidFrame, + "synthetic Events response belongs to another stream", + false, + )); + } + Ok(()) + } + } + + fn identity(install: &str) -> DeviceIdentity { + let store = InMemorySecretStore::default(); + let slot = DeviceSecretSlot::new("cloud.opensecret.maple.test", install, 1).unwrap(); + DeviceIdentity::load_or_create(&store, &slot).unwrap() + } + + fn endpoint_id(identity: &DeviceIdentity) -> iroh::EndpointId { + identity.public_id().parse().unwrap() + } + + fn epoch(value: u64) -> HostConnectionClock { + HostConnectionClock::new(HostEpoch::new(value).unwrap()) + } + + #[test] + fn durable_runtime_clock_uses_the_native_identity_lineage_and_advances_on_restart() { + let store = InMemorySecretStore::default(); + let slot = + DeviceSecretSlot::new("cloud.opensecret.maple.test", "durable-clock", 1).unwrap(); + + let first = DurableHostRuntime::load_and_reserve_for_test(&store, &slot).unwrap(); + assert_eq!( + first.host_clock().allocate().unwrap(), + ConnectionStamp::new(1, 1).unwrap() + ); + assert_eq!( + first.host_clock().allocate().unwrap(), + ConnectionStamp::new(1, 2).unwrap() + ); + + let restarted = DurableHostRuntime::load_and_reserve_for_test(&store, &slot).unwrap(); + assert_eq!( + restarted.host_clock().allocate().unwrap(), + ConnectionStamp::new(2, 1).unwrap() + ); + } + + fn test_incarnation() -> PairingIncarnation { + PairingIncarnation::new(1).unwrap() + } + + fn test_pairing_fence() -> PairingFence { + PairingFence::new(test_incarnation()).unwrap() + } + + fn paired( + peers: impl IntoIterator, + ) -> HashMap { + peers + .into_iter() + .map(|peer| (peer, test_incarnation())) + .collect() + } + + async fn within(operation: impl Future, context: &'static str) -> T { + tokio::time::timeout(TEST_TIMEOUT, operation) + .await + .unwrap_or_else(|_| panic!("{context} timed out")) + } + + async fn bind_direct_endpoint( + identity: &DeviceIdentity, + target_id: &str, + host_clock: HostConnectionClock, + ) -> MapleIrohEndpoint { + within( + MapleIrohEndpoint::bind_direct(identity, target_id, host_clock), + "direct endpoint bind", + ) + .await + .unwrap() + } + + async fn wait_for_cached(endpoint: &MapleIrohEndpoint) -> CachedEndpointAddr { + within( + async { + loop { + if let Ok(cached) = endpoint.cached_endpoint_addr(endpoint.endpoint_addr()) { + return cached; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }, + "local endpoint address publication", + ) + .await + } + + async fn connect_pair( + controller: &MapleIrohEndpoint, + host: &MapleIrohEndpoint, + cached_host: &CachedEndpointAddr, + host_id: iroh::EndpointId, + request_id: &str, + target_id: &str, + ) -> (ConnectedPeer, ConnectedPeer) { + let (client, server) = within( + async { + tokio::join!( + controller.connect_cached(cached_host, host_id, request_id, target_id), + host.accept_authenticated(), + ) + }, + "bootstrapped Iroh connection", + ) + .await; + (client.unwrap(), server.unwrap()) + } + + async fn connect_pair_managed( + controller: &MapleIrohEndpoint, + host: &MapleIrohEndpoint, + manager: &GenerationConnectionManager, + cached_host: &CachedEndpointAddr, + host_id: iroh::EndpointId, + request_id: &str, + target_id: &str, + ) -> (ConnectedPeer, ConnectedPeer) { + let (client, server) = within( + async { + tokio::join!( + controller.connect_and_install_cached( + manager, + cached_host, + host_id, + request_id, + target_id, + ), + host.accept_authenticated(), + ) + }, + "managed bootstrapped Iroh connection", + ) + .await; + (client.unwrap(), server.unwrap()) + } + + struct HandoverFixture { + controller: MapleIrohEndpoint, + host: MapleIrohEndpoint, + manager: GenerationConnectionManager, + cached_host: CachedEndpointAddr, + controller_id: iroh::EndpointId, + host_id: iroh::EndpointId, + target_id: String, + current_client: ConnectedPeer, + current_server: ConnectedPeer, + } + + async fn handover_fixture(label: &str, host_epoch: u64) -> HandoverFixture { + let controller_identity = identity(&format!("{label}-controller")); + let host_identity = identity(&format!("{label}-host")); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let target_id = format!("{label}-target"); + let controller = bind_direct_endpoint( + &controller_identity, + &format!("{label}-controller-install"), + epoch(host_epoch + 1), + ) + .await; + let host = bind_direct_endpoint(&host_identity, &target_id, epoch(host_epoch)).await; + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + host.authorize_incoming_controller(controller_id).unwrap(); + let cached_host = wait_for_cached(&host).await; + let manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + target_id.clone(), + test_pairing_fence(), + None, + ) + .unwrap(); + let (current_client, current_server) = connect_pair_managed( + &controller, + &host, + &manager, + &cached_host, + host_id, + &format!("{label}-initial"), + &target_id, + ) + .await; + HandoverFixture { + controller, + host, + manager, + cached_host, + controller_id, + host_id, + target_id, + current_client, + current_server, + } + } + + struct RawReadyHandover { + connection: iroh::endpoint::Connection, + send: iroh::endpoint::SendStream, + recv: iroh::endpoint::RecvStream, + request_id: String, + target_id: String, + controller_id: iroh::EndpointId, + pairing_fence: PairingFence, + connection_stamp: ConnectionStamp, + previous_connection_stamp: Option, + } + + impl RawReadyHandover { + fn pending(&self, fixture: &HandoverFixture) -> PendingCommit { + PendingCommit::new( + fixture.manager.pairing_fence, + &self.request_id, + &self.target_id, + self.controller_id, + fixture.host_id, + self.previous_connection_stamp, + self.connection_stamp, + ) + .unwrap() + } + + fn installed(&self) -> BootstrapInstalled { + BootstrapInstalled { + protocol_version: PROTOCOL_VERSION, + request_id: self.request_id.clone(), + execution_target_id: self.target_id.clone(), + controller_id: self.controller_id.to_string(), + pairing_fence: self.pairing_fence, + connection_stamp: self.connection_stamp, + previous_connection_stamp: self.previous_connection_stamp, + } + } + + fn observed(&self) -> BootstrapCommitObserved { + BootstrapCommitObserved { + protocol_version: PROTOCOL_VERSION, + request_id: self.request_id.clone(), + execution_target_id: self.target_id.clone(), + controller_id: self.controller_id.to_string(), + pairing_fence: self.pairing_fence, + connection_stamp: self.connection_stamp, + previous_connection_stamp: self.previous_connection_stamp, + } + } + + async fn install_and_expect_committed(&mut self) { + let deadline = tokio::time::Instant::now() + TEST_TIMEOUT; + let installed = self.installed(); + write_frame_until(&mut self.send, &installed, deadline) + .await + .unwrap(); + let committed: BootstrapCommitted = + read_frame_until(&mut self.recv, deadline).await.unwrap(); + committed + .validate( + &self.request_id, + &self.target_id, + self.controller_id, + self.pairing_fence, + self.connection_stamp, + self.previous_connection_stamp, + ) + .unwrap(); + } + + async fn expect_finalized(&mut self) { + let deadline = tokio::time::Instant::now() + TEST_TIMEOUT; + let finalized: BootstrapFinalized = + read_frame_until(&mut self.recv, deadline).await.unwrap(); + finalized + .validate( + &self.request_id, + &self.target_id, + self.controller_id, + self.pairing_fence, + self.connection_stamp, + self.previous_connection_stamp, + ) + .unwrap(); + expect_stream_end(&mut self.recv, deadline).await.unwrap(); + } + } + + async fn open_raw_handover_to_ready( + fixture: &HandoverFixture, + request_id: &str, + ) -> RawReadyHandover { + let deadline = tokio::time::Instant::now() + TEST_TIMEOUT; + let connection = tokio::time::timeout_at( + deadline, + fixture + .controller + .endpoint + .connect(fixture.cached_host.as_iroh().clone(), ALPN), + ) + .await + .unwrap() + .unwrap(); + fixture.controller.admission.register(&connection).unwrap(); + let (mut send, mut recv) = tokio::time::timeout_at(deadline, connection.open_bi()) + .await + .unwrap() + .unwrap(); + send.set_priority(StreamKind::Control.priority()).unwrap(); + let previous_connection_stamp = Some(fixture.current_client.connection_stamp()); + let request = BootstrapRequest { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: fixture.target_id.clone(), + bootstrap_generation: 0, + pairing_fence: fixture.manager.pairing_fence, + previous_connection_stamp, + reconciliation: None, + }; + write_frame_until(&mut send, &request, deadline) + .await + .unwrap(); + let response: BootstrapResponse = read_frame_until(&mut recv, deadline).await.unwrap(); + let connection_stamp = response + .validate( + request_id, + &fixture.target_id, + fixture.manager.pairing_fence, + ) + .unwrap(); + let ready: BootstrapReady = read_frame_until(&mut recv, deadline).await.unwrap(); + ready + .validate( + request_id, + &fixture.target_id, + fixture.controller_id, + fixture.manager.pairing_fence, + connection_stamp, + previous_connection_stamp, + ) + .unwrap(); + RawReadyHandover { + connection, + send, + recv, + request_id: request_id.into(), + target_id: fixture.target_id.clone(), + controller_id: fixture.controller_id, + pairing_fence: fixture.manager.pairing_fence, + connection_stamp, + previous_connection_stamp, + } + } + + async fn wait_for_handover_rollback(fixture: &HandoverFixture) { + within( + async { + loop { + let activating = fixture + .host + .admission + .state + .read() + .unwrap() + .incoming_controllers + .activating + .contains_key(&fixture.controller_id); + let pending = fixture + .host + .accepted_connections + .state + .lock() + .unwrap() + .pending + .contains_key(&fixture.controller_id); + if !activating && !pending { + return; + } + tokio::task::yield_now().await; + } + }, + "handover rollback", + ) + .await; + } + + async fn close_handover_fixture(fixture: HandoverFixture) { + fixture.manager.clear().unwrap(); + within( + async { tokio::join!(fixture.controller.close(), fixture.host.close()) }, + "handover fixture close", + ) + .await; + } + + async fn assert_page_roundtrip( + client: &ConnectedPeer, + server: &ConnectedPeer, + request_id: &str, + ) { + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: request_id.into(), + execution_target_id: client.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: client.connection_stamp(), + body: PageRequest::default(), + }; + let (client_result, server_result) = within( + async { + tokio::join!( + async { + let response: ResponseEnvelope> = + client.request(&request).await?; + let page = response.result?; + assert_eq!( + page.items, + vec![SyntheticPageItem { + value: "synthetic-item".into() + }] + ); + Ok::<_, ProtocolError>(()) + }, + async { + let mut accepted = server.accept_stream().await?; + assert_eq!(accepted.header().stream_kind, StreamKind::Bulk); + assert_eq!( + accepted.send_stream().priority().unwrap(), + StreamKind::Bulk.priority() + ); + let mut received: AcceptedRequest = + accepted.read_request().await?; + assert_eq!(received.request().request_id, request_id); + assert_eq!( + received.request().connection_stamp, + server.connection_stamp() + ); + assert_eq!( + received.send_stream().priority().unwrap(), + StreamKind::Bulk.priority() + ); + let received_request = received.request(); + let response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: received_request.request_id.clone(), + execution_target_id: received_request.execution_target_id.clone(), + connection_stamp: received_request.connection_stamp, + result: Ok(Page { + items: vec![SyntheticPageItem { + value: "synthetic-item".into(), + }], + next_cursor: None, + }), + }; + received.write_response(&response).await?; + Ok::<_, ProtocolError>(()) + } + ) + }, + "typed page roundtrip", + ) + .await; + client_result.unwrap(); + server_result.unwrap(); + } + + async fn bind_raw_endpoint(identity: &DeviceIdentity, alpns: Vec>) -> iroh::Endpoint { + let builder = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .clear_address_lookup() + .relay_mode(iroh::RelayMode::Disabled) + .secret_key(identity.iroh_secret_key().unwrap()) + .alpns(alpns) + .clear_ip_transports() + .bind_addr_with_opts( + "127.0.0.1:0", + iroh::endpoint::BindOpts::default().set_prefix_len(8), + ) + .unwrap(); + within(builder.bind(), "raw endpoint bind").await.unwrap() + } + + #[tokio::test] + async fn bootstrapped_page_roundtrip_enforces_bulk_lane() { + let controller_identity = identity("roundtrip-controller"); + let host_identity = identity("roundtrip-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let controller = + bind_direct_endpoint(&controller_identity, "controller-install", epoch(90)).await; + let host = bind_direct_endpoint(&host_identity, "host-install", epoch(41)).await; + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + host.authorize_incoming_controller(controller_id).unwrap(); + let cached = wait_for_cached(&host).await; + let (client, server) = connect_pair( + &controller, + &host, + &cached, + host_id, + "bootstrap-roundtrip", + "host-install", + ) + .await; + + assert_eq!(client.connection_stamp(), server.connection_stamp()); + assert_eq!(client.connection_stamp().host_epoch(), 41); + assert_eq!(client.connection_stamp().generation(), 1); + assert_page_roundtrip(&client, &server, "page-roundtrip").await; + + let mislabeled = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "mislabeled-page".into(), + execution_target_id: "host-install".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: client.connection_stamp(), + body: PageRequest::default(), + }; + let (client_result, server_result) = within( + async { + tokio::join!( + async { + let (mut send, _recv) = client.raw_connection().open_bi().await.unwrap(); + send.set_priority(StreamKind::Control.priority()).unwrap(); + write_frame_bounded( + &mut send, + &StreamHeader { + protocol_version: PROTOCOL_VERSION, + stream_kind: StreamKind::Control, + direction: PeerDirection::ControllerToHost, + connection_stamp: client.connection_stamp(), + }, + Duration::from_secs(1), + ) + .await + .unwrap(); + write_frame_bounded(&mut send, &mislabeled, Duration::from_secs(1)) + .await + .unwrap(); + send.finish().unwrap(); + }, + async { + let mut accepted = server.accept_stream().await.unwrap(); + assert_eq!(accepted.header().stream_kind, StreamKind::Control); + assert_eq!( + accepted.send_stream().priority().unwrap(), + StreamKind::Bulk.priority() + ); + let error = accepted.read_request::().await.unwrap_err(); + assert_eq!(error.code, ErrorCode::InvalidFrame); + } + ) + }, + "mislabeled bulk request", + ) + .await; + let _ = (client_result, server_result); + + within( + async { tokio::join!(controller.close(), host.close()) }, + "roundtrip endpoint close", + ) + .await; + } + + #[tokio::test] + async fn events_stream_clears_lifetime_deadlines_and_bounds_each_write() { + let controller_identity = identity("events-liveness-controller"); + let host_identity = identity("events-liveness-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let controller = bind_direct_endpoint( + &controller_identity, + "events-liveness-controller-install", + epoch(93), + ) + .await; + let host = + bind_direct_endpoint(&host_identity, "events-liveness-host-install", epoch(53)).await; + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + host.authorize_incoming_controller(controller_id).unwrap(); + let cached = wait_for_cached(&host).await; + let (client, server) = connect_pair( + &controller, + &host, + &cached, + host_id, + "events-liveness-bootstrap", + "events-liveness-host-install", + ) + .await; + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "events-liveness-request".into(), + execution_target_id: client.execution_target_id().into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: client.connection_stamp(), + body: SyntheticEventsRequest { + stream_id: "events-liveness-stream".into(), + }, + }; + + let (client_result, server_result) = within( + async { + tokio::join!( + async { + let mut response = client.start_streaming_request(request).await?; + assert_eq!(response.stream_kind, StreamKind::Events); + assert!(response.operation_deadline.is_none()); + let frame: ResponseEnvelope = response.read().await?; + assert_eq!( + frame.result?, + SyntheticEventsFrame { + stream_id: "events-liveness-stream".into(), + sequence: 1, + } + ); + response.finish().await + }, + async { + let accepted = server.accept_stream().await?; + assert_eq!(accepted.header().stream_kind, StreamKind::Events); + assert!(accepted.operation_deadline.is_some()); + let mut request: AcceptedRequest = + accepted.read_request().await?; + assert!(request.stream.operation_deadline.is_none()); + assert_eq!( + request.stream.send.priority().unwrap(), + StreamKind::Events.priority() + ); + let envelope = request.request(); + let response = ResponseEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: envelope.request_id.clone(), + execution_target_id: envelope.execution_target_id.clone(), + connection_stamp: envelope.connection_stamp, + result: Ok(SyntheticEventsFrame { + stream_id: envelope.body.stream_id.clone(), + sequence: 1, + }), + }; + request.write_response_frame(&response).await?; + request.finish_response() + } + ) + }, + "Events lifetime/write deadline roundtrip", + ) + .await; + client_result.unwrap(); + server_result.unwrap(); + + within( + async { tokio::join!(controller.close(), host.close()) }, + "Events liveness endpoint close", + ) + .await; + } + + #[tokio::test] + async fn application_stream_deadlines_recover_for_next_typed_request() { + let controller_identity = identity("application-timeout-controller"); + let host_identity = identity("application-timeout-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let controller = bind_direct_endpoint( + &controller_identity, + "application-timeout-controller-install", + epoch(92), + ) + .await; + let host = bind_direct_endpoint( + &host_identity, + "application-timeout-host-install", + epoch(52), + ) + .await; + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + host.authorize_incoming_controller(controller_id).unwrap(); + let cached = wait_for_cached(&host).await; + let (client, server) = connect_pair( + &controller, + &host, + &cached, + host_id, + "application-timeout-bootstrap", + "application-timeout-host-install", + ) + .await; + + let error = within( + server.accept_stream(), + "silent application stream accept deadline", + ) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::TransportUnavailable); + + let (mut silent_header, _recv) = + within(client.raw_connection().open_bi(), "silent header stream") + .await + .unwrap(); + // Sending only the length prefix makes the QUIC stream observable while + // leaving the header payload silent until the bounded frame deadline. + silent_header + .write_all(&32_u32.to_be_bytes()) + .await + .unwrap(); + let error = within(server.accept_stream(), "silent application header deadline") + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::TransportUnavailable); + drop(silent_header); + + let (mut partial_body, _recv) = + within(client.raw_connection().open_bi(), "partial body stream") + .await + .unwrap(); + write_frame_bounded( + &mut partial_body, + &StreamHeader { + protocol_version: PROTOCOL_VERSION, + stream_kind: StreamKind::Bulk, + direction: PeerDirection::ControllerToHost, + connection_stamp: client.connection_stamp(), + }, + Duration::from_secs(1), + ) + .await + .unwrap(); + partial_body.write_all(&32_u32.to_be_bytes()).await.unwrap(); + partial_body.write_all(&[0xa1]).await.unwrap(); + let accepted = within(server.accept_stream(), "valid application header") + .await + .unwrap(); + let error = within( + accepted.read_request::(), + "partial application body deadline", + ) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::TransportUnavailable); + drop(partial_body); + + let (mut trailing_body, _recv) = + within(client.raw_connection().open_bi(), "trailing body stream") + .await + .unwrap(); + write_frame_bounded( + &mut trailing_body, + &StreamHeader { + protocol_version: PROTOCOL_VERSION, + stream_kind: StreamKind::Bulk, + direction: PeerDirection::ControllerToHost, + connection_stamp: client.connection_stamp(), + }, + Duration::from_secs(1), + ) + .await + .unwrap(); + let request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "trailing-body".into(), + execution_target_id: "application-timeout-host-install".into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: client.connection_stamp(), + body: PageRequest::default(), + }; + let mut encoded = encode_frame_bounded(&request).unwrap(); + encoded.push(0xf6); + trailing_body + .write_all(&(encoded.len() as u32).to_be_bytes()) + .await + .unwrap(); + trailing_body.write_all(&encoded).await.unwrap(); + let accepted = within(server.accept_stream(), "trailing body header") + .await + .unwrap(); + assert_eq!( + accepted + .read_request::() + .await + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + + assert_page_roundtrip(&client, &server, "page-after-application-timeouts").await; + within( + async { tokio::join!(controller.close(), host.close()) }, + "application timeout endpoint close", + ) + .await; + } + + #[tokio::test] + async fn host_stamps_advance_and_manager_rejects_floor_then_supersedes() { + let controller_identity = identity("stamp-controller"); + let host_identity = identity("stamp-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let controller = + bind_direct_endpoint(&controller_identity, "stamp-controller-install", epoch(91)).await; + let host = bind_direct_endpoint(&host_identity, "stamp-host-install", epoch(77)).await; + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + host.authorize_incoming_controller(controller_id).unwrap(); + let cached = wait_for_cached(&host).await; + let manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + "stamp-host-install", + test_pairing_fence(), + Some(ConnectionStamp::new(77, 1).unwrap()), + ) + .unwrap(); + + assert_eq!( + within( + controller.connect_and_install_cached( + &manager, + &cached, + host_id, + "stamp-bootstrap-1", + "stamp-host-install", + ), + "floor-rejected managed handover", + ) + .await + .unwrap_err() + .code, + ErrorCode::StaleGeneration + ); + + let (second_client, second_server) = connect_pair_managed( + &controller, + &host, + &manager, + &cached, + host_id, + "stamp-bootstrap-2", + "stamp-host-install", + ) + .await; + assert_eq!( + second_client.connection_stamp(), + second_server.connection_stamp() + ); + assert_eq!(second_client.connection_stamp().host_epoch(), 77); + assert_eq!(second_client.connection_stamp().generation(), 2); + let current = second_client; + + let (third_client, third_server) = connect_pair_managed( + &controller, + &host, + &manager, + &cached, + host_id, + "stamp-bootstrap-3", + "stamp-host-install", + ) + .await; + assert_eq!( + third_client.connection_stamp(), + third_server.connection_stamp() + ); + assert_eq!(third_client.connection_stamp().generation(), 3); + let newest = third_client; + assert_eq!(newest.connection_stamp().generation(), 3); + within( + current.raw_connection().closed(), + "manager superseded close", + ) + .await; + within( + second_server.raw_connection().closed(), + "host sequential supersession close", + ) + .await; + manager.clear().unwrap(); + within( + third_server.raw_connection().closed(), + "manager clear close", + ) + .await; + + within( + async { tokio::join!(controller.close(), host.close()) }, + "stamp endpoint close", + ) + .await; + } + + #[tokio::test] + async fn installed_ack_loss_keeps_previous_generation_routable() { + let fixture = handover_fixture("ack-loss", 301).await; + let mut handover = open_raw_handover_to_ready(&fixture, "ack-loss-b").await; + + // Ready alone cannot gate A. Exercise an actual typed request while B + // is pending and has no application dispatcher. + assert_page_roundtrip( + &fixture.current_client, + &fixture.current_server, + "ack-loss-a-still-routable", + ) + .await; + handover + .send + .reset(iroh::endpoint::VarInt::from_u32(0x4d_41)) + .unwrap(); + let _ = handover + .recv + .stop(iroh::endpoint::VarInt::from_u32(0x4d_41)); + wait_for_handover_rollback(&fixture).await; + + let current = fixture.manager.current().unwrap().unwrap(); + assert_eq!( + current.connection_stamp(), + fixture.current_client.connection_stamp() + ); + assert!(fixture + .current_client + .raw_connection() + .close_reason() + .is_none()); + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn wrong_or_stale_installed_ack_rolls_back_to_previous_generation() { + let fixture = handover_fixture("wrong-ack", 311).await; + let mut handover = open_raw_handover_to_ready(&fixture, "wrong-ack-b").await; + let mut wrong = handover.installed(); + wrong.previous_connection_stamp = None; + let deadline = tokio::time::Instant::now() + TEST_TIMEOUT; + write_frame_until(&mut handover.send, &wrong, deadline) + .await + .unwrap(); + within(handover.connection.closed(), "wrong acknowledgment close").await; + wait_for_handover_rollback(&fixture).await; + + assert_eq!( + fixture + .manager + .current() + .unwrap() + .unwrap() + .connection_stamp(), + fixture.current_client.connection_stamp() + ); + assert_page_roundtrip( + &fixture.current_client, + &fixture.current_server, + "wrong-ack-a-still-routable", + ) + .await; + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn candidate_death_before_installed_ack_preserves_previous_generation() { + let fixture = handover_fixture("candidate-death", 321).await; + let handover = open_raw_handover_to_ready(&fixture, "candidate-death-b").await; + handover.connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_42), + b"test candidate died before Installed", + ); + wait_for_handover_rollback(&fixture).await; + + assert!(fixture + .current_client + .raw_connection() + .close_reason() + .is_none()); + assert_page_roundtrip( + &fixture.current_client, + &fixture.current_server, + "candidate-death-a-still-routable", + ) + .await; + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn simultaneous_handover_candidate_cannot_nest_over_pending_candidate() { + let fixture = handover_fixture("simultaneous", 331).await; + let mut pending_b = open_raw_handover_to_ready(&fixture, "simultaneous-b").await; + + let competing = within( + fixture.controller.connect_and_install_cached( + &fixture.manager, + &fixture.cached_host, + fixture.host_id, + "simultaneous-c", + &fixture.target_id, + ), + "competing handover rejection", + ) + .await; + assert!(competing.is_err()); + assert_eq!( + fixture + .manager + .current() + .unwrap() + .unwrap() + .connection_stamp(), + fixture.current_client.connection_stamp() + ); + + pending_b + .send + .reset(iroh::endpoint::VarInt::from_u32(0x4d_43)) + .unwrap(); + wait_for_handover_rollback(&fixture).await; + assert_page_roundtrip( + &fixture.current_client, + &fixture.current_server, + "simultaneous-a-still-routable", + ) + .await; + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn successful_handover_retires_a_only_after_b_is_finalized() { + let fixture = handover_fixture("successful-handover", 341).await; + let a_client = fixture.current_client.clone(); + let a_server = fixture.current_server.clone(); + let (b_client, b_server) = connect_pair_managed( + &fixture.controller, + &fixture.host, + &fixture.manager, + &fixture.cached_host, + fixture.host_id, + "successful-handover-b", + &fixture.target_id, + ) + .await; + + assert!(b_client.connection_stamp() > a_client.connection_stamp()); + assert_eq!(b_client.connection_stamp(), b_server.connection_stamp()); + within(a_client.wait_closed(), "retired controller A").await; + within(a_server.wait_closed(), "retired host A").await; + assert_eq!( + fixture + .manager + .current() + .unwrap() + .unwrap() + .connection_stamp(), + b_client.connection_stamp() + ); + assert_page_roundtrip(&b_client, &b_server, "successful-handover-b-routable").await; + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn controller_restart_restores_committed_lineage_without_live_handle() { + let fixture = handover_fixture("controller-lineage-restart", 346).await; + let a_stamp = fixture.current_client.connection_stamp(); + let HandoverFixture { + controller, + host, + manager, + cached_host, + controller_id, + host_id, + target_id, + current_client, + current_server, + } = fixture; + let snapshot = manager.capture_lineage().unwrap(); + assert_eq!(snapshot.last_committed(), Some(a_stamp)); + assert!(!snapshot.requires_reconciliation()); + within( + current_client.wait_closed(), + "captured controller lineage closes A", + ) + .await; + within( + current_server.wait_closed(), + "host observes captured A close", + ) + .await; + let restored = GenerationConnectionManager::restore_for_pairing( + snapshot, + controller_id, + host_id, + target_id.clone(), + test_pairing_fence(), + ) + .unwrap(); + let (b_client, b_server) = connect_pair_managed( + &controller, + &host, + &restored, + &cached_host, + host_id, + "controller-lineage-restart-b", + &target_id, + ) + .await; + assert!(b_client.connection_stamp() > a_stamp); + assert_eq!(b_client.connection_stamp(), b_server.connection_stamp()); + restored.clear().unwrap(); + within( + async { tokio::join!(controller.close(), host.close()) }, + "controller lineage restart close", + ) + .await; + } + + #[tokio::test] + async fn lost_finalized_survives_controller_restart_and_reconciles_before_next_dial() { + let fixture = handover_fixture("lost-finalized-reconcile", 347).await; + let mut handover = open_raw_handover_to_ready(&fixture, "lost-finalized-reconcile-b").await; + let b_stamp = handover.connection_stamp; + let candidate = ConnectedPeer::new( + handover.connection.clone(), + b_stamp, + fixture.manager.pairing_fence, + Arc::from(fixture.target_id.as_str()), + PeerDirection::ControllerToHost, + fixture.controller.connection_policy.frame_deadline, + ); + let prepared = fixture + .manager + .begin_handover(candidate, handover.pending(&fixture)) + .unwrap(); + handover.install_and_expect_committed().await; + let promoted = prepared.promote().unwrap(); + let awaiting = promoted.mark_observed_sent().unwrap(); + let deadline = tokio::time::Instant::now() + TEST_TIMEOUT; + let observed = handover.observed(); + write_frame_until(&mut handover.send, &observed, deadline) + .await + .unwrap(); + handover.send.finish().unwrap(); + within( + async { + loop { + if fixture + .host + .admission + .state + .read() + .unwrap() + .incoming_controllers + .committed_lineage + .get(&fixture.controller_id) + == Some(&b_stamp) + { + break; + } + tokio::task::yield_now().await; + } + }, + "host commits B before Finalized loss", + ) + .await; + // Lose the correlated Finalized frame and every B/A handle. The exact + // PendingCommit, not liveness, is the only safe reconstruction input. + handover.connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_4b), + b"test drops Finalized response", + ); + drop(awaiting); + drop(handover); + + let HandoverFixture { + controller, + host, + manager, + cached_host, + controller_id, + host_id, + target_id, + current_client, + current_server, + } = fixture; + let snapshot = manager.capture_lineage().unwrap(); + assert!(snapshot.requires_reconciliation()); + assert_eq!( + snapshot.last_committed(), + Some(current_client.connection_stamp()) + ); + let restored = GenerationConnectionManager::restore_for_pairing( + snapshot, + controller_id, + host_id, + target_id.clone(), + test_pairing_fence(), + ) + .unwrap(); + let (c_client, first_dequeued) = within( + async { + tokio::join!( + controller.connect_and_install_cached( + &restored, + &cached_host, + host_id, + "lost-finalized-reconcile-c", + &target_id, + ), + host.accept_authenticated(), + ) + }, + "reconcile then install C", + ) + .await; + let c_client = c_client.unwrap(); + let first_dequeued = first_dequeued.unwrap(); + let c_server = if first_dequeued.connection_stamp() == c_client.connection_stamp() { + first_dequeued + } else { + // B was legitimately routable while C's handover was in progress. + // Once C finalizes, B closes and the next dequeue is C. + within(first_dequeued.wait_closed(), "reconciled B retires after C").await; + within(host.accept_authenticated(), "dequeue C after reconciled B") + .await + .unwrap() + }; + assert!(c_client.connection_stamp() > b_stamp); + assert_eq!(c_client.connection_stamp(), c_server.connection_stamp()); + assert!(!restored.pending_reconciliation().unwrap().is_some()); + drop((current_client, current_server)); + restored.clear().unwrap(); + within( + async { tokio::join!(controller.close(), host.close()) }, + "lost Finalized reconciliation close", + ) + .await; + } + + #[tokio::test] + async fn validated_finalized_frame_commits_before_missing_eof() { + let fixture = handover_fixture("finalized-before-eof", 348).await; + let mut handover = open_raw_handover_to_ready(&fixture, "finalized-before-eof-b").await; + let candidate = ConnectedPeer::new( + handover.connection.clone(), + handover.connection_stamp, + fixture.manager.pairing_fence, + Arc::from(fixture.target_id.as_str()), + PeerDirection::ControllerToHost, + fixture.controller.connection_policy.frame_deadline, + ); + let prepared = fixture + .manager + .begin_handover(candidate, handover.pending(&fixture)) + .unwrap(); + handover.install_and_expect_committed().await; + let awaiting = prepared.promote().unwrap().mark_observed_sent().unwrap(); + + let deadline = tokio::time::Instant::now() + TEST_TIMEOUT; + let observed = handover.observed(); + write_frame_until(&mut handover.send, &observed, deadline) + .await + .unwrap(); + handover.send.finish().unwrap(); + let received: BootstrapFinalized = read_frame_until(&mut handover.recv, deadline) + .await + .unwrap(); + received + .validate( + &handover.request_id, + &handover.target_id, + handover.controller_id, + handover.pairing_fence, + handover.connection_stamp, + handover.previous_connection_stamp, + ) + .unwrap(); + let _ = awaiting.finalize(); + assert_eq!( + fixture.manager.current_stamp().unwrap(), + Some(handover.connection_stamp) + ); + // Locally lose the trailing FIN after the correlated frame. This is a + // framing/transport failure only; B was already selected. + handover + .recv + .stop(iroh::endpoint::VarInt::from_u32(0x4d_4c)) + .unwrap(); + let _ = expect_stream_end(&mut handover.recv, deadline).await; + assert_eq!( + fixture.manager.current_stamp().unwrap(), + Some(handover.connection_stamp) + ); + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn controller_missing_a_handle_reconnects_from_committed_lineage_immediately() { + let fixture = handover_fixture("controller-asymmetric-loss", 351).await; + let a_stamp = fixture.current_client.connection_stamp(); + { + let mut state = fixture.manager.state.lock().unwrap(); + let forgotten = state.current.take().expect("initial controller handle"); + drop(forgotten); + assert_eq!(state.last_committed, Some(a_stamp)); + } + assert_eq!(fixture.manager.current_stamp().unwrap(), Some(a_stamp)); + + let (b_client, b_server) = connect_pair_managed( + &fixture.controller, + &fixture.host, + &fixture.manager, + &fixture.cached_host, + fixture.host_id, + "controller-asymmetric-loss-b", + &fixture.target_id, + ) + .await; + assert!(b_client.connection_stamp() > a_stamp); + assert_eq!(b_client.connection_stamp(), b_server.connection_stamp()); + assert_eq!( + fixture.manager.current_stamp().unwrap(), + Some(b_client.connection_stamp()) + ); + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn host_missing_a_handle_reconnects_from_committed_lineage_immediately() { + let fixture = handover_fixture("host-asymmetric-loss", 361).await; + let a_stamp = fixture.current_server.connection_stamp(); + { + let mut state = fixture.host.admission.state.write().unwrap(); + let incoming = &mut state.incoming_controllers; + assert_eq!( + incoming.committed_lineage.get(&fixture.controller_id), + Some(&a_stamp) + ); + incoming.current.remove(&fixture.controller_id); + } + + let (b_client, b_server) = connect_pair_managed( + &fixture.controller, + &fixture.host, + &fixture.manager, + &fixture.cached_host, + fixture.host_id, + "host-asymmetric-loss-b", + &fixture.target_id, + ) + .await; + assert!(b_server.connection_stamp() > a_stamp); + assert_eq!(b_client.connection_stamp(), b_server.connection_stamp()); + assert_eq!( + fixture + .host + .admission + .state + .read() + .unwrap() + .incoming_controllers + .committed_lineage + .get(&fixture.controller_id), + Some(&b_server.connection_stamp()) + ); + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn revoke_and_explicit_repairing_reset_host_lineage_to_genesis() { + let fixture = handover_fixture("lineage-repairing", 366).await; + let a_stamp = fixture.current_server.connection_stamp(); + assert_eq!( + fixture + .host + .admission + .state + .read() + .unwrap() + .incoming_controllers + .committed_lineage + .get(&fixture.controller_id), + Some(&a_stamp) + ); + assert!(fixture + .host + .revoke_incoming_controller(&fixture.controller_id) + .unwrap()); + fixture + .host + .authorize_incoming_controller(fixture.controller_id) + .unwrap(); + assert!(!fixture + .host + .admission + .state + .read() + .unwrap() + .incoming_controllers + .committed_lineage + .contains_key(&fixture.controller_id)); + + // The old manager still names A and is fenced by the host's explicit + // revoke/re-pair. A fresh manager starts the new lineage with None. + let stale = within( + fixture.controller.connect_and_install_cached( + &fixture.manager, + &fixture.cached_host, + fixture.host_id, + "lineage-repairing-stale-a", + &fixture.target_id, + ), + "old lineage rejected after repairing", + ) + .await + .unwrap_err(); + assert!(matches!( + stale.code, + ErrorCode::StaleGeneration | ErrorCode::TransportUnavailable + )); + let repaired_manager = GenerationConnectionManager::new_for_pairing( + fixture.controller_id, + fixture.host_id, + fixture.target_id.clone(), + test_pairing_fence(), + None, + ) + .unwrap(); + let (new_client, new_server) = connect_pair_managed( + &fixture.controller, + &fixture.host, + &repaired_manager, + &fixture.cached_host, + fixture.host_id, + "lineage-repairing-genesis", + &fixture.target_id, + ) + .await; + assert_eq!(new_client.connection_stamp(), new_server.connection_stamp()); + repaired_manager.clear().unwrap(); + fixture.manager.clear().unwrap(); + within( + async { tokio::join!(fixture.controller.close(), fixture.host.close()) }, + "lineage repairing fixture close", + ) + .await; + } + + #[tokio::test] + async fn host_finalizes_b_when_a_dies_after_observed_before_eof() { + let fixture = handover_fixture("a-dies-after-observed", 371).await; + let mut handover = open_raw_handover_to_ready(&fixture, "a-dies-after-observed-b").await; + handover.install_and_expect_committed().await; + let deadline = tokio::time::Instant::now() + TEST_TIMEOUT; + let observed = handover.observed(); + write_frame_until(&mut handover.send, &observed, deadline) + .await + .unwrap(); + + // The host has the Observed frame but cannot finalize until the + // controller closes its send half. Lose A in that exact window. + fixture.current_server.raw_connection().close( + iroh::endpoint::VarInt::from_u32(0x4d_48), + b"test loses A after Observed", + ); + handover.send.finish().unwrap(); + handover.expect_finalized().await; + let b_server = within( + fixture.host.accept_authenticated(), + "host accepts B after A dies", + ) + .await + .unwrap(); + assert_eq!(b_server.connection_stamp(), handover.connection_stamp); + assert_eq!( + fixture + .host + .admission + .state + .read() + .unwrap() + .incoming_controllers + .committed_lineage + .get(&fixture.controller_id), + Some(&handover.connection_stamp) + ); + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn observed_write_ambiguity_never_resolves_from_handle_liveness() { + let fixture = handover_fixture("observed-ambiguity", 381).await; + let a_stamp = fixture.current_client.connection_stamp(); + { + let mut state = fixture.manager.state.lock().unwrap(); + drop(state.current.take()); + assert_eq!(state.last_committed, Some(a_stamp)); + } + let mut handover = open_raw_handover_to_ready(&fixture, "observed-ambiguity-b").await; + let candidate = ConnectedPeer::new( + handover.connection.clone(), + handover.connection_stamp, + fixture.manager.pairing_fence, + Arc::from(fixture.target_id.as_str()), + PeerDirection::ControllerToHost, + fixture.controller.connection_policy.frame_deadline, + ); + let prepared = fixture + .manager + .begin_handover(candidate, handover.pending(&fixture)) + .unwrap(); + handover.install_and_expect_committed().await; + let promoted = prepared.promote().unwrap(); + let token = promoted.token.as_ref().unwrap().clone(); + assert_eq!( + fixture.manager.finalize_awaiting(&token).unwrap_err().code, + ErrorCode::StaleGeneration + ); + let awaiting = promoted.mark_observed_sent().unwrap(); + + // Simulate a write which never reaches the host. B is live and the + // controller has no A handle, but neither fact proves host commit. + handover + .send + .reset(iroh::endpoint::VarInt::from_u32(0x4d_49)) + .unwrap(); + drop(awaiting); + assert_eq!(fixture.manager.current_stamp().unwrap(), Some(a_stamp)); + assert_eq!( + fixture.manager.current().unwrap_err().code, + ErrorCode::TransportUnavailable + ); + assert!(matches!( + fixture.manager.state.lock().unwrap().handover, + Some(ManagedHandover::AwaitingFinalized { .. }) + )); + wait_for_handover_rollback(&fixture).await; + assert_eq!(fixture.manager.current_stamp().unwrap(), Some(a_stamp)); + assert!(matches!( + fixture.manager.state.lock().unwrap().handover, + Some(ManagedHandover::AwaitingFinalized { .. }) + )); + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn finalized_b_lineage_survives_b_dying_before_local_finalize() { + let fixture = handover_fixture("b-dies-after-finalized", 391).await; + let a_stamp = fixture.current_client.connection_stamp(); + let mut handover = open_raw_handover_to_ready(&fixture, "b-dies-after-finalized-b").await; + let candidate = ConnectedPeer::new( + handover.connection.clone(), + handover.connection_stamp, + fixture.manager.pairing_fence, + Arc::from(fixture.target_id.as_str()), + PeerDirection::ControllerToHost, + fixture.controller.connection_policy.frame_deadline, + ); + let prepared = fixture + .manager + .begin_handover(candidate, handover.pending(&fixture)) + .unwrap(); + handover.install_and_expect_committed().await; + let promoted = prepared.promote().unwrap(); + let awaiting = promoted.mark_observed_sent().unwrap(); + let deadline = tokio::time::Instant::now() + TEST_TIMEOUT; + let observed = handover.observed(); + write_frame_until(&mut handover.send, &observed, deadline) + .await + .unwrap(); + handover.send.finish().unwrap(); + handover.expect_finalized().await; + + handover.connection.close( + iroh::endpoint::VarInt::from_u32(0x4d_4a), + b"test loses B after Finalized", + ); + assert_eq!( + awaiting.finalize().unwrap_err().code, + ErrorCode::TransportUnavailable + ); + assert_eq!( + fixture.manager.current_stamp().unwrap(), + Some(handover.connection_stamp) + ); + assert!(fixture.manager.current().unwrap().is_none()); + assert_ne!(fixture.manager.current_stamp().unwrap(), Some(a_stamp)); + close_handover_fixture(fixture).await; + } + + #[tokio::test] + async fn managed_reconnect_survives_quiescent_host_endpoint_rebuild() { + let controller_identity = identity("lineage-rebuild-controller"); + let host_identity = identity("lineage-rebuild-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let target_id = "lineage-rebuild-target"; + let pairing_incarnation = PairingIncarnation::new(7).unwrap(); + let clock = epoch(401); + + let controller = bind_direct_endpoint( + &controller_identity, + "lineage-rebuild-controller-install", + epoch(402), + ) + .await; + controller + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 17, + snapshot_revision: 1, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::from([(host_id, pairing_incarnation)]), + }) + .unwrap(); + let first_host = bind_direct_endpoint(&host_identity, target_id, clock.clone()).await; + first_host + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 17, + snapshot_revision: 1, + incoming_controllers: HashMap::from([(controller_id, pairing_incarnation)]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + let first_cached = wait_for_cached(&first_host).await; + let manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + target_id, + PairingFence::new(pairing_incarnation).unwrap(), + None, + ) + .unwrap(); + let (first_client, first_server) = connect_pair_managed( + &controller, + &first_host, + &manager, + &first_cached, + host_id, + "lineage-rebuild-first", + target_id, + ) + .await; + let first_stamp = first_client.connection_stamp(); + + let mut handoff = first_host.begin_lineage_handoff().unwrap(); + assert_eq!(handoff.snapshot().unwrap().local_endpoint(), host_id); + assert_eq!(handoff.snapshot().unwrap().execution_target_id(), target_id); + assert_eq!(handoff.snapshot().unwrap().account_epoch(), 17); + assert_eq!(handoff.snapshot().unwrap().incoming_controller_count(), 1); + within(handoff.ensure_source_closed(), "quiescent host close") + .await + .unwrap(); + within(first_client.wait_closed(), "captured controller A close").await; + within(first_server.wait_closed(), "captured host A close").await; + + // An unrelated current authorization revision retains the same pair + // incarnation and therefore the same committed generation lineage. + let second_host = within( + MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + clock, + AuthorizationSnapshot { + account_epoch: 17, + snapshot_revision: 9, + incoming_controllers: HashMap::from([(controller_id, pairing_incarnation)]), + outgoing_execution_targets: HashMap::new(), + }, + &mut handoff, + ), + "host endpoint lineage restore", + ) + .await + .unwrap(); + assert!(handoff.is_consumed()); + assert_eq!( + second_host + .admission + .state + .read() + .unwrap() + .incoming_controllers + .committed_lineage + .get(&controller_id), + Some(&first_stamp) + ); + assert_eq!( + second_host + .admission + .state + .read() + .unwrap() + .snapshot_revision, + 9 + ); + + let second_cached = wait_for_cached(&second_host).await; + let (second_client, second_server) = connect_pair_managed( + &controller, + &second_host, + &manager, + &second_cached, + host_id, + "lineage-rebuild-second", + target_id, + ) + .await; + assert!(second_client.connection_stamp() > first_stamp); + assert_eq!( + second_client.connection_stamp(), + second_server.connection_stamp() + ); + manager.clear().unwrap(); + within( + async { tokio::join!(controller.close(), second_host.close()) }, + "lineage rebuilt endpoints close", + ) + .await; + } + + #[tokio::test] + async fn host_lineage_restore_is_fenced_by_pairing_incarnation() { + let controller_identity = identity("lineage-incarnation-controller"); + let host_identity = identity("lineage-incarnation-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let target_id = "lineage-incarnation-target"; + let retained_incarnation = PairingIncarnation::new(3).unwrap(); + let repaired_incarnation = PairingIncarnation::new(4).unwrap(); + let retained_stamp = ConnectionStamp::new(411, 12).unwrap(); + let retained_transition = PendingCommit::new( + PairingFence::new(retained_incarnation).unwrap(), + "lineage-incarnation-commit", + target_id, + controller_id, + host_id, + Some(ConnectionStamp::new(411, 11).unwrap()), + retained_stamp, + ) + .unwrap(); + let lineage_fixture = || { + let incoming_authorization = HashMap::from([(controller_id, retained_incarnation)]); + EndpointLineageSnapshot { + local_endpoint: host_id, + execution_target_id: Arc::from(target_id), + account_epoch: 21, + snapshot_revision: 3, + authorization_digest: authorization_snapshot_digest( + 21, + 3, + &incoming_authorization, + &HashMap::new(), + ), + incoming_controllers: HashMap::from([( + controller_id, + IncomingControllerLineage { + pairing_incarnation: retained_incarnation, + last_committed: Some(retained_stamp), + finalized_transition: Some(retained_transition.clone()), + }, + )]), + } + }; + + let mut repaired_handoff = EndpointLineageHandoff::from_snapshot_fixture(lineage_fixture()); + + let repaired_host = within( + MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(411), + AuthorizationSnapshot { + account_epoch: 21, + snapshot_revision: 44, + incoming_controllers: HashMap::from([(controller_id, repaired_incarnation)]), + outgoing_execution_targets: HashMap::new(), + }, + &mut repaired_handoff, + ), + "repaired-incarnation host bind", + ) + .await + .unwrap(); + assert!(!repaired_host + .admission + .state + .read() + .unwrap() + .incoming_controllers + .committed_lineage + .contains_key(&controller_id)); + within(repaired_host.close(), "repaired-incarnation host close").await; + + let mut wrong_account_handoff = + EndpointLineageHandoff::from_snapshot_fixture(lineage_fixture()); + let wrong_account = within( + MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(411), + AuthorizationSnapshot { + account_epoch: 22, + snapshot_revision: 1, + incoming_controllers: HashMap::from([(controller_id, retained_incarnation)]), + outgoing_execution_targets: HashMap::new(), + }, + &mut wrong_account_handoff, + ), + "wrong-account host lineage rejection", + ) + .await + .unwrap_err(); + assert_eq!(wrong_account.code, ErrorCode::Unauthorized); + } + + #[test] + fn bootstrap_pairing_fence_is_bound_to_every_normal_phase() { + let controller = endpoint_id(&identity("phase-fence-controller")); + let expected = PairingFence::new(PairingIncarnation::new(7).unwrap()).unwrap(); + let wrong = PairingFence::new(PairingIncarnation::new(8).unwrap()).unwrap(); + let stamp = ConnectionStamp::new(421, 2).unwrap(); + let request = BootstrapRequest { + protocol_version: PROTOCOL_VERSION, + request_id: "phase-fence".into(), + execution_target_id: "phase-fence-target".into(), + bootstrap_generation: 0, + pairing_fence: wrong, + previous_connection_stamp: None, + reconciliation: None, + }; + assert_eq!( + request + .validate("phase-fence-target", expected) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + let response = BootstrapResponse { + protocol_version: PROTOCOL_VERSION, + request_id: "phase-fence".into(), + execution_target_id: "phase-fence-target".into(), + pairing_fence: wrong, + result: Ok(BootstrapAccepted { + connection_stamp: stamp, + }), + }; + assert_eq!( + response + .validate("phase-fence", "phase-fence-target", expected) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + let ready = BootstrapReady { + protocol_version: PROTOCOL_VERSION, + request_id: "phase-fence".into(), + execution_target_id: "phase-fence-target".into(), + controller_id: controller.to_string(), + pairing_fence: wrong, + connection_stamp: stamp, + previous_connection_stamp: None, + }; + assert_eq!( + ready + .validate( + "phase-fence", + "phase-fence-target", + controller, + expected, + stamp, + None, + ) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + + macro_rules! assert_phase_fence_rejected { + ($frame:expr) => {{ + assert_eq!( + $frame + .validate( + "phase-fence", + "phase-fence-target", + controller, + expected, + stamp, + None, + ) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + }}; + } + assert_phase_fence_rejected!(BootstrapInstalled { + protocol_version: PROTOCOL_VERSION, + request_id: "phase-fence".into(), + execution_target_id: "phase-fence-target".into(), + controller_id: controller.to_string(), + pairing_fence: wrong, + connection_stamp: stamp, + previous_connection_stamp: None, + }); + assert_phase_fence_rejected!(BootstrapCommitted { + protocol_version: PROTOCOL_VERSION, + request_id: "phase-fence".into(), + execution_target_id: "phase-fence-target".into(), + controller_id: controller.to_string(), + pairing_fence: wrong, + connection_stamp: stamp, + previous_connection_stamp: None, + }); + assert_phase_fence_rejected!(BootstrapCommitObserved { + protocol_version: PROTOCOL_VERSION, + request_id: "phase-fence".into(), + execution_target_id: "phase-fence-target".into(), + controller_id: controller.to_string(), + pairing_fence: wrong, + connection_stamp: stamp, + previous_connection_stamp: None, + }); + assert_phase_fence_rejected!(BootstrapFinalized { + protocol_version: PROTOCOL_VERSION, + request_id: "phase-fence".into(), + execution_target_id: "phase-fence-target".into(), + controller_id: controller.to_string(), + pairing_fence: wrong, + connection_stamp: stamp, + previous_connection_stamp: None, + }); + } + + #[tokio::test] + async fn async_pairing_incarnation_mismatch_cannot_stage_host_lineage() { + let controller_identity = identity("async-fence-controller"); + let host_identity = identity("async-fence-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let target_id = "async-fence-target"; + let old_incarnation = PairingIncarnation::new(9).unwrap(); + let repaired_incarnation = PairingIncarnation::new(10).unwrap(); + let controller = + bind_direct_endpoint(&controller_identity, "async-fence-client", epoch(431)).await; + let host = bind_direct_endpoint(&host_identity, target_id, epoch(432)).await; + controller + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 41, + snapshot_revision: 2, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::from([(host_id, repaired_incarnation)]), + }) + .unwrap(); + host.replace_authorizations(AuthorizationSnapshot { + account_epoch: 41, + snapshot_revision: 1, + incoming_controllers: HashMap::from([(controller_id, old_incarnation)]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + let manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + target_id, + PairingFence::new(repaired_incarnation).unwrap(), + None, + ) + .unwrap(); + let cached = wait_for_cached(&host).await; + let mismatch = within( + controller.connect_and_install_cached( + &manager, + &cached, + host_id, + "async-fence-bootstrap", + target_id, + ), + "async pairing-fence rejection", + ) + .await + .unwrap_err(); + assert_eq!(mismatch.code, ErrorCode::Unauthorized); + assert!(!mismatch.retryable); + let state = host.admission.state.read().unwrap(); + assert!(state.incoming_controllers.committed_lineage.is_empty()); + assert!(state.incoming_controllers.activating.is_empty()); + drop(state); + assert_eq!(manager.current_stamp().unwrap(), None); + within( + async { tokio::join!(controller.close(), host.close()) }, + "async pairing-fence endpoints close", + ) + .await; + } + + #[tokio::test] + async fn independent_local_account_epochs_share_incarnation_and_repair_fences_dispatch() { + let controller_identity = identity("local-epoch-controller"); + let host_identity = identity("local-epoch-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let target_id = "local-epoch-target"; + let old_incarnation = PairingIncarnation::new(61).unwrap(); + let new_incarnation = PairingIncarnation::new(62).unwrap(); + let old_fence = PairingFence::new(old_incarnation).unwrap(); + let new_fence = PairingFence::new(new_incarnation).unwrap(); + let controller = + bind_direct_endpoint(&controller_identity, "local-epoch-controller", epoch(433)).await; + let host = bind_direct_endpoint(&host_identity, target_id, epoch(434)).await; + + // Account epochs are installation-local. The shared directed pairing + // incarnation is the only authorization lineage placed on the wire. + controller + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 7, + snapshot_revision: 1, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::from([(host_id, old_incarnation)]), + }) + .unwrap(); + host.replace_authorizations(AuthorizationSnapshot { + account_epoch: 41, + snapshot_revision: 1, + incoming_controllers: HashMap::from([(controller_id, old_incarnation)]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + let cached = wait_for_cached(&host).await; + let old_manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + target_id, + old_fence, + None, + ) + .unwrap(); + let (old_client, old_server) = connect_pair_managed( + &controller, + &host, + &old_manager, + &cached, + host_id, + "local-epoch-old", + target_id, + ) + .await; + assert_eq!(old_client.pairing_fence(), old_fence); + assert_eq!(old_server.pairing_fence(), old_fence); + host.validate_current_incoming_peer(&old_server).unwrap(); + + // Hold a fully classified old-incarnation stream at the adapter + // boundary, then revoke and re-pair the same EndpointId. Revalidation + // must reject it before its request body can be dispatched. + let old_request = RequestEnvelope { + protocol_version: PROTOCOL_VERSION, + request_id: "old-incarnation-prepared".into(), + execution_target_id: target_id.into(), + direction: PeerDirection::ControllerToHost, + connection_stamp: old_client.connection_stamp(), + body: PageRequest::default(), + }; + let old_request_client = old_client.clone(); + let old_request_task = tokio::spawn(async move { + old_request_client + .request::>(&old_request) + .await + }); + let old_prepared = within( + old_server.accept_stream(), + "old-incarnation prepared stream", + ) + .await + .unwrap(); + + controller + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 7, + snapshot_revision: 2, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::from([(host_id, new_incarnation)]), + }) + .unwrap(); + host.replace_authorizations(AuthorizationSnapshot { + account_epoch: 41, + snapshot_revision: 2, + incoming_controllers: HashMap::from([(controller_id, new_incarnation)]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + let revoked = host + .validate_current_incoming_peer(&old_server) + .unwrap_err(); + assert_eq!(revoked.code, ErrorCode::Revoked); + assert!(!revoked.retryable); + drop(old_prepared); + assert!(within(old_request_task, "old-incarnation request stops") + .await + .unwrap() + .is_err()); + + let stale_manager = within( + controller.connect_and_install_cached( + &old_manager, + &cached, + host_id, + "local-epoch-stale-incarnation", + target_id, + ), + "old-incarnation manager rejection", + ) + .await + .unwrap_err(); + assert_eq!(stale_manager.code, ErrorCode::Unauthorized); + assert!(!stale_manager.retryable); + old_manager.clear().unwrap(); + + let new_manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + target_id, + new_fence, + None, + ) + .unwrap(); + let (fresh_client, fresh_server) = connect_pair_managed( + &controller, + &host, + &new_manager, + &cached, + host_id, + "local-epoch-fresh-incarnation", + target_id, + ) + .await; + assert_eq!(fresh_server.pairing_fence(), new_fence); + host.validate_current_incoming_peer(&fresh_server).unwrap(); + assert_page_roundtrip(&fresh_client, &fresh_server, "local-epoch-fresh-dispatch").await; + + // A host-local account transition remains an independent hard fence: + // it clears grants, live generations, activations, and lineage without + // requiring the controller's local epoch to match. + host.replace_authorizations(AuthorizationSnapshot { + account_epoch: 42, + snapshot_revision: 1, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + within( + fresh_server.wait_closed(), + "host-local account switch closes generation", + ) + .await; + assert_eq!( + host.validate_current_incoming_peer(&fresh_server) + .unwrap_err() + .code, + ErrorCode::Revoked + ); + { + let state = host.admission.state.read().unwrap(); + assert!(state.incoming_controllers.allowed.is_empty()); + assert!(state.incoming_controllers.active.is_empty()); + assert!(state.incoming_controllers.current.is_empty()); + assert!(state.incoming_controllers.activating.is_empty()); + assert!(state.incoming_controllers.committed_lineage.is_empty()); + assert!(state.incoming_controllers.finalized_transitions.is_empty()); + } + + new_manager.clear().unwrap(); + within( + async { tokio::join!(controller.close(), host.close()) }, + "local-epoch endpoints close", + ) + .await; + } + + #[tokio::test] + async fn newer_local_account_terminally_fences_lineage_handoff_before_preflight() { + let host_identity = identity("account-advance-handoff-host"); + let controller_id = endpoint_id(&identity("account-advance-handoff-controller")); + let target_id = "account-advance-handoff-target"; + let incarnation = PairingIncarnation::new(63).unwrap(); + let host = bind_direct_endpoint(&host_identity, target_id, epoch(435)).await; + let current = AuthorizationSnapshot { + account_epoch: 51, + snapshot_revision: 5, + incoming_controllers: HashMap::from([(controller_id, incarnation)]), + outgoing_execution_targets: HashMap::new(), + }; + host.replace_authorizations(current.clone()).unwrap(); + let mut handoff = host.begin_lineage_handoff().unwrap(); + + let lower = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(435), + AuthorizationSnapshot { + account_epoch: 50, + snapshot_revision: 99, + incoming_controllers: HashMap::from([(controller_id, incarnation)]), + outgoing_execution_targets: HashMap::new(), + }, + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(lower.code, ErrorCode::Revoked); + assert!(!handoff.is_consumed()); + + // The intentionally invalid target proves observing a newer local + // account is the constructor's first operation, before ID/key/bind + // preflight can fail and accidentally preserve old-account authority. + let advanced = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + "invalid target with spaces", + epoch(435), + AuthorizationSnapshot { + account_epoch: 52, + snapshot_revision: 1, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::new(), + }, + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(advanced.code, ErrorCode::Unauthorized); + assert!(!advanced.retryable); + assert!(handoff.is_consumed()); + + let old_account_retry = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(435), + current, + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(old_account_retry.code, ErrorCode::StaleGeneration); + } + + #[tokio::test] + async fn host_lineage_handoff_survives_cancellation_failure_and_revision_rollback() { + let host_identity = identity("retryable-lineage-host"); + let controller_id = endpoint_id(&identity("retryable-lineage-controller")); + let outgoing_id = endpoint_id(&identity("retryable-lineage-outgoing")); + let host_id = endpoint_id(&host_identity); + let target_id = "retryable-lineage-target"; + let incarnation = PairingIncarnation::new(11).unwrap(); + let outgoing_incarnation = PairingIncarnation::new(12).unwrap(); + let authorization = + |snapshot_revision: u64, include_incoming: bool, include_outgoing: bool| { + AuthorizationSnapshot { + account_epoch: 51, + snapshot_revision, + incoming_controllers: include_incoming + .then(|| HashMap::from([(controller_id, incarnation)])) + .unwrap_or_default(), + outgoing_execution_targets: include_outgoing + .then(|| HashMap::from([(outgoing_id, outgoing_incarnation)])) + .unwrap_or_default(), + } + }; + let host = bind_direct_endpoint(&host_identity, target_id, epoch(441)).await; + host.replace_authorizations(authorization(5, true, true)) + .unwrap(); + let mut handoff = host.begin_lineage_handoff().unwrap(); + assert_eq!(handoff.snapshot().unwrap().local_endpoint(), host_id); + assert_eq!( + handoff.snapshot().unwrap().authorization_revision_floor(), + 5 + ); + + let close_gate = Arc::new(tokio::sync::Notify::new()); + handoff.gate_source_close(close_gate); + assert!( + tokio::time::timeout(Duration::from_millis(20), handoff.ensure_source_closed(),) + .await + .is_err() + ); + assert!(!handoff.is_consumed()); + assert_eq!( + handoff.snapshot().unwrap().authorization_revision_floor(), + 5 + ); + + let equal_revision_incoming_fork = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(5, false, true), + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(equal_revision_incoming_fork.code, ErrorCode::Revoked); + let equal_revision_outgoing_fork = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(5, true, false), + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(equal_revision_outgoing_fork.code, ErrorCode::Revoked); + assert!(!handoff.is_consumed()); + + let stale = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(4, true, true), + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(stale.code, ErrorCode::Revoked); + assert!(!handoff.is_consumed()); + + assert!(tokio::time::timeout( + Duration::from_millis(20), + MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(6, true, true), + &mut handoff, + ), + ) + .await + .is_err()); + assert_eq!(handoff.authorization_floor_revision, 6); + assert!(!handoff.is_consumed()); + handoff.ungate_source_close(); + + handoff.fail_next_bind(); + let transient = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(6, true, true), + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(transient.code, ErrorCode::TransportUnavailable); + assert!(!handoff.is_consumed()); + assert_eq!(handoff.authorization_floor_revision, 6); + + let rolled_back_after_failure = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(5, true, true), + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(rolled_back_after_failure.code, ErrorCode::Revoked); + let forked_after_failure = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(6, false, true), + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(forked_after_failure.code, ErrorCode::Revoked); + + let rebuilt = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(6, true, true), + &mut handoff, + ) + .await + .unwrap(); + assert!(handoff.is_consumed()); + let reused = MapleIrohEndpoint::bind_direct_restoring_lineage( + &host_identity, + target_id, + epoch(441), + authorization(7, true, true), + &mut handoff, + ) + .await + .unwrap_err(); + assert_eq!(reused.code, ErrorCode::StaleGeneration); + within(rebuilt.close(), "retryable lineage rebuilt host close").await; + } + + #[tokio::test] + async fn shared_host_clock_survives_endpoint_rebuild() { + let controller_identity = identity("clock-rebuild-controller"); + let host_identity = identity("clock-rebuild-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let clock = epoch(78); + let controller = bind_direct_endpoint( + &controller_identity, + "clock-rebuild-controller-install", + epoch(79), + ) + .await; + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + + let first_host = + bind_direct_endpoint(&host_identity, "clock-rebuild-host-install", clock.clone()).await; + first_host + .authorize_incoming_controller(controller_id) + .unwrap(); + let first_cached = wait_for_cached(&first_host).await; + let (first_client, first_server) = connect_pair( + &controller, + &first_host, + &first_cached, + host_id, + "clock-rebuild-first", + "clock-rebuild-host-install", + ) + .await; + assert_eq!(first_client.connection_stamp().generation(), 1); + within(first_host.close(), "first rebuilt endpoint close").await; + within( + first_client.wait_closed(), + "first rebuilt client close signal", + ) + .await; + within( + first_server.wait_closed(), + "first rebuilt server close signal", + ) + .await; + + let second_host = + bind_direct_endpoint(&host_identity, "clock-rebuild-host-install", clock).await; + second_host + .authorize_incoming_controller(controller_id) + .unwrap(); + let second_cached = wait_for_cached(&second_host).await; + let (second_client, second_server) = connect_pair( + &controller, + &second_host, + &second_cached, + host_id, + "clock-rebuild-second", + "clock-rebuild-host-install", + ) + .await; + assert_eq!(second_client.connection_stamp().host_epoch(), 78); + assert_eq!(second_client.connection_stamp().generation(), 2); + assert_eq!( + second_client.connection_stamp(), + second_server.connection_stamp() + ); + + within( + async { tokio::join!(controller.close(), second_host.close()) }, + "rebuilt endpoint final close", + ) + .await; + } + + #[tokio::test] + async fn accept_skips_superseded_or_closed_queued_generations() { + let controller_identity = identity("queued-generation-controller"); + let host_identity = identity("queued-generation-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let controller = bind_direct_endpoint( + &controller_identity, + "queued-generation-controller-install", + epoch(80), + ) + .await; + let host = + bind_direct_endpoint(&host_identity, "queued-generation-host-install", epoch(81)).await; + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + host.authorize_incoming_controller(controller_id).unwrap(); + let cached = wait_for_cached(&host).await; + let manager = GenerationConnectionManager::new_for_pairing( + controller_id, + host_id, + "queued-generation-host-install", + test_pairing_fence(), + None, + ) + .unwrap(); + + // Let two complete handshakes queue before the router dequeues either. + let first = within( + controller.connect_and_install_cached( + &manager, + &cached, + host_id, + "queued-generation-first", + "queued-generation-host-install", + ), + "first queued generation", + ) + .await + .unwrap(); + let second = within( + controller.connect_and_install_cached( + &manager, + &cached, + host_id, + "queued-generation-second", + "queued-generation-host-install", + ), + "second queued generation", + ) + .await + .unwrap(); + within(first.wait_closed(), "superseded generation loss signal").await; + let accepted = within(host.accept_authenticated(), "skip superseded queued peer") + .await + .unwrap(); + assert_eq!(accepted.connection_stamp(), second.connection_stamp()); + + let third = within( + controller.connect_and_install_cached( + &manager, + &cached, + host_id, + "queued-generation-third", + "queued-generation-host-install", + ), + "third queued generation", + ) + .await + .unwrap(); + within(accepted.wait_closed(), "accepted generation superseded").await; + + // A closed current generation must not remain current merely because + // the accepted queue still owns a strong handle. Dequeue must skip it + // and wait for the next live generation. + third.raw_connection().close( + iroh::endpoint::VarInt::from_u32(0), + b"closed before host dequeue", + ); + within(third.wait_closed(), "explicit queued close signal").await; + within( + async { + while host.admission.is_current_incoming( + &controller_id, + third.connection_stamp(), + third.pairing_fence(), + ) { + tokio::task::yield_now().await; + } + }, + "host observes queued connection close", + ) + .await; + let (fourth, dequeued) = within( + async { + tokio::join!( + controller.connect_and_install_cached( + &manager, + &cached, + host_id, + "queued-generation-fourth", + "queued-generation-host-install", + ), + host.accept_authenticated(), + ) + }, + "replace closed queued generation", + ) + .await; + assert_eq!( + fourth.unwrap().connection_stamp(), + dequeued.unwrap().connection_stamp() + ); + + within( + async { tokio::join!(controller.close(), host.close()) }, + "queued generation endpoint close", + ) + .await; + } + + #[tokio::test] + async fn forward_pairing_is_one_way_and_directional_revocation_isolated() { + let a_identity = identity("direction-device-a"); + let b_identity = identity("direction-device-b"); + let a_id = endpoint_id(&a_identity); + let b_id = endpoint_id(&b_identity); + let a = bind_direct_endpoint(&a_identity, "device-a", epoch(11)).await; + let b = bind_direct_endpoint(&b_identity, "device-b", epoch(22)).await; + a.authorize_outgoing_execution_target(b_id).unwrap(); + b.authorize_incoming_controller(a_id).unwrap(); + let cached_a = wait_for_cached(&a).await; + let cached_b = wait_for_cached(&b).await; + let (forward_client, forward_server) = + connect_pair(&a, &b, &cached_b, b_id, "forward-bootstrap", "device-b").await; + + assert_eq!( + b.connect_cached(&cached_a, a_id, "implicit-reverse", "device-a") + .await + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + + b.authorize_outgoing_execution_target(a_id).unwrap(); + a.authorize_incoming_controller(b_id).unwrap(); + let (reverse_client, reverse_server) = + connect_pair(&b, &a, &cached_a, a_id, "explicit-reverse", "device-a").await; + + assert!(b.revoke_incoming_controller(&a_id).unwrap()); + within( + forward_server.raw_connection().closed(), + "incoming revoke local close", + ) + .await; + within( + forward_client.raw_connection().closed(), + "incoming revoke remote close", + ) + .await; + assert!(tokio::time::timeout( + Duration::from_millis(100), + reverse_client.raw_connection().closed(), + ) + .await + .is_err()); + assert_page_roundtrip(&reverse_client, &reverse_server, "reverse-still-usable").await; + + assert!(b.revoke_outgoing_execution_target(&a_id).unwrap()); + within( + reverse_client.raw_connection().closed(), + "outgoing revoke local close", + ) + .await; + within( + reverse_server.raw_connection().closed(), + "outgoing revoke remote close", + ) + .await; + + within( + async { tokio::join!(a.close(), b.close()) }, + "directional endpoint close", + ) + .await; + } + + #[tokio::test] + async fn authorization_snapshot_replacement_and_clear_close_only_revoked_active() { + let a_identity = identity("snapshot-device-a"); + let b_identity = identity("snapshot-device-b"); + let a_id = endpoint_id(&a_identity); + let b_id = endpoint_id(&b_identity); + let a = bind_direct_endpoint(&a_identity, "snapshot-a", epoch(31)).await; + let b = bind_direct_endpoint(&b_identity, "snapshot-b", epoch(32)).await; + a.replace_authorizations(AuthorizationSnapshot { + account_epoch: 1, + snapshot_revision: 1, + incoming_controllers: paired([b_id]), + outgoing_execution_targets: paired([b_id]), + }) + .unwrap(); + b.replace_authorizations(AuthorizationSnapshot { + account_epoch: 1, + snapshot_revision: 1, + incoming_controllers: paired([a_id]), + outgoing_execution_targets: paired([a_id]), + }) + .unwrap(); + let cached_a = wait_for_cached(&a).await; + let cached_b = wait_for_cached(&b).await; + let (forward_client, forward_server) = + connect_pair(&a, &b, &cached_b, b_id, "snapshot-forward", "snapshot-b").await; + let (reverse_client, reverse_server) = + connect_pair(&b, &a, &cached_a, a_id, "snapshot-reverse", "snapshot-a").await; + + b.replace_authorizations(AuthorizationSnapshot { + account_epoch: 1, + snapshot_revision: 2, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: paired([a_id]), + }) + .unwrap(); + within( + forward_server.raw_connection().closed(), + "snapshot revoked local close", + ) + .await; + within( + forward_client.raw_connection().closed(), + "snapshot revoked remote close", + ) + .await; + assert!(tokio::time::timeout( + Duration::from_millis(100), + reverse_client.raw_connection().closed(), + ) + .await + .is_err()); + + b.replace_authorizations(AuthorizationSnapshot { + account_epoch: 2, + snapshot_revision: 1, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: paired([a_id]), + }) + .unwrap(); + within( + reverse_client.raw_connection().closed(), + "authorization epoch transition local close", + ) + .await; + within( + reverse_server.raw_connection().closed(), + "authorization epoch transition remote close", + ) + .await; + + b.clear_authorizations_and_close().unwrap(); + assert_eq!( + b.replace_authorizations(AuthorizationSnapshot { + account_epoch: 2, + snapshot_revision: 2, + incoming_controllers: paired([a_id]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap_err() + .code, + ErrorCode::Revoked + ); + b.replace_authorizations(AuthorizationSnapshot { + account_epoch: 3, + snapshot_revision: 1, + incoming_controllers: paired([a_id]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + + within( + async { tokio::join!(a.close(), b.close()) }, + "snapshot endpoint close", + ) + .await; + } + + #[test] + fn authorization_transition_receipt_names_removed_peer_and_account_epoch_change() { + let admission = PeerAdmission::default(); + let first = identity("receipt-controller-a") + .iroh_secret_key() + .unwrap() + .public(); + let second = identity("receipt-controller-b") + .iroh_secret_key() + .unwrap() + .public(); + let initial = admission + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 7, + snapshot_revision: 1, + incoming_controllers: paired([first]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + assert!(initial.previous().is_none()); + assert_eq!(initial.current().account_epoch(), 7); + assert!(initial.removed_incoming_controllers().is_empty()); + assert!(!initial.account_epoch_changed()); + + let removed = admission + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 7, + snapshot_revision: 2, + incoming_controllers: paired([second]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + assert_eq!(removed.previous().unwrap().snapshot_revision(), 1); + assert_eq!(removed.current().snapshot_revision(), 2); + assert_eq!(removed.removed_incoming_controllers(), &[first]); + assert!(!removed.account_epoch_changed()); + + let switched = admission + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 8, + snapshot_revision: 1, + incoming_controllers: HashMap::new(), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + assert_eq!(switched.previous().unwrap().account_epoch(), 7); + assert_eq!(switched.current().account_epoch(), 8); + assert_eq!(switched.removed_incoming_controllers(), &[second]); + assert!(switched.account_epoch_changed()); + } + + #[test] + fn admission_revision_exhaustion_cannot_preserve_authority_on_clear() { + let admission = PeerAdmission::default(); + let controller = identity("exhausted-revision-controller") + .iroh_secret_key() + .unwrap() + .public(); + admission + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 11, + snapshot_revision: 1, + incoming_controllers: paired([controller]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + admission.state.write().unwrap().admission_revision = u64::MAX; + admission.clear_all_and_close().unwrap(); + let state = admission.state.read().unwrap(); + assert!(state.authorization_disabled); + assert!(state.incoming_controllers.allowed.is_empty()); + assert!(state.outgoing_execution_targets.allowed.is_empty()); + assert_eq!(state.admission_revision, u64::MAX); + } + + #[tokio::test] + async fn silent_and_partial_bootstrap_streams_are_bounded() { + let host_identity = identity("bootstrap-timeout-host"); + let raw_identity = identity("bootstrap-timeout-controller"); + let raw_id = endpoint_id(&raw_identity); + let host = bind_direct_endpoint(&host_identity, "timeout-host", epoch(51)).await; + host.authorize_incoming_controller(raw_id).unwrap(); + let cached = wait_for_cached(&host).await; + let raw = bind_raw_endpoint(&raw_identity, vec![ALPN.to_vec()]).await; + + let silent = within( + raw.connect(cached.as_iroh().clone(), ALPN), + "silent bootstrap handshake", + ) + .await + .unwrap(); + within(silent.closed(), "silent bootstrap deadline close").await; + + let partial = within( + raw.connect(cached.as_iroh().clone(), ALPN), + "partial bootstrap handshake", + ) + .await + .unwrap(); + let (mut send, _recv) = within(partial.open_bi(), "partial bootstrap stream") + .await + .unwrap(); + send.write_all(&32_u32.to_be_bytes()).await.unwrap(); + send.write_all(&[0xa1]).await.unwrap(); + within(partial.closed(), "partial bootstrap deadline close").await; + + within( + async { tokio::join!(raw.close(), host.close()) }, + "bootstrap timeout endpoint close", + ) + .await; + } + + #[test] + fn cached_addresses_and_relay_policy_are_bounded_and_redacted() { + let id = endpoint_id(&identity("cached-address-id")); + let disabled = RelayPolicy::disabled(); + assert_eq!( + CachedEndpointAddr::new(iroh::EndpointAddr::new(id), &disabled) + .unwrap_err() + .code, + ErrorCode::TransportUnavailable + ); + + let too_many_total = (0..=MAX_CACHED_ADDRESSES) + .map(|index| { + iroh::TransportAddr::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, index as u8 + 1)), + 10_000 + index as u16, + )) + }) + .collect::>(); + assert_eq!( + CachedEndpointAddr::new( + iroh::EndpointAddr::from_parts(id, too_many_total), + &disabled, + ) + .unwrap_err() + .code, + ErrorCode::TransportUnavailable + ); + + let too_many_ips = (0..=MAX_CACHED_IP_ADDRESSES) + .map(|index| { + iroh::TransportAddr::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 1, index as u8 + 1)), + 20_000 + index as u16, + )) + }) + .collect::>(); + assert_eq!( + CachedEndpointAddr::new(iroh::EndpointAddr::from_parts(id, too_many_ips), &disabled,) + .unwrap_err() + .code, + ErrorCode::TransportUnavailable + ); + + let custom = iroh::TransportAddr::Custom("1_00".parse().unwrap()); + assert_eq!( + CachedEndpointAddr::new(iroh::EndpointAddr::from_parts(id, [custom]), &disabled) + .unwrap_err() + .code, + ErrorCode::TransportUnavailable + ); + + let allowed_urls = [ + "https://relay-a.example", + "https://relay-b.example", + "https://relay-c.example", + "https://relay-d.example", + "https://relay-e.example", + ]; + let relay_policy = + RelayPolicy::custom(iroh::RelayMap::try_from_iter(allowed_urls).unwrap()).unwrap(); + let mutable_source = iroh::RelayMap::try_from_iter(allowed_urls).unwrap(); + let isolated_policy = RelayPolicy::custom(mutable_source.clone()).unwrap(); + let injected_url: iroh::RelayUrl = "https://relay-injected.example".parse().unwrap(); + mutable_source.insert( + injected_url.clone(), + Arc::new(iroh::RelayConfig::from(injected_url.clone())), + ); + assert!(!isolated_policy.mode().relay_map().contains(&injected_url)); + assert_eq!( + CachedEndpointAddr::new( + iroh::EndpointAddr::from_parts(id, [iroh::TransportAddr::Relay(injected_url)],), + &isolated_policy, + ) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + + let mismatched = iroh::RelayMap::empty(); + let mismatch_key: iroh::RelayUrl = "https://relay-key.example".parse().unwrap(); + let mismatch_config_url: iroh::RelayUrl = "https://relay-config.example".parse().unwrap(); + mismatched.insert( + mismatch_key, + Arc::new(iroh::RelayConfig::from(mismatch_config_url)), + ); + assert_eq!( + RelayPolicy::custom(mismatched).unwrap_err().code, + ErrorCode::InvalidFrame + ); + let allowed_url: iroh::RelayUrl = allowed_urls[0].parse().unwrap(); + let cached = CachedEndpointAddr::new( + iroh::EndpointAddr::from_parts( + id, + [ + iroh::TransportAddr::Ip("203.0.113.7:443".parse().unwrap()), + iroh::TransportAddr::Relay(allowed_url), + ], + ), + &relay_policy, + ) + .unwrap(); + let cached_debug = format!("{cached:?}"); + assert!(!cached_debug.contains("203.0.113.7")); + assert!(!cached_debug.contains("relay-a.example")); + assert!(!format!("{relay_policy:?}").contains("relay-a.example")); + + let unlisted: iroh::RelayUrl = "https://relay-secret.example".parse().unwrap(); + assert_eq!( + CachedEndpointAddr::new( + iroh::EndpointAddr::from_parts(id, [iroh::TransportAddr::Relay(unlisted)]), + &relay_policy, + ) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + let five_relays = allowed_urls + .iter() + .map(|url| iroh::TransportAddr::Relay(url.parse().unwrap())) + .collect::>(); + assert_eq!( + CachedEndpointAddr::new( + iroh::EndpointAddr::from_parts(id, five_relays), + &relay_policy, + ) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + + let nine_urls = [ + "https://r0.example", + "https://r1.example", + "https://r2.example", + "https://r3.example", + "https://r4.example", + "https://r5.example", + "https://r6.example", + "https://r7.example", + "https://r8.example", + ]; + assert_eq!( + RelayPolicy::custom(iroh::RelayMap::try_from_iter(nine_urls).unwrap()) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + assert_eq!( + RelayPolicy::custom( + iroh::RelayMap::try_from_iter(["http://relay-insecure.example"]).unwrap(), + ) + .unwrap_err() + .code, + ErrorCode::InvalidFrame + ); + } + + struct OversizedSequence; + + impl Serialize for OversizedSequence { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let len = MAX_FRAME_BYTES as usize + 1; + let mut sequence = serializer.serialize_seq(Some(len))?; + for _ in 0..len { + sequence.serialize_element(&0_u8)?; + } + sequence.end() + } + } + + #[test] + fn bounded_encoder_and_wire_prefix_reject_oversized_frames() { + assert_eq!( + encode_frame_bounded(&OversizedSequence).unwrap_err().code, + ErrorCode::FrameTooLarge + ); + assert_eq!( + validate_wire_length_prefix((MAX_FRAME_BYTES + 1).to_be_bytes()) + .unwrap_err() + .code, + ErrorCode::FrameTooLarge + ); + + let encoded = encode_frame_bounded(&PageRequest::default()).unwrap(); + let mut trailing = encoded.clone(); + trailing.push(0xf6); // a second valid CBOR value must not be ignored + let mut cursor = Cursor::new(trailing.as_slice()); + let _: PageRequest = ciborium::de::from_reader(&mut cursor).unwrap(); + assert_eq!(cursor.position(), encoded.len() as u64); + assert_ne!(cursor.position(), trailing.len() as u64); + + let mut huge_declared_array = vec![0x9b]; + huge_declared_array.extend_from_slice(&u64::MAX.to_be_bytes()); + assert_eq!( + validate_cbor_shape(&huge_declared_array).unwrap_err().code, + ErrorCode::InvalidFrame + ); + let mut huge_declared_map = vec![0xbb]; + huge_declared_map.extend_from_slice(&u64::MAX.to_be_bytes()); + assert_eq!( + validate_cbor_shape(&huge_declared_map).unwrap_err().code, + ErrorCode::InvalidFrame + ); + let deeply_nested = std::iter::repeat_n(0x81, MAX_CBOR_RECURSION + 1) + .chain(std::iter::once(0xf6)) + .collect::>(); + assert_eq!( + validate_cbor_shape(&deeply_nested).unwrap_err().code, + ErrorCode::InvalidFrame + ); + let mut trailing_value = encoded; + trailing_value.push(0xf6); + assert_eq!( + validate_cbor_shape(&trailing_value).unwrap_err().code, + ErrorCode::InvalidFrame + ); + } + + #[test] + fn policy_and_authorization_bounds_reject_replay() { + assert!(ConnectionPolicy::new(Duration::ZERO).is_err()); + assert!(ConnectionPolicy::new(MAX_POLICY_DEADLINE + Duration::from_millis(1)).is_err()); + assert!(ConnectionPolicy::new(Duration::from_secs(1)).is_ok()); + + let admission = PeerAdmission::default(); + let peers = (0..=MAX_AUTHORIZED_PEERS_PER_DIRECTION) + .map(|index| endpoint_id(&identity(&format!("bounded-peer-{index}")))) + .collect::>(); + for peer in peers.iter().take(MAX_AUTHORIZED_PEERS_PER_DIRECTION) { + admission + .allow(iroh::endpoint::Side::Server, *peer) + .unwrap(); + } + assert_eq!( + admission + .allow( + iroh::endpoint::Side::Server, + peers[MAX_AUTHORIZED_PEERS_PER_DIRECTION], + ) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + + let versioned = PeerAdmission::default(); + let first_peer = peers[0]; + let second_peer = peers[1]; + versioned + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 4, + snapshot_revision: 2, + incoming_controllers: paired([first_peer]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap(); + assert_eq!( + versioned + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 4, + snapshot_revision: 1, + incoming_controllers: paired([second_peer]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap_err() + .code, + ErrorCode::Revoked + ); + assert_eq!( + versioned + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 4, + snapshot_revision: 2, + incoming_controllers: paired([second_peer]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap_err() + .code, + ErrorCode::Revoked + ); + assert!(!versioned.is_allowed(iroh::endpoint::Side::Server, &first_peer)); + assert!(!versioned.is_allowed(iroh::endpoint::Side::Server, &second_peer)); + // Once a durable account snapshot is active, unversioned mutations are + // rejected. A delayed higher snapshot can therefore never race an + // imperative revoke that the durable revision stream did not record. + assert_eq!( + versioned + .revoke(iroh::endpoint::Side::Server, &first_peer) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + assert_eq!( + versioned + .allow(iroh::endpoint::Side::Server, second_peer) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + assert!(!versioned.is_allowed(iroh::endpoint::Side::Server, &first_peer)); + + let oversized_snapshot = paired(peers.iter().copied()); + assert_eq!( + PeerAdmission::default() + .replace_authorizations(AuthorizationSnapshot { + account_epoch: 1, + snapshot_revision: 1, + incoming_controllers: oversized_snapshot, + outgoing_execution_targets: HashMap::new(), + }) + .unwrap_err() + .code, + ErrorCode::Unauthorized + ); + + let aba = PeerAdmission::default(); + aba.allow(iroh::endpoint::Side::Server, first_peer).unwrap(); + let before_clear = aba.state.read().unwrap().admission_revision; + aba.clear_all_and_close().unwrap(); + assert_eq!( + aba.allow(iroh::endpoint::Side::Server, first_peer) + .unwrap_err() + .code, + ErrorCode::Revoked + ); + assert!(aba.state.read().unwrap().admission_revision > before_clear); + assert_eq!( + aba.replace_authorizations(AuthorizationSnapshot { + account_epoch: 1, + snapshot_revision: 1, + incoming_controllers: paired([first_peer]), + outgoing_execution_targets: HashMap::new(), + }) + .unwrap_err() + .code, + ErrorCode::Revoked + ); + } + + #[tokio::test] + async fn wrong_cached_endpoint_and_wrong_alpn_fail_closed() { + let controller_identity = identity("wrong-controller"); + let host_identity = identity("wrong-host"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let controller = + bind_direct_endpoint(&controller_identity, "wrong-controller-install", epoch(61)).await; + let host = bind_direct_endpoint(&host_identity, "wrong-host-install", epoch(62)).await; + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + host.authorize_incoming_controller(controller_id).unwrap(); + let cached = wait_for_cached(&host).await; + let unrelated = endpoint_id(&identity("wrong-unrelated")); + assert_eq!( + controller + .connect_cached(&cached, unrelated, "wrong-endpoint", "wrong-host-install",) + .await + .unwrap_err() + .code, + ErrorCode::WrongEndpoint + ); + + let wrong_alpn = b"cloud.opensecret.maple/agent/999"; + let raw_identity = identity("wrong-alpn-raw"); + host.authorize_incoming_controller(endpoint_id(&raw_identity)) + .unwrap(); + let raw = bind_raw_endpoint(&raw_identity, vec![wrong_alpn.to_vec()]).await; + assert!(within( + raw.connect(cached.as_iroh().clone(), wrong_alpn), + "wrong ALPN connection", + ) + .await + .is_err()); + + within( + async { tokio::join!(raw.close(), controller.close(), host.close()) }, + "wrong endpoint test close", + ) + .await; + } + + /// External integration smoke. It uses only ephemeral in-memory identities, + /// a synthetic page request, and Iroh's official public production relays. + /// Direct IP transports and N0 discovery are disabled. + #[tokio::test] + #[ignore = "opt-in live test: contacts Iroh's official public production relays"] + async fn synthetic_roundtrip_over_forced_public_relay() { + const LIVE_TIMEOUT: Duration = Duration::from_secs(45); + let controller_identity = identity("synthetic-live-relay-controller-v2"); + let host_identity = identity("synthetic-live-relay-host-v2"); + let controller_id = endpoint_id(&controller_identity); + let host_id = endpoint_id(&host_identity); + let policy = ConnectionPolicy::new(Duration::from_secs(20)).unwrap(); + let controller = tokio::time::timeout( + LIVE_TIMEOUT, + MapleIrohEndpoint::bind_public_relay_only( + &controller_identity, + "synthetic-controller", + epoch(70), + policy, + ), + ) + .await + .expect("controller relay bind timed out") + .unwrap(); + let host = tokio::time::timeout( + LIVE_TIMEOUT, + MapleIrohEndpoint::bind_public_relay_only( + &host_identity, + "synthetic-host", + epoch(71), + policy, + ), + ) + .await + .expect("host relay bind timed out") + .unwrap(); + controller + .authorize_outgoing_execution_target(host_id) + .unwrap(); + host.authorize_incoming_controller(controller_id).unwrap(); + + tokio::time::timeout(LIVE_TIMEOUT, async { + tokio::join!(controller.endpoint.online(), host.endpoint.online()) + }) + .await + .expect("public relay endpoints did not become online"); + let cached = host.cached_endpoint_addr(host.endpoint_addr()).unwrap(); + assert_eq!(cached.as_iroh().ip_addrs().count(), 0); + assert!(cached.as_iroh().relay_urls().next().is_some()); + + let (client, server) = tokio::time::timeout(LIVE_TIMEOUT, async { + tokio::join!( + controller.connect_cached( + &cached, + host_id, + "synthetic-live-bootstrap", + "synthetic-host", + ), + host.accept_authenticated(), + ) + }) + .await + .expect("forced-relay connection phase timed out"); + let client = client.unwrap(); + let server = server.unwrap(); + assert!(client + .raw_connection() + .paths() + .iter() + .any(|path| path.is_selected() && path.is_relay())); + assert!(server + .raw_connection() + .paths() + .iter() + .any(|path| path.is_selected() && path.is_relay())); + + tokio::time::timeout( + LIVE_TIMEOUT, + assert_page_roundtrip(&client, &server, "synthetic-live-page"), + ) + .await + .expect("forced-relay typed page roundtrip timed out"); + + client.raw_connection().close( + iroh::endpoint::VarInt::from_u32(0), + b"synthetic test complete", + ); + server.raw_connection().close( + iroh::endpoint::VarInt::from_u32(0), + b"synthetic test complete", + ); + tokio::time::timeout(Duration::from_secs(10), async { + tokio::join!(controller.close(), host.close()) + }) + .await + .expect("forced-relay endpoint close timed out"); + } + + #[test] + fn transport_errors_do_not_expose_backend_details() { + let error = transport_error( + "failed to connect to Maple host", + "relay=https://private.example/ device-address=10.0.0.8", + true, + ); + assert_eq!(error.message, "failed to connect to Maple host"); + assert!(!error.message.contains("private.example")); + } +} diff --git a/frontend/src-tauri/src/secure_storage.rs b/frontend/src-tauri/src/secure_storage.rs new file mode 100644 index 000000000..9272c29a9 --- /dev/null +++ b/frontend/src-tauri/src/secure_storage.rs @@ -0,0 +1,890 @@ +//! Fail-closed storage for Maple's installation identity. +//! +//! Production implementations must use platform secure storage. This module +//! deliberately has no filesystem implementation and no plaintext fallback. +#![allow( + dead_code, + reason = "bounded foundation is wired in later vertical slices" +)] + +use std::sync::Arc; +#[cfg(target_os = "macos")] +use std::{ + fs::OpenOptions, + path::{Path, PathBuf}, + sync::{LazyLock, Mutex, MutexGuard, TryLockError}, + time::{Duration, Instant}, +}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::durable_host_epoch::{HostEpochRecordKind, HostEpochStorageKey}; + +const DEVICE_SECRET_LEN: usize = 32; +const PURPOSE: &str = "remote-agent-installation-identity-v1"; +const STORAGE_ENVELOPE_VERSION: u8 = 1; +const STORAGE_ACCOUNT: &str = "installation-identity"; +#[cfg(target_os = "macos")] +const INITIALIZATION_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(target_os = "macos")] +const INITIALIZATION_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(target_os = "macos")] +static MACOS_IDENTITY_INIT_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretStoreError { + Unavailable(String), + Corrupt(String), + Backend(String), +} + +impl std::fmt::Display for SecretStoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable(message) => { + write!(formatter, "secure storage unavailable: {message}") + } + Self::Corrupt(message) => { + write!(formatter, "secure storage data is corrupt: {message}") + } + Self::Backend(message) => write!(formatter, "secure storage failed: {message}"), + } + } +} + +impl std::error::Error for SecretStoreError {} + +/// Opaque storage interface. Implementations store one versioned credential at +/// a stable app/purpose slot; install marker and generation live inside that +/// credential so resetting never leaves addressable old identity slots behind. +/// Account-scoped registration remains above this interface. +pub trait DeviceSecretStore: Send + Sync { + fn load(&self, slot: &DeviceSecretSlot) + -> Result>>, SecretStoreError>; + fn store(&self, slot: &DeviceSecretSlot, secret: &[u8]) -> Result<(), SecretStoreError>; + fn delete(&self, slot: &DeviceSecretSlot) -> Result<(), SecretStoreError>; + + /// Read one installation-local host epoch record. Implementations must + /// keep the state and lineage-guard accounts distinct and must not provide + /// a plaintext fallback when secure storage is unavailable. + fn load_host_epoch_record( + &self, + _key: &HostEpochStorageKey, + _kind: HostEpochRecordKind, + ) -> Result>>, SecretStoreError> { + Err(SecretStoreError::Unavailable( + "secure storage backend has no durable host epoch records".into(), + )) + } + + /// Atomically replace one host epoch record and make it durable before + /// returning success. A backend error may occur after the write became + /// durable; reservation recovery treats that value as consumed. + fn store_host_epoch_record( + &self, + _key: &HostEpochStorageKey, + _kind: HostEpochRecordKind, + _record: &[u8], + ) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Unavailable( + "secure storage backend has no durable host epoch records".into(), + )) + } + + /// Execute a host epoch reservation under one bounded lock shared by all + /// Maple processes for this installation. The callback re-reads both + /// records while the lock is held and performs the ordered durable writes. + fn with_host_epoch_lock( + &self, + _key: &HostEpochStorageKey, + _operation: &mut dyn FnMut() -> Result, + ) -> Result { + Err(SecretStoreError::Unavailable( + "secure storage backend has no atomic host epoch boundary".into(), + )) + } + + /// Execute identity initialization under the strongest atomic boundary the + /// backend can provide. Test stores use a shared mutex; macOS uses bounded + /// process and advisory file locks before re-reading Keychain. No private + /// material is written to the lock file. + fn with_initialization_lock( + &self, + _slot: &DeviceSecretSlot, + _operation: &mut dyn FnMut() -> Result, + ) -> Result { + // Initializing without a backend-wide create/re-read boundary can + // return two different identities to racing processes. A new backend + // must therefore opt in by implementing an atomic boundary; there is + // deliberately no process-local-only fallback here. + Err(SecretStoreError::Unavailable( + "secure storage backend has no atomic identity initialization boundary".into(), + )) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DeviceSecretSlot { + app_identifier: String, + install_marker: String, + generation: u64, +} + +impl DeviceSecretSlot { + pub fn new( + app_identifier: impl Into, + install_marker: impl Into, + generation: u64, + ) -> Result { + let slot = Self { + app_identifier: app_identifier.into(), + install_marker: install_marker.into(), + generation, + }; + if slot.generation == 0 + || !is_safe_component(&slot.app_identifier) + || !is_safe_component(&slot.install_marker) + { + return Err(SecretStoreError::Corrupt( + "app identifier, install marker, or generation has an invalid shape".into(), + )); + } + Ok(slot) + } + + fn service(&self) -> String { + format!("{}.{}", self.app_identifier, PURPOSE) + } + + fn account(&self) -> String { + STORAGE_ACCOUNT.into() + } +} + +#[derive(Clone)] +pub struct DeviceIdentity { + secret: Arc>>, + public_id: String, + host_epoch_storage_key: HostEpochStorageKey, +} + +impl std::fmt::Debug for DeviceIdentity { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DeviceIdentity") + .field("public_id", &self.public_id) + .finish_non_exhaustive() + } +} + +impl DeviceIdentity { + pub fn load_or_create( + store: &dyn DeviceSecretStore, + slot: &DeviceSecretSlot, + ) -> Result { + // The backend boundary must serialize threads and processes and then + // re-read secure storage. A process-global mutex here would be both + // insufficient across processes and an unbounded wait around OS I/O. + let mut initialize = || load_generate_and_store(store, slot); + store.with_initialization_lock(slot, &mut initialize) + } + + pub fn public_id(&self) -> &str { + &self.public_id + } + + /// This stays crate-private: the private identity may be consumed by the + /// native transport, but may never be returned by a Tauri command. + pub(crate) fn iroh_secret_key(&self) -> Result { + secret_key_from_bytes(self.secret.as_slice()) + } + + pub(crate) fn host_epoch_storage_key(&self) -> &HostEpochStorageKey { + &self.host_epoch_storage_key + } +} + +fn load_generate_and_store( + store: &dyn DeviceSecretStore, + slot: &DeviceSecretSlot, +) -> Result { + // Always load again while holding the backend boundary. This is required + // after waiting for a different Maple process to initialize Keychain. + let mut secret = match store.load(slot)? { + Some(envelope) => match decode_envelope(&envelope, slot)? { + Some(secret) => secret, + // A changed external install marker or explicit generation is an + // identity reset. Overwrite the same secure-store slot. + None => create_and_store_secret(store, slot)?, + }, + None => create_and_store_secret(store, slot)?, + }; + if secret.len() != DEVICE_SECRET_LEN { + secret.zeroize(); + return Err(SecretStoreError::Corrupt(format!( + "expected {DEVICE_SECRET_LEN} identity bytes" + ))); + } + let iroh_secret = secret_key_from_bytes(&secret)?; + let public_id = iroh_secret.public().to_string(); + Ok(DeviceIdentity { + secret: Arc::new(secret), + public_id, + host_epoch_storage_key: HostEpochStorageKey::new( + slot.app_identifier.clone(), + slot.install_marker.clone(), + slot.generation, + )?, + }) +} + +fn secret_key_from_bytes(bytes: &[u8]) -> Result { + let bytes = Zeroizing::new( + <[u8; DEVICE_SECRET_LEN]>::try_from(bytes) + .map_err(|_| SecretStoreError::Corrupt("identity secret length is invalid".into()))?, + ); + Ok(iroh::SecretKey::from_bytes(&bytes)) +} + +fn create_and_store_secret( + store: &dyn DeviceSecretStore, + slot: &DeviceSecretSlot, +) -> Result>, SecretStoreError> { + // Maple owns the seed buffer from the instant it is filled by the OS CSPRNG; + // no temporary key object or unprotected byte array exists on this path. + let mut generated = Zeroizing::new([0_u8; DEVICE_SECRET_LEN]); + getrandom::fill(generated.as_mut()).map_err(|_| { + SecretStoreError::Backend("operating-system random source is unavailable".into()) + })?; + let envelope = encode_envelope(slot, &generated)?; + store.store(slot, &envelope)?; + Ok(Zeroizing::new(generated.to_vec())) +} + +fn encode_envelope( + slot: &DeviceSecretSlot, + secret: &[u8; DEVICE_SECRET_LEN], +) -> Result>, SecretStoreError> { + let marker_len = u16::try_from(slot.install_marker.len()).map_err(|_| { + SecretStoreError::Corrupt("install marker does not fit credential envelope".into()) + })?; + let mut envelope = Zeroizing::new(Vec::with_capacity( + 1 + 8 + 2 + usize::from(marker_len) + DEVICE_SECRET_LEN, + )); + envelope.push(STORAGE_ENVELOPE_VERSION); + envelope.extend_from_slice(&slot.generation.to_be_bytes()); + envelope.extend_from_slice(&marker_len.to_be_bytes()); + envelope.extend_from_slice(slot.install_marker.as_bytes()); + envelope.extend_from_slice(secret); + Ok(envelope) +} + +fn decode_envelope( + envelope: &[u8], + slot: &DeviceSecretSlot, +) -> Result>>, SecretStoreError> { + const HEADER_LEN: usize = 1 + 8 + 2; + if envelope.len() < HEADER_LEN + DEVICE_SECRET_LEN { + return Err(SecretStoreError::Corrupt( + "identity credential envelope is truncated".into(), + )); + } + if envelope[0] != STORAGE_ENVELOPE_VERSION { + return Err(SecretStoreError::Corrupt(format!( + "unsupported identity credential version {}", + envelope[0] + ))); + } + let generation = u64::from_be_bytes( + envelope[1..9] + .try_into() + .map_err(|_| SecretStoreError::Corrupt("invalid identity generation".into()))?, + ); + let marker_len = + usize::from(u16::from_be_bytes(envelope[9..11].try_into().map_err( + |_| SecretStoreError::Corrupt("invalid install marker length".into()), + )?)); + let expected_len = HEADER_LEN + marker_len + DEVICE_SECRET_LEN; + if envelope.len() != expected_len { + return Err(SecretStoreError::Corrupt( + "identity credential envelope has an invalid length".into(), + )); + } + let marker = &envelope[HEADER_LEN..HEADER_LEN + marker_len]; + if generation != slot.generation || marker != slot.install_marker.as_bytes() { + return Ok(None); + } + Ok(Some(Zeroizing::new( + envelope[HEADER_LEN + marker_len..].to_vec(), + ))) +} + +fn is_safe_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 200 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +/// Return the secure backend for the current platform. Unsupported production +/// platforms receive an explicit error; callers must never substitute a file. +pub fn platform_store() -> Result, SecretStoreError> { + Err(SecretStoreError::Unavailable( + "Maple device identity storage is not enabled on this platform; macOS Data Protection Keychain also requires signed-app entitlement/profile validation before activation".into(), + )) +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Copy)] +struct MacOsKeychainStore; + +#[cfg(target_os = "macos")] +impl MacOsKeychainStore { + fn query_options(slot: &DeviceSecretSlot) -> security_framework::passwords::PasswordOptions { + let mut options = security_framework::passwords::PasswordOptions::new_generic_password( + &slot.service(), + &slot.account(), + ); + // The installation seed is local-only and lives in the modern data- + // protection Keychain. Iroh needs the raw 32-byte seed in-process, so + // this is not a Secure Enclave key handle. + options.set_access_synchronized(Some(false)); + options.use_protected_keychain(); + options + } + + fn create_options( + slot: &DeviceSecretSlot, + ) -> Result { + use security_framework::access_control::{ProtectionMode, SecAccessControl}; + + let access_control = SecAccessControl::create_with_protection( + Some(ProtectionMode::AccessibleAfterFirstUnlockThisDeviceOnly), + 0, + ) + .map_err(|error| { + SecretStoreError::Backend(format!( + "Keychain access-control creation returned OSStatus {}", + error.code() + )) + })?; + let mut options = Self::query_options(slot); + options.set_access_control(access_control); + Ok(options) + } + + fn update_search(slot: &DeviceSecretSlot) -> security_framework::item::ItemSearchOptions { + use security_framework::item::{ItemClass, ItemSearchOptions}; + + let mut search = ItemSearchOptions::new(); + search + .ignore_legacy_keychains() + .class(ItemClass::generic_password()) + .service(&slot.service()) + .account(&slot.account()) + .cloud_sync(Some(false)); + search + } +} + +#[cfg(target_os = "macos")] +impl DeviceSecretStore for MacOsKeychainStore { + fn load( + &self, + slot: &DeviceSecretSlot, + ) -> Result>>, SecretStoreError> { + match security_framework::passwords::generic_password(Self::query_options(slot)) { + Ok(secret) => Ok(Some(Zeroizing::new(secret))), + Err(error) if error.code() == -25300 => Ok(None), // errSecItemNotFound + Err(error) => Err(SecretStoreError::Backend(format!( + "Keychain read returned OSStatus {}", + error.code() + ))), + } + } + + fn store(&self, slot: &DeviceSecretSlot, secret: &[u8]) -> Result<(), SecretStoreError> { + use core_foundation::data::CFData; + use security_framework::item::{update_item, ItemUpdateOptions, ItemUpdateValue}; + + // Update data separately from creation attributes. In particular, + // kSecAttrAccessControl belongs on SecItemAdd, not in a match query. + // If the protected item is absent, create it with the non-migrating + // accessibility class. The initialization lock prevents a duplicate- + // add race between Maple processes. + let mut update = ItemUpdateOptions::new(); + update.set_value(ItemUpdateValue::Data(CFData::from_buffer(secret))); + match update_item(&Self::update_search(slot), &update) { + Ok(()) => Ok(()), + Err(error) if error.code() == -25300 => { + security_framework::passwords::set_generic_password_options( + secret, + Self::create_options(slot)?, + ) + .map_err(|error| { + SecretStoreError::Backend(format!( + "Keychain create returned OSStatus {}", + error.code() + )) + }) + } + Err(error) => Err(SecretStoreError::Backend(format!( + "Keychain update returned OSStatus {}", + error.code() + ))), + } + } + + fn delete(&self, slot: &DeviceSecretSlot) -> Result<(), SecretStoreError> { + match security_framework::passwords::delete_generic_password_options(Self::query_options( + slot, + )) { + Ok(()) => Ok(()), + Err(error) if error.code() == -25300 => Ok(()), // errSecItemNotFound + Err(error) => Err(SecretStoreError::Backend(format!( + "Keychain delete returned OSStatus {}", + error.code() + ))), + } + } + + fn with_initialization_lock( + &self, + slot: &DeviceSecretSlot, + operation: &mut dyn FnMut() -> Result, + ) -> Result { + let deadline = Instant::now() + INITIALIZATION_LOCK_TIMEOUT; + // macOS flock ownership is process-associated, so a bounded native + // mutex is also required to serialize independently opened descriptors + // from threads in this process. It is backend-local and has no + // unbounded lock() call. + let _process_guard = acquire_macos_process_lock(deadline)?; + let lock_path = macos_identity_lock_path(slot)?; + let lock_file = open_private_lock_file(&lock_path)?; + acquire_initialization_lock(&lock_file, deadline)?; + // load_generate_and_store executes here and therefore re-reads + // Keychain after any other Maple process releases this lock. + let result = operation(); + let unlock_result = fs2::FileExt::unlock(&lock_file).map_err(|error| { + SecretStoreError::Backend(format!( + "could not release installation identity lock: {error}" + )) + }); + match (result, unlock_result) { + (Ok(identity), Ok(())) => Ok(identity), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } + } +} + +#[cfg(target_os = "macos")] +fn macos_identity_lock_path(slot: &DeviceSecretSlot) -> Result { + let application_support = dirs::data_local_dir().ok_or_else(|| { + SecretStoreError::Unavailable("macOS Application Support directory is unavailable".into()) + })?; + // This dedicated, non-cache directory is intentionally not purgeable. It + // contains no private key material, only a zero-length advisory lock. + let lock_dir = application_support.join(format!("{}.{}-locks", slot.app_identifier, PURPOSE)); + create_private_directory(&lock_dir)?; + Ok(lock_dir.join("initialization.lock")) +} + +#[cfg(target_os = "macos")] +fn current_effective_uid() -> libc::uid_t { + // SAFETY: geteuid has no arguments or caller-side safety preconditions. + unsafe { libc::geteuid() } +} + +#[cfg(target_os = "macos")] +fn create_private_directory(path: &Path) -> Result<(), SecretStoreError> { + use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; + + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700); + match builder.create(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(SecretStoreError::Unavailable(format!( + "could not create installation identity lock directory: {error}" + ))) + } + } + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + SecretStoreError::Unavailable(format!( + "could not inspect installation identity lock directory: {error}" + )) + })?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != current_effective_uid() + { + return Err(SecretStoreError::Unavailable( + "installation identity lock directory is not a private owned directory".into(), + )); + } + if metadata.permissions().mode() & 0o077 != 0 { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err( + |error| { + SecretStoreError::Unavailable(format!( + "could not protect installation identity lock directory: {error}" + )) + }, + )?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn open_private_lock_file(path: &Path) -> Result { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + if let Ok(metadata) = std::fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(SecretStoreError::Unavailable( + "installation identity lock path is not a regular file".into(), + )); + } + } + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(path) + .map_err(|error| { + SecretStoreError::Unavailable(format!( + "could not open installation identity lock: {error}" + )) + })?; + let metadata = lock_file.metadata().map_err(|error| { + SecretStoreError::Unavailable(format!( + "could not inspect installation identity lock: {error}" + )) + })?; + if !metadata.is_file() || metadata.uid() != current_effective_uid() || metadata.nlink() != 1 { + return Err(SecretStoreError::Unavailable( + "installation identity lock is not a private owned file".into(), + )); + } + if metadata.permissions().mode() & 0o077 != 0 { + lock_file + .set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|error| { + SecretStoreError::Unavailable(format!( + "could not protect installation identity lock: {error}" + )) + })?; + } + Ok(lock_file) +} + +#[cfg(target_os = "macos")] +fn acquire_macos_process_lock( + deadline: Instant, +) -> Result, SecretStoreError> { + loop { + match MACOS_IDENTITY_INIT_LOCK.try_lock() { + Ok(guard) => return Ok(guard), + Err(TryLockError::WouldBlock) => wait_for_initialization_lock(deadline)?, + Err(TryLockError::Poisoned(_)) => { + return Err(SecretStoreError::Backend( + "installation identity process lock is poisoned".into(), + )) + } + } + } +} + +#[cfg(target_os = "macos")] +fn acquire_initialization_lock( + lock_file: &std::fs::File, + deadline: Instant, +) -> Result<(), SecretStoreError> { + let contended_code = fs2::lock_contended_error().raw_os_error(); + loop { + match fs2::FileExt::try_lock_exclusive(lock_file) { + Ok(()) => return Ok(()), + Err(error) if error.raw_os_error() == contended_code => { + wait_for_initialization_lock(deadline)?; + } + Err(error) => { + return Err(SecretStoreError::Unavailable(format!( + "could not acquire installation identity lock: {error}" + ))) + } + } + } +} + +#[cfg(target_os = "macos")] +fn wait_for_initialization_lock(deadline: Instant) -> Result<(), SecretStoreError> { + let now = Instant::now(); + if now >= deadline { + return Err(SecretStoreError::Unavailable( + "timed out acquiring installation identity lock".into(), + )); + } + std::thread::sleep( + INITIALIZATION_LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)), + ); + Ok(()) +} + +#[cfg(test)] +pub mod testing { + use super::*; + use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + }; + + #[derive(Debug, Clone, Default)] + pub struct InMemorySecretStore { + values: Arc>>>, + initialization: Arc>, + } + + impl InMemorySecretStore { + pub fn entry_count(&self) -> usize { + self.values.lock().unwrap().len() + } + } + + impl DeviceSecretStore for InMemorySecretStore { + fn load( + &self, + slot: &DeviceSecretSlot, + ) -> Result>>, SecretStoreError> { + Ok(self + .values + .lock() + .map_err(|_| SecretStoreError::Backend("test store lock poisoned".into()))? + .get(&(slot.service(), slot.account())) + .cloned() + .map(Zeroizing::new)) + } + + fn store(&self, slot: &DeviceSecretSlot, secret: &[u8]) -> Result<(), SecretStoreError> { + self.values + .lock() + .map_err(|_| SecretStoreError::Backend("test store lock poisoned".into()))? + .insert((slot.service(), slot.account()), secret.to_vec()); + Ok(()) + } + + fn delete(&self, slot: &DeviceSecretSlot) -> Result<(), SecretStoreError> { + self.values + .lock() + .map_err(|_| SecretStoreError::Backend("test store lock poisoned".into()))? + .remove(&(slot.service(), slot.account())); + Ok(()) + } + + fn load_host_epoch_record( + &self, + key: &HostEpochStorageKey, + kind: HostEpochRecordKind, + ) -> Result>>, SecretStoreError> { + Ok(self + .values + .lock() + .map_err(|_| SecretStoreError::Backend("test store lock poisoned".into()))? + .get(&(key.service(), key.account(kind).into())) + .cloned() + .map(Zeroizing::new)) + } + + fn store_host_epoch_record( + &self, + key: &HostEpochStorageKey, + kind: HostEpochRecordKind, + record: &[u8], + ) -> Result<(), SecretStoreError> { + self.values + .lock() + .map_err(|_| SecretStoreError::Backend("test store lock poisoned".into()))? + .insert((key.service(), key.account(kind).into()), record.to_vec()); + Ok(()) + } + + fn with_host_epoch_lock( + &self, + _key: &HostEpochStorageKey, + operation: &mut dyn FnMut() -> Result, + ) -> Result { + let _guard = self.initialization.lock().map_err(|_| { + SecretStoreError::Backend("test initialization lock poisoned".into()) + })?; + operation() + } + + fn with_initialization_lock( + &self, + _slot: &DeviceSecretSlot, + operation: &mut dyn FnMut() -> Result, + ) -> Result { + let _guard = self.initialization.lock().map_err(|_| { + SecretStoreError::Backend("test initialization lock poisoned".into()) + })?; + operation() + } + } + + #[derive(Debug, Clone, Copy)] + pub struct UnavailableSecretStore; + + impl DeviceSecretStore for UnavailableSecretStore { + fn load( + &self, + _slot: &DeviceSecretSlot, + ) -> Result>>, SecretStoreError> { + Err(SecretStoreError::Unavailable("test backend offline".into())) + } + + fn store(&self, _slot: &DeviceSecretSlot, _secret: &[u8]) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Unavailable("test backend offline".into())) + } + + fn delete(&self, _slot: &DeviceSecretSlot) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Unavailable("test backend offline".into())) + } + } +} + +#[cfg(test)] +mod tests { + use super::{testing::*, *}; + use std::thread; + + fn slot(install: &str, generation: u64) -> DeviceSecretSlot { + DeviceSecretSlot::new("cloud.opensecret.maple.test", install, generation).unwrap() + } + + struct FailingWriteStore; + + impl DeviceSecretStore for FailingWriteStore { + fn load( + &self, + _slot: &DeviceSecretSlot, + ) -> Result>>, SecretStoreError> { + Ok(None) + } + + fn store(&self, _slot: &DeviceSecretSlot, _secret: &[u8]) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Backend("synthetic write failure".into())) + } + + fn delete(&self, _slot: &DeviceSecretSlot) -> Result<(), SecretStoreError> { + Ok(()) + } + + fn with_initialization_lock( + &self, + _slot: &DeviceSecretSlot, + operation: &mut dyn FnMut() -> Result, + ) -> Result { + operation() + } + } + + #[test] + fn identity_is_stable_for_one_install_slot() { + let store = InMemorySecretStore::default(); + let first = DeviceIdentity::load_or_create(&store, &slot("install-a", 1)).unwrap(); + let second = DeviceIdentity::load_or_create(&store, &slot("install-a", 1)).unwrap(); + assert_eq!(first.public_id(), second.public_id()); + } + + #[test] + fn concurrent_initialization_returns_one_identity() { + let store = Arc::new(InMemorySecretStore::default()); + let slot = Arc::new(slot("install-concurrent", 1)); + let workers = (0..16) + .map(|_| { + let store = store.clone(); + let slot = slot.clone(); + thread::spawn(move || { + DeviceIdentity::load_or_create(store.as_ref(), slot.as_ref()) + .unwrap() + .public_id() + .to_owned() + }) + }) + .collect::>(); + let identities = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + assert!(identities.iter().all(|identity| identity == &identities[0])); + assert_eq!(store.entry_count(), 1); + } + + #[test] + fn backend_atomic_boundary_models_independent_process_initializers() { + let store = Arc::new(InMemorySecretStore::default()); + let slot = Arc::new(slot("install-backend-atomic", 1)); + let workers = (0..16) + .map(|_| { + let store = store.clone(); + let slot = slot.clone(); + thread::spawn(move || { + // Each thread represents an independent process relying + // only on the backend atomic boundary and its mandatory + // re-read. + let mut operation = || load_generate_and_store(store.as_ref(), slot.as_ref()); + store + .with_initialization_lock(slot.as_ref(), &mut operation) + .unwrap() + .public_id() + .to_owned() + }) + }) + .collect::>(); + let identities = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + assert!(identities.iter().all(|identity| identity == &identities[0])); + assert_eq!(store.entry_count(), 1); + } + + #[test] + fn installations_and_generations_are_isolated() { + let store = InMemorySecretStore::default(); + let first = DeviceIdentity::load_or_create(&store, &slot("install-a", 1)).unwrap(); + let other_install = DeviceIdentity::load_or_create(&store, &slot("install-b", 1)).unwrap(); + let next_generation = + DeviceIdentity::load_or_create(&store, &slot("install-a", 2)).unwrap(); + assert_ne!(first.public_id(), other_install.public_id()); + assert_ne!(first.public_id(), next_generation.public_id()); + assert_eq!(store.entry_count(), 1, "resets overwrite the stable slot"); + } + + #[test] + fn unavailable_storage_never_generates_an_ephemeral_identity() { + assert!(matches!( + DeviceIdentity::load_or_create(&UnavailableSecretStore, &slot("install-a", 1)), + Err(SecretStoreError::Unavailable(_)) + )); + } + + #[test] + fn failed_secure_store_write_never_returns_generated_identity() { + assert!(matches!( + DeviceIdentity::load_or_create(&FailingWriteStore, &slot("install-write-fails", 1)), + Err(SecretStoreError::Backend(message)) if message == "synthetic write failure" + )); + } + + #[test] + fn private_key_is_not_debuggable() { + let store = InMemorySecretStore::default(); + let identity = DeviceIdentity::load_or_create(&store, &slot("install-a", 1)).unwrap(); + let debug = format!("{identity:?}"); + assert!(debug.contains(identity.public_id())); + assert!(!debug.contains("secret")); + } +} From 41af686d2bce91b125cc542773e44176c4bb46b1 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:53:03 +0000 Subject: [PATCH 2/3] feat(agent): add target-scoped paged history runtime --- frontend/src/components/AgentMode.tsx | 1868 ++++++-- .../agent/AgentSidebarInfoCard.test.tsx | 19 + .../components/agent/AgentSidebarInfoCard.tsx | 57 +- frontend/src/components/chat/ChatTurn.tsx | 9 +- .../services/agentHistoryPagination.test.ts | 1384 ++++++ .../src/services/agentHistoryPagination.ts | 1402 ++++++ .../agentLiveConnectionLifecycle.test.ts | 225 + .../services/agentLiveConnectionLifecycle.ts | 130 + .../src/services/agentProjectFolder.test.ts | 20 +- frontend/src/services/agentProjectFolder.ts | 9 + .../src/services/agentProjectOrdering.test.ts | 1 + .../src/services/agentRuntimeService.test.ts | 2587 ++++++++++- frontend/src/services/agentRuntimeService.ts | 3899 ++++++++++++++++- .../services/agentSessionPagination.test.ts | 208 + .../src/services/agentSessionPagination.ts | 169 + .../services/agentSessionSelection.test.ts | 10 + .../src/services/agentSessionSelection.ts | 24 +- .../services/agentSessionSummaries.test.ts | 1 + frontend/src/services/agentTimeline.test.ts | 96 + frontend/src/services/agentTimeline.ts | 99 +- 20 files changed, 11757 insertions(+), 460 deletions(-) create mode 100644 frontend/src/services/agentHistoryPagination.test.ts create mode 100644 frontend/src/services/agentHistoryPagination.ts create mode 100644 frontend/src/services/agentLiveConnectionLifecycle.test.ts create mode 100644 frontend/src/services/agentLiveConnectionLifecycle.ts create mode 100644 frontend/src/services/agentSessionPagination.test.ts create mode 100644 frontend/src/services/agentSessionPagination.ts diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index 140bb0d9f..f18c2b2a0 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -95,19 +95,42 @@ import { AgentMcpMenu, AgentMcpServersDialog } from "@/components/agent/AgentMcp import { AgentSidebarInfoCard } from "@/components/agent/AgentSidebarInfoCard"; import { handleAgentModeThoughtRunFinished } from "@/components/agent/agentModeThoughtRun"; import { - agentRuntimeService, + DEFAULT_AGENT_PAGE_SIZE, + agentRuntimeService as defaultAgentRuntimeService, awaitAgentAuthUser, type AgentConfig, type AgentEventEnvelope, + type AgentLiveChannelFrame, + type AgentLiveEventCursor, + type AgentPendingHistoryAttach, type AgentMcpServer, type AgentPermissionDecision, + isAgentPageStaleError, + isAgentLiveSnapshotRequiredError, type AgentProjectSkillsTrustStatus, type AgentRuntimeStatus, + type AgentRuntimeService, type AgentSessionMcpServer, type AgentSessionSummary, type AgentTimelineItem, type RecentProjectRoot } from "@/services/agentRuntimeService"; +import { AgentHistoryPaginationCache } from "@/services/agentHistoryPagination"; +import { + AgentLiveConnectionRegistry, + recoverAgentLiveConnectionAfterReplacementFailure +} from "@/services/agentLiveConnectionLifecycle"; +import { AgentSessionPaginationCache } from "@/services/agentSessionPagination"; +import { + CHAT_HISTORY_TOP_MARGIN_PX, + ChatHistoryPaginationGate, + type ChatHistoryScrollSnapshot, + preferredChatHistoryScrollSnapshot, + requiredChatHistoryBottomCompensation, + restoredChatHistoryAnchorScrollTop, + restoredChatHistoryScrollTop, + usesFirstCancelableWheelGestureStart +} from "@/components/chatHistoryPagination"; import { createProjectOrderState, groupAgentSessionsByRoot, @@ -128,7 +151,6 @@ import { } from "@/services/agentMcpErrors"; import { reconcileNewChatMcpServerNames } from "@/services/agentMcpServers"; import { agentOperationFence } from "@/services/agentOperationFence"; -import { reconcileAgentSessionSnapshot } from "@/services/agentSessionSummaries"; import { agentToolKind, agentToolKindLabel, @@ -141,9 +163,12 @@ import { startAgentThoughtLabelDisplay } from "@/services/agentThoughtLabels"; import { + AgentAssistantTurnKeyRegistry, AgentLiveThoughtPhaseTracker, activeAgentThinkingItemId, + agentTimelineHistoryAnchorIds, agentThinkingPhaseId, + agentUserTurnReactKey, coalesceAdjacentThinkingItems, getAgentTurnCopyText, groupAgentTimelineItems, @@ -169,9 +194,12 @@ import { useIsLandscapeMobile, useIsMobile } from "@/utils/utils"; -import { isTauriDesktop } from "@/utils/platform"; +import { isMacOS, isTauri, isTauriDesktop } from "@/utils/platform"; import { useLazyRef } from "@/utils/useLazyRef"; -import { revealAgentProjectFolder } from "@/services/agentProjectFolder"; +import { + canUseLocalAgentProjectFolderActions, + revealAgentProjectFolder +} from "@/services/agentProjectFolder"; import { aggregateAgentSidebarStatus, agentProjectProgressLabel, @@ -206,7 +234,6 @@ const DEFAULT_MODEL = DEFAULT_AGENT_MODEL; const DEFAULT_MODE = "smart_approve"; const NEW_SESSION_PENDING_KEY = "__maple-agent-new-session__"; const NEW_PROJECT_OPTION_VALUE = "__maple-agent-new-project__"; -const MAX_STABLE_SESSION_LOAD_ATTEMPTS = 3; const THOUGHT_PHASE_SEED_RETRY_MS = 250; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 100; const SIDEBAR_REORDER_ANIMATION_MS = 150; @@ -373,9 +400,20 @@ function buildFallbackModelAliases(models: OpenSecretModel[]): OpenSecretModelAl }); } -export function AgentMode({ userId }: { userId: string }) { +export function AgentMode({ + userId, + agentRuntimeService = defaultAgentRuntimeService +}: { + userId: string; + agentRuntimeService?: AgentRuntimeService; +}) { const openai = useOpenAI(); const os = useOpenSecret(); + const agentOwnerKey = JSON.stringify([userId, String(agentRuntimeService.target.id)]); + const localProjectFolderActionsAvailable = canUseLocalAgentProjectFolderActions( + agentRuntimeService.target, + isTauriDesktop() + ); const { availableModels, setAvailableModels, modelAliases, setModelAliases, setHasWhisperModel } = useModelState(); const { agentSessionSelection } = usePersistentHomeNavigation(); @@ -399,10 +437,9 @@ export function AgentMode({ userId }: { userId: string }) { const recentRoots = projectOrderState.visible; const [removedProjectRoots, setRemovedProjectRoots] = useState>(() => new Set()); const [sessions, setSessions] = useState([]); - const sessionSummaryRevisionRef = useRef(0); - const sessionSummaryRevisionsRef = useRef(new Map()); - const sessionListRefreshGenerationRef = useRef(0); - const sessionListAppliedGenerationRef = useRef(0); + const sessionPaginationCacheRef = useLazyRef(() => new AgentSessionPaginationCache()); + const [hasMoreSessions, setHasMoreSessions] = useState(false); + const [isLoadingOlderSessions, setIsLoadingOlderSessions] = useState(false); const [isSessionHistoryReady, setIsSessionHistoryReady] = useState(false); const [sessionToDelete, setSessionToDelete] = useState(null); const [sessionToRename, setSessionToRename] = useState(null); @@ -419,6 +456,15 @@ export function AgentMode({ userId }: { userId: string }) { const [model, setModel] = useState(() => newTaskAgentModel(agentModelPreferenceRef.current)); const [mode, setMode] = useState(DEFAULT_MODE); const [timelineItems, setTimelineItems] = useState([]); + const historyPaginationCacheRef = useLazyRef( + () => + new AgentHistoryPaginationCache({ + accountId: userId, + targetId: String(agentRuntimeService.target.id) + }) + ); + const [hasMoreOlderHistory, setHasMoreOlderHistory] = useState(false); + const [isLoadingOlderHistory, setIsLoadingOlderHistory] = useState(false); const [generatedThoughtLabels, setGeneratedThoughtLabels] = useState< Record> >({}); @@ -455,12 +501,32 @@ export function AgentMode({ userId }: { userId: string }) { () => new Set() ); const chatContainerRef = useRef(null); + const historyTopSentinelRef = useRef(null); + const historyBottomCompensationRef = useRef(null); + const pendingHistoryScrollRestoreRef = useRef(null); + const pendingHistoryScrollRestoreSessionIdRef = useRef(null); + const historyGestureEndTimeoutRef = useRef | null>(null); + const historyTouchGestureEndTimeoutRef = useRef | null>(null); + const historyKeyIntentTimeoutRef = useRef | null>(null); + const historyWheelGestureStartPendingRef = useRef(false); + const historyPreviousWheelCancelableRef = useRef(null); + const previousHistoryTouchYRef = useRef(null); + const historyTouchGestureActiveRef = useRef(false); + const historyPointerGestureActiveRef = useRef(false); + const previousHistoryPointerScrollTopRef = useRef(0); + const suppressedHistoryScrollEndsRef = useRef(0); + const eventGapRecoveryRef = useRef | null>(null); + const pendingEventGapSessionIdsRef = useLazyRef(() => new Set()); + const hasUnknownEventGapRef = useRef(false); + const liveConnectionsRef = useLazyRef(() => new AgentLiveConnectionRegistry()); + const liveRetirementRef = useRef | null>(null); + const liveResumeInFlightRef = useRef | null>(null); + const liveStreamGenerationRef = useRef(0); + const liveChannelHandlerRef = useRef<(frame: AgentLiveChannelFrame) => void>(() => {}); + const agentOwnerKeyRef = useRef(agentOwnerKey); + agentOwnerKeyRef.current = agentOwnerKey; const activeSessionIdRef = useRef(activeSessionId); const deletedSessionIdsRef = useLazyRef(() => new Set()); - const markSessionSummaryChanged = useCallback((sessionId: string) => { - sessionSummaryRevisionRef.current += 1; - sessionSummaryRevisionsRef.current.set(sessionId, sessionSummaryRevisionRef.current); - }, []); const shouldAutoScrollRef = useRef(true); const permissionModeUpdateRef = useLazyRef>(() => Promise.resolve()); const permissionModeUpdateGenerationRef = useRef(0); @@ -505,6 +571,10 @@ export function AgentMode({ userId }: { userId: string }) { const thoughtLabelProvisionalSchedulerRef = useRef( null ); + const historyPaginationLifecycle = useMemo( + () => ({ sessionId: activeSessionId, gate: new ChatHistoryPaginationGate() }), + [activeSessionId] + ); useLayoutEffect(() => { openaiRef.current = openai; @@ -654,6 +724,239 @@ export function AgentMode({ userId }: { userId: string }) { [generateThoughtLabel] ); + const publishHistorySnapshot = useCallback( + (sessionId: string) => { + if (activeSessionIdRef.current !== sessionId) return; + const snapshot = historyPaginationCacheRef.current.snapshot(sessionId); + setTimelineItems([...snapshot.timeline]); + setHasMoreOlderHistory(snapshot.hasMore); + setIsLoadingOlderHistory(snapshot.isLoading); + }, + [historyPaginationCacheRef] + ); + + const reconcileHistoryRetention = useCallback(() => { + const protectedSessionIds = new Set(); + if (activeSessionIdRef.current) protectedSessionIds.add(activeSessionIdRef.current); + const pendingSelection = pendingSessionSelectionIdRef.current; + if (pendingSelection && pendingSelection !== NEW_SESSION_PENDING_KEY) { + protectedSessionIds.add(pendingSelection); + } + historyPaginationCacheRef.current.reconcileRetention(protectedSessionIds); + }, [historyPaginationCacheRef]); + + const retireAgentLiveConnection = useCallback(async () => { + liveStreamGenerationRef.current += 1; + const previousRetirement = liveRetirementRef.current; + // With no predecessor, invoke retirement synchronously so every service + // handle publishes close intent before a same-commit remount can open. A + // later caller still waits for and then retries handles retained by an + // earlier failed cancellation. + const retirement = previousRetirement + ? (async () => { + await previousRetirement.catch(() => {}); + await liveConnectionsRef.current.retire(); + })() + : liveConnectionsRef.current.retire(); + liveRetirementRef.current = retirement; + try { + await retirement; + } finally { + if (liveRetirementRef.current === retirement) liveRetirementRef.current = null; + } + }, [liveConnectionsRef]); + + const resumeAgentLiveConnection = useCallback( + async (retainedCursor?: AgentLiveEventCursor) => { + if (agentRuntimeService.target.kind !== "remote") return; + const existingResume = liveResumeInFlightRef.current; + if (existingResume) { + if (!retainedCursor) return await existingResume; + // A replacement attach may have fenced this older resume while it was + // opening. Wait for its owned cleanup, then make a fresh attempt from + // the replacement's retained cursor instead of treating it as recovery. + await existingResume.catch(() => {}); + if (liveResumeInFlightRef.current === existingResume) { + liveResumeInFlightRef.current = null; + } + } + const cursor = retainedCursor ?? historyPaginationCacheRef.current.eventCursor(); + if (!cursor) throw new Error("Agent live resume requires an event cursor"); + + const resume = (async () => { + // Cursor replay closes the short retirement gap without fetching any + // history page. Snapshot-required failures are recovered separately by + // the existing bounded head coordinator. + await retireAgentLiveConnection(); + const generation = ++liveStreamGenerationRef.current; + const resumeOwnerKey = agentOwnerKey; + const active = await agentRuntimeService.resumeLiveEvents(userId, cursor, (frame) => { + if ( + generation === liveStreamGenerationRef.current && + resumeOwnerKey === agentOwnerKeyRef.current && + isAgentModeMountedRef.current + ) { + liveChannelHandlerRef.current(frame); + } + }); + if ( + generation !== liveStreamGenerationRef.current || + resumeOwnerKey !== agentOwnerKeyRef.current || + !isAgentModeMountedRef.current || + userIdRef.current !== userId + ) { + await liveConnectionsRef.current.cancelActive(active); + return; + } + liveConnectionsRef.current.trackActive(active); + })(); + liveResumeInFlightRef.current = resume; + try { + await resume; + } finally { + if (liveResumeInFlightRef.current === resume) liveResumeInFlightRef.current = null; + } + }, + [ + agentOwnerKey, + agentRuntimeService, + historyPaginationCacheRef, + liveConnectionsRef, + retireAgentLiveConnection, + userId + ] + ); + + const loadHistoryHead = useCallback( + async (sessionId: string): Promise => { + if (agentRuntimeService.target.kind === "remote") { + await retireAgentLiveConnection(); + const attachGeneration = ++liveStreamGenerationRef.current; + const attachOwnerKey = agentOwnerKey; + const token = historyPaginationCacheRef.current.beginHead(sessionId); + if (activeSessionIdRef.current === sessionId) setIsLoadingOlderHistory(true); + let pending: AgentPendingHistoryAttach | null = null; + try { + pending = await agentRuntimeService.beginSessionHistoryAttach( + userId, + { sessionId, limit: DEFAULT_AGENT_PAGE_SIZE }, + (frame) => { + if ( + attachGeneration === liveStreamGenerationRef.current && + attachOwnerKey === agentOwnerKeyRef.current && + isAgentModeMountedRef.current + ) { + liveChannelHandlerRef.current(frame); + } + } + ); + liveConnectionsRef.current.trackPending(pending); + if ( + attachGeneration !== liveStreamGenerationRef.current || + !isAgentModeMountedRef.current || + userIdRef.current !== userId + ) { + historyPaginationCacheRef.current.fail(token); + publishHistorySnapshot(sessionId); + await liveConnectionsRef.current.cancelPending(pending); + return [...historyPaginationCacheRef.current.snapshot(sessionId).timeline]; + } + const response = pending.response; + const result = historyPaginationCacheRef.current.installSynchronizedAccountHead( + token, + response.page, + { + liveSessionsComplete: response.liveSessionsComplete, + liveSessionCount: response.liveSessionCount, + liveSessions: response.liveSessions, + throughEventCursor: response.throughEventCursor + } + ); + if (result !== "applied") { + historyPaginationCacheRef.current.fail(token); + publishHistorySnapshot(sessionId); + await liveConnectionsRef.current.cancelPending(pending); + return [...historyPaginationCacheRef.current.snapshot(sessionId).timeline]; + } + publishHistorySnapshot(sessionId); + const active = await pending.activate(); + if ( + attachGeneration !== liveStreamGenerationRef.current || + !isAgentModeMountedRef.current || + userIdRef.current !== userId + ) { + historyPaginationCacheRef.current.fail(token); + publishHistorySnapshot(sessionId); + await liveConnectionsRef.current.cancelPending(pending); + return [...historyPaginationCacheRef.current.snapshot(sessionId).timeline]; + } + liveConnectionsRef.current.promote(pending, active); + return [...historyPaginationCacheRef.current.snapshot(sessionId).timeline]; + } catch (loadError) { + historyPaginationCacheRef.current.fail(token); + if (pending) liveConnectionsRef.current.trackPending(pending); + publishHistorySnapshot(sessionId); + const resumeCursor = historyPaginationCacheRef.current.eventCursor(); + const canResume = + resumeCursor !== null && + isAgentModeMountedRef.current && + userIdRef.current === userId && + agentOwnerKeyRef.current === agentOwnerKey; + return await recoverAgentLiveConnectionAfterReplacementFailure({ + replacementError: loadError, + cursor: canResume ? resumeCursor : null, + retire: retireAgentLiveConnection, + resume: async (retainedCursor) => { + try { + await resumeAgentLiveConnection(retainedCursor); + } catch (resumeError) { + historyPaginationCacheRef.current.requireSynchronizedReload(); + throw resumeError; + } + } + }); + } finally { + reconcileHistoryRetention(); + } + } + + const token = historyPaginationCacheRef.current.beginHead(sessionId); + if (activeSessionIdRef.current === sessionId) setIsLoadingOlderHistory(true); + try { + const page = await agentRuntimeService.listSessionRecordsPage(userId, { + sessionId, + limit: DEFAULT_AGENT_PAGE_SIZE + }); + // Ordinary head loads commit persisted records only. Installing an + // absolute live snapshot/checkpoint requires the attach coordinator's + // subscribe-buffer-replay ordering and must not be inferred from a page. + const result = historyPaginationCacheRef.current.commit(token, page); + if (result === "stale") { + return [...historyPaginationCacheRef.current.snapshot(sessionId).timeline]; + } + publishHistorySnapshot(sessionId); + return [...historyPaginationCacheRef.current.snapshot(sessionId).timeline]; + } catch (loadError) { + historyPaginationCacheRef.current.fail(token); + publishHistorySnapshot(sessionId); + throw loadError; + } finally { + reconcileHistoryRetention(); + } + }, + [ + agentRuntimeService, + agentOwnerKey, + historyPaginationCacheRef, + liveConnectionsRef, + publishHistorySnapshot, + reconcileHistoryRetention, + retireAgentLiveConnection, + resumeAgentLiveConnection, + userId + ] + ); + const observeActiveThoughtPhase = useCallback( (sessionId: string) => { const activePhase = thoughtPhaseTrackerRef.current.activePhase(sessionId); @@ -670,7 +973,7 @@ export function AgentMode({ userId }: { userId: string }) { void (async () => { const timelineRevision = timelineRevisionBySessionRef.current.get(sessionId) || 0; try { - const detail = await agentRuntimeService.loadSession(userId, sessionId); + const timeline = await loadHistoryHead(sessionId); if ( !isAgentModeMountedRef.current || userIdRef.current !== userId || @@ -681,7 +984,7 @@ export function AgentMode({ userId }: { userId: string }) { return; } if ((timelineRevisionBySessionRef.current.get(sessionId) || 0) === timelineRevision) { - thoughtPhaseTrackerRef.current.seedActiveTimeline(sessionId, detail.timeline); + thoughtPhaseTrackerRef.current.seedActiveTimeline(sessionId, timeline); observeActiveThoughtPhase(sessionId); return; } @@ -704,6 +1007,7 @@ export function AgentMode({ userId }: { userId: string }) { }, [ deletedSessionIdsRef, + loadHistoryHead, observeActiveThoughtPhase, thoughtPhaseSeededRunIdsRef, thoughtPhaseTrackerRef, @@ -754,9 +1058,12 @@ export function AgentMode({ userId }: { userId: string }) { isAgentModeMountedRef.current = true; return () => { isAgentModeMountedRef.current = false; + void retireAgentLiveConnection().catch(() => { + console.error("Unable to retire the Agent live connection during unmount"); + }); cancelThoughtLabelDisplays(); }; - }, [cancelThoughtLabelDisplays]); + }, [cancelThoughtLabelDisplays, retireAgentLiveConnection]); useEffect(() => { const wasCompactLayout = previousIsCompactLayoutRef.current; @@ -793,6 +1100,500 @@ export function AgentMode({ userId }: { userId: string }) { }); }, []); + const clearHistoryBottomCompensation = useCallback(() => { + if (historyBottomCompensationRef.current) { + historyBottomCompensationRef.current.style.height = "0px"; + } + }, []); + + useLayoutEffect(() => { + const ownerBinding = historyPaginationCacheRef.current.bindOwner({ + accountId: userId, + targetId: String(agentRuntimeService.target.id) + }); + if (ownerBinding === "reset") { + void retireAgentLiveConnection().catch((retirementError) => { + setError(errorMessage(retirementError)); + }); + sessionPaginationCacheRef.current.clear(); + sessionSelectionGenerationRef.current += 1; + interactionGenerationRef.current += 1; + pendingSessionSelectionIdRef.current = null; + deletedSessionIdsRef.current.clear(); + activeSessionIdRef.current = null; + setActiveSessionId(null); + setSessions([]); + setTimelineItems([]); + setHasMoreSessions(false); + setHasMoreOlderHistory(false); + setIsSessionHistoryReady(false); + } + clearHistoryBottomCompensation(); + pendingHistoryScrollRestoreRef.current = null; + pendingHistoryScrollRestoreSessionIdRef.current = null; + previousHistoryTouchYRef.current = null; + historyTouchGestureActiveRef.current = false; + historyPointerGestureActiveRef.current = false; + suppressedHistoryScrollEndsRef.current = 0; + historyPaginationLifecycle.gate.resetIntent(); + }, [ + agentOwnerKey, + agentRuntimeService.target.id, + clearHistoryBottomCompensation, + deletedSessionIdsRef, + historyPaginationCacheRef, + historyPaginationLifecycle, + retireAgentLiveConnection, + sessionPaginationCacheRef, + userId + ]); + + useEffect(() => { + const protectedSessionIds = new Set(); + if (activeSessionId) protectedSessionIds.add(activeSessionId); + if (pendingSessionSelectionId && pendingSessionSelectionId !== NEW_SESSION_PENDING_KEY) { + protectedSessionIds.add(pendingSessionSelectionId); + } + historyPaginationCacheRef.current.reconcileRetention(protectedSessionIds); + }, [activeSessionId, historyPaginationCacheRef, pendingSessionSelectionId]); + + const captureHistoryScrollSnapshot = useCallback( + (sessionId: string): ChatHistoryScrollSnapshot | null => { + if (activeSessionIdRef.current !== sessionId) return null; + const container = chatContainerRef.current; + if (!container) return null; + const containerRect = container.getBoundingClientRect(); + const anchor = Array.from( + container.querySelectorAll("[data-history-anchor-ids]") + ).find((candidate) => { + const rect = candidate.getBoundingClientRect(); + return rect.bottom > containerRect.top && rect.top < containerRect.bottom; + }); + return { + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + anchorId: anchor?.dataset.historyAnchorIds?.split(" ").find(Boolean), + anchorOffset: anchor ? anchor.getBoundingClientRect().top - containerRect.top : undefined + }; + }, + [] + ); + + const isHistoryTopBoundaryNear = useCallback(() => { + const container = chatContainerRef.current; + const sentinel = historyTopSentinelRef.current; + if (!container || !sentinel) return false; + if (container.scrollHeight <= container.clientHeight + 1) return true; + const containerRect = container.getBoundingClientRect(); + const sentinelRect = sentinel.getBoundingClientRect(); + return ( + sentinelRect.bottom >= containerRect.top - CHAT_HISTORY_TOP_MARGIN_PX && + sentinelRect.top <= containerRect.top + CHAT_HISTORY_TOP_MARGIN_PX + ); + }, []); + + const loadOlderHistory = useCallback(async () => { + const { gate, sessionId } = historyPaginationLifecycle; + if (!sessionId) { + gate.finishLoad(); + return; + } + const token = historyPaginationCacheRef.current.beginOlder(sessionId); + if (!token || !token.cursor) { + gate.finishLoad(); + return; + } + + const requestStartSnapshot = captureHistoryScrollSnapshot(sessionId); + publishHistorySnapshot(sessionId); + let pageProgressed = false; + try { + const page = await agentRuntimeService.listSessionRecordsPage(userId, { + sessionId, + cursor: token.cursor, + limit: DEFAULT_AGENT_PAGE_SIZE + }); + const commitSnapshot = captureHistoryScrollSnapshot(sessionId); + const result = historyPaginationCacheRef.current.commit(token, page); + if (result === "history-replaced") { + await loadHistoryHead(sessionId); + } else if (result === "applied") { + pageProgressed = page.records.length > 0; + if (pageProgressed && activeSessionIdRef.current === sessionId) { + pendingHistoryScrollRestoreRef.current = preferredChatHistoryScrollSnapshot({ + requestStartSnapshot, + commitSnapshot + }); + pendingHistoryScrollRestoreSessionIdRef.current = sessionId; + } + } + } catch (loadError) { + if (isAgentPageStaleError(loadError)) { + historyPaginationCacheRef.current.invalidate(sessionId); + try { + await loadHistoryHead(sessionId); + } catch (headError) { + if (activeSessionIdRef.current === sessionId) setError(errorMessage(headError)); + } + } else { + historyPaginationCacheRef.current.fail(token); + if (activeSessionIdRef.current === sessionId) setError(errorMessage(loadError)); + } + } finally { + gate.finishLoad({ preserveQueuedLoad: pageProgressed }); + publishHistorySnapshot(sessionId); + reconcileHistoryRetention(); + } + }, [ + agentRuntimeService, + captureHistoryScrollSnapshot, + historyPaginationCacheRef, + historyPaginationLifecycle, + loadHistoryHead, + publishHistorySnapshot, + reconcileHistoryRetention, + userId + ]); + + const maybeLoadOlderHistory = useCallback(() => { + const { gate, sessionId } = historyPaginationLifecycle; + const shouldLoad = gate.tryStartLoad({ + canLoad: Boolean(sessionId && hasMoreOlderHistory), + topBoundaryVisible: isHistoryTopBoundaryNear(), + requestInFlight: isLoadingOlderHistory + }); + if (shouldLoad) void loadOlderHistory(); + }, [ + hasMoreOlderHistory, + historyPaginationLifecycle, + isHistoryTopBoundaryNear, + isLoadingOlderHistory, + loadOlderHistory + ]); + + useEffect(() => { + const container = chatContainerRef.current; + const sessionId = historyPaginationLifecycle.sessionId; + if (!container || !sessionId) return; + const gate = historyPaginationLifecycle.gate; + const usesMacOSWheelGestureStart = usesFirstCancelableWheelGestureStart({ + isTauriEnvironment: isTauri(), + browserPlatform: navigator.platform + }); + const delaysWheelGestureEndAfterScrollEnd = isTauri() && isMacOS(); + + const finishWheelGesture = () => { + historyWheelGestureStartPendingRef.current = false; + historyPreviousWheelCancelableRef.current = null; + gate.endGesture(); + historyGestureEndTimeoutRef.current = null; + }; + const finishTouchGesture = () => { + historyTouchGestureActiveRef.current = false; + gate.endGesture(); + historyTouchGestureEndTimeoutRef.current = null; + }; + const scheduleTouchGestureEnd = () => { + if (historyTouchGestureEndTimeoutRef.current) { + clearTimeout(historyTouchGestureEndTimeoutRef.current); + } + historyTouchGestureEndTimeoutRef.current = setTimeout(finishTouchGesture, 250); + }; + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey) return; + + const startsMacOSWheelGesture = + usesMacOSWheelGestureStart && + event.cancelable && + historyPreviousWheelCancelableRef.current !== true; + if (usesMacOSWheelGestureStart) { + historyPreviousWheelCancelableRef.current = event.cancelable; + } + + if (usesMacOSWheelGestureStart && event.deltaY === 0) { + if (startsMacOSWheelGesture) historyWheelGestureStartPendingRef.current = true; + if (historyGestureEndTimeoutRef.current) { + clearTimeout(historyGestureEndTimeoutRef.current); + } + historyGestureEndTimeoutRef.current = setTimeout(finishWheelGesture, 180); + return; + } + + if (event.deltaY >= 0) { + if (event.deltaY > 0) { + historyWheelGestureStartPendingRef.current = false; + historyPreviousWheelCancelableRef.current = null; + gate.endGesture(); + clearHistoryBottomCompensation(); + if (historyGestureEndTimeoutRef.current) { + clearTimeout(historyGestureEndTimeoutRef.current); + historyGestureEndTimeoutRef.current = null; + } + } + return; + } + + if (usesMacOSWheelGestureStart) { + const isNewWheelGesture = + startsMacOSWheelGesture || historyWheelGestureStartPendingRef.current; + historyWheelGestureStartPendingRef.current = false; + gate.beginWheelGesture(isNewWheelGesture); + } else { + gate.beginGesture(); + } + if (container.scrollTop <= CHAT_HISTORY_TOP_MARGIN_PX) maybeLoadOlderHistory(); + if (historyGestureEndTimeoutRef.current) { + clearTimeout(historyGestureEndTimeoutRef.current); + } + historyGestureEndTimeoutRef.current = setTimeout(finishWheelGesture, 180); + }; + const handleTouchStart = (event: TouchEvent) => { + if (historyTouchGestureEndTimeoutRef.current) { + clearTimeout(historyTouchGestureEndTimeoutRef.current); + historyTouchGestureEndTimeoutRef.current = null; + } + historyTouchGestureActiveRef.current = true; + previousHistoryTouchYRef.current = event.touches[0]?.clientY ?? null; + gate.endGesture(); + }; + const handleTouchMove = (event: TouchEvent) => { + const nextY = event.touches[0]?.clientY; + const previousY = previousHistoryTouchYRef.current; + if (nextY === undefined || previousY === null) return; + previousHistoryTouchYRef.current = nextY; + if (nextY > previousY + 2) { + gate.beginGesture(); + maybeLoadOlderHistory(); + } else if (nextY < previousY - 2) { + gate.endGesture(); + clearHistoryBottomCompensation(); + } + }; + const handleTouchEnd = () => { + previousHistoryTouchYRef.current = null; + maybeLoadOlderHistory(); + scheduleTouchGestureEnd(); + }; + const handleTouchCancel = () => { + previousHistoryTouchYRef.current = null; + finishTouchGesture(); + }; + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType !== "mouse" || !event.isPrimary || event.button !== 0) return; + if (historyGestureEndTimeoutRef.current) { + clearTimeout(historyGestureEndTimeoutRef.current); + historyGestureEndTimeoutRef.current = null; + } + gate.endGesture(); + historyPointerGestureActiveRef.current = true; + previousHistoryPointerScrollTopRef.current = container.scrollTop; + }; + const handlePointerEnd = () => { + if (!historyPointerGestureActiveRef.current) return; + historyPointerGestureActiveRef.current = false; + gate.endGesture(); + }; + const isBackwardKey = (event: KeyboardEvent) => + event.key === "ArrowUp" || + event.key === "PageUp" || + event.key === "Home" || + (event.shiftKey && (event.key === " " || event.key === "Spacebar")); + const isForwardKey = (event: KeyboardEvent) => + event.key === "ArrowDown" || + event.key === "PageDown" || + event.key === "End" || + (!event.shiftKey && (event.key === " " || event.key === "Spacebar")); + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof Element && + event.target.closest( + "input, textarea, select, button, a, [contenteditable='true'], [role='button'], [role='textbox']" + ) + ) { + return; + } + if (isForwardKey(event)) { + gate.endGesture(); + clearHistoryBottomCompensation(); + if (historyKeyIntentTimeoutRef.current) { + clearTimeout(historyKeyIntentTimeoutRef.current); + historyKeyIntentTimeoutRef.current = null; + } + return; + } + if (!isBackwardKey(event)) return; + gate.beginGesture(); + maybeLoadOlderHistory(); + if (historyKeyIntentTimeoutRef.current) { + clearTimeout(historyKeyIntentTimeoutRef.current); + } + historyKeyIntentTimeoutRef.current = setTimeout(() => { + gate.endGesture(); + historyKeyIntentTimeoutRef.current = null; + }, 500); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (!isBackwardKey(event)) return; + if (historyKeyIntentTimeoutRef.current) { + clearTimeout(historyKeyIntentTimeoutRef.current); + historyKeyIntentTimeoutRef.current = null; + } + gate.endGesture(); + }; + const handleScroll = () => { + const nextScrollTop = container.scrollTop; + if ( + historyPointerGestureActiveRef.current && + nextScrollTop < previousHistoryPointerScrollTopRef.current + ) { + gate.beginGesture(); + maybeLoadOlderHistory(); + } else if ( + (historyPointerGestureActiveRef.current || + (historyTouchGestureActiveRef.current && previousHistoryTouchYRef.current === null)) && + nextScrollTop > previousHistoryPointerScrollTopRef.current + ) { + clearHistoryBottomCompensation(); + } + previousHistoryPointerScrollTopRef.current = nextScrollTop; + if (historyTouchGestureActiveRef.current && previousHistoryTouchYRef.current === null) { + maybeLoadOlderHistory(); + scheduleTouchGestureEnd(); + } + }; + const handleScrollEnd = () => { + if (suppressedHistoryScrollEndsRef.current > 0) { + suppressedHistoryScrollEndsRef.current -= 1; + return; + } + if (historyPointerGestureActiveRef.current) return; + if (historyTouchGestureActiveRef.current) { + if (historyTouchGestureEndTimeoutRef.current) { + clearTimeout(historyTouchGestureEndTimeoutRef.current); + } + finishTouchGesture(); + return; + } + if (historyGestureEndTimeoutRef.current) { + clearTimeout(historyGestureEndTimeoutRef.current); + if (delaysWheelGestureEndAfterScrollEnd) { + historyGestureEndTimeoutRef.current = setTimeout(finishWheelGesture, 80); + } else { + finishWheelGesture(); + } + return; + } + if (historyKeyIntentTimeoutRef.current) { + clearTimeout(historyKeyIntentTimeoutRef.current); + historyKeyIntentTimeoutRef.current = null; + } + gate.endGesture(); + }; + + container.addEventListener("wheel", handleWheel, { + passive: !usesMacOSWheelGestureStart + }); + container.addEventListener("touchstart", handleTouchStart, { passive: true }); + container.addEventListener("touchmove", handleTouchMove, { passive: true }); + container.addEventListener("touchend", handleTouchEnd, { passive: true }); + container.addEventListener("touchcancel", handleTouchCancel, { passive: true }); + container.addEventListener("pointerdown", handlePointerDown); + container.addEventListener("scroll", handleScroll, { passive: true }); + container.addEventListener("scrollend", handleScrollEnd); + window.addEventListener("pointerup", handlePointerEnd); + window.addEventListener("pointercancel", handlePointerEnd); + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + container.removeEventListener("wheel", handleWheel); + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); + container.removeEventListener("touchcancel", handleTouchCancel); + container.removeEventListener("pointerdown", handlePointerDown); + container.removeEventListener("scroll", handleScroll); + container.removeEventListener("scrollend", handleScrollEnd); + window.removeEventListener("pointerup", handlePointerEnd); + window.removeEventListener("pointercancel", handlePointerEnd); + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + if (historyGestureEndTimeoutRef.current) { + clearTimeout(historyGestureEndTimeoutRef.current); + historyGestureEndTimeoutRef.current = null; + } + if (historyTouchGestureEndTimeoutRef.current) { + clearTimeout(historyTouchGestureEndTimeoutRef.current); + historyTouchGestureEndTimeoutRef.current = null; + } + if (historyKeyIntentTimeoutRef.current) { + clearTimeout(historyKeyIntentTimeoutRef.current); + historyKeyIntentTimeoutRef.current = null; + } + historyWheelGestureStartPendingRef.current = false; + historyPreviousWheelCancelableRef.current = null; + historyTouchGestureActiveRef.current = false; + historyPointerGestureActiveRef.current = false; + gate.resetIntent(); + }; + }, [clearHistoryBottomCompensation, historyPaginationLifecycle, maybeLoadOlderHistory]); + + useLayoutEffect(() => { + if (isLoadingOlderHistory || !pendingHistoryScrollRestoreRef.current) return; + const sessionId = pendingHistoryScrollRestoreSessionIdRef.current; + const snapshot = pendingHistoryScrollRestoreRef.current; + pendingHistoryScrollRestoreRef.current = null; + pendingHistoryScrollRestoreSessionIdRef.current = null; + const container = chatContainerRef.current; + if (!container || !sessionId || activeSessionIdRef.current !== sessionId) return; + + let restoredTop = restoredChatHistoryScrollTop(snapshot, container.scrollHeight); + if (snapshot.anchorId && snapshot.anchorOffset !== undefined) { + const anchor = Array.from( + container.querySelectorAll("[data-history-anchor-ids]") + ).find((candidate) => + candidate.dataset.historyAnchorIds?.split(" ").includes(snapshot.anchorId!) + ); + if (anchor) { + restoredTop = restoredChatHistoryAnchorScrollTop( + container.scrollTop, + snapshot.anchorOffset, + anchor.getBoundingClientRect().top - container.getBoundingClientRect().top + ); + } + } + const compensation = historyBottomCompensationRef.current; + const currentCompensation = compensation?.offsetHeight ?? 0; + const missingRange = requiredChatHistoryBottomCompensation( + restoredTop, + container.scrollHeight - currentCompensation, + container.clientHeight + ); + if (compensation) + compensation.style.height = missingRange > 0 ? `${missingRange + 1}px` : "0px"; + const previousScrollTop = container.scrollTop; + container.scrollTop = restoredTop; + if (Math.abs(container.scrollTop - previousScrollTop) > 0.5) { + suppressedHistoryScrollEndsRef.current += 1; + } + }, [isLoadingOlderHistory, timelineItems]); + + useLayoutEffect(() => { + if (isLoadingOlderHistory) return; + if ( + historyPaginationLifecycle.gate.tryStartQueuedLoad({ + canLoad: Boolean(activeSessionId && hasMoreOlderHistory) + }) + ) { + void loadOlderHistory(); + } + }, [ + activeSessionId, + hasMoreOlderHistory, + historyPaginationLifecycle, + isLoadingOlderHistory, + loadOlderHistory + ]); + useEffect(() => { if (!shouldAutoScrollRef.current) return; @@ -941,14 +1742,21 @@ export function AgentMode({ userId }: { userId: string }) { const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), [setIsSidebarOpen]); - const beginSessionSelection = useCallback((sessionId: string): number => { - interactionGenerationRef.current += 1; - const generation = sessionSelectionGenerationRef.current + 1; - sessionSelectionGenerationRef.current = generation; - pendingSessionSelectionIdRef.current = sessionId; - setPendingSessionSelectionId(sessionId); - return generation; - }, []); + const beginSessionSelection = useCallback( + (sessionId: string): number => { + interactionGenerationRef.current += 1; + const generation = sessionSelectionGenerationRef.current + 1; + sessionSelectionGenerationRef.current = generation; + pendingSessionSelectionIdRef.current = sessionId; + setPendingSessionSelectionId(sessionId); + const protectedSessionIds = new Set(); + if (activeSessionIdRef.current) protectedSessionIds.add(activeSessionIdRef.current); + if (sessionId !== NEW_SESSION_PENDING_KEY) protectedSessionIds.add(sessionId); + historyPaginationCacheRef.current.reconcileRetention(protectedSessionIds); + return generation; + }, + [historyPaginationCacheRef] + ); const finishSessionSelection = useCallback((generation: number): boolean => { if (sessionSelectionGenerationRef.current !== generation) return false; @@ -1072,11 +1880,11 @@ export function AgentMode({ userId }: { userId: string }) { const mergeSessionTimelineItem = useCallback( (sessionId: string, item: AgentTimelineItem) => { bumpTimelineRevision(sessionId); - if (activeSessionIdRef.current === sessionId) { - setTimelineItems((current) => mergeTimelineItem(current, item)); - } + const result = historyPaginationCacheRef.current.mergeLiveItem(sessionId, item); + publishHistorySnapshot(sessionId); + return result; }, - [bumpTimelineRevision] + [bumpTimelineRevision, historyPaginationCacheRef, publishHistorySnapshot] ); const clearCompletedUnreadSession = useCallback((sessionId: string) => { @@ -1137,7 +1945,7 @@ export function AgentMode({ userId }: { userId: string }) { await agentRuntimeService.saveConfig(userId, nextConfig); }); }, - [enqueueProjectRootMutation, userId] + [agentRuntimeService, enqueueProjectRootMutation, userId] ); const registerProjectRoot = useCallback( @@ -1165,7 +1973,7 @@ export function AgentMode({ userId }: { userId: string }) { return { projectRoot: path, roots, config: nextConfig }; }); }, - [enqueueProjectRootMutation, projectOrderState.confirmed, userId] + [agentRuntimeService, enqueueProjectRootMutation, projectOrderState.confirmed, userId] ); const persistProjectRootOrder = useCallback( @@ -1177,7 +1985,7 @@ export function AgentMode({ userId }: { userId: string }) { ); }); }, - [enqueueProjectRootMutation, userId] + [agentRuntimeService, enqueueProjectRootMutation, userId] ); const saveProjectRootOrder = useCallback( @@ -1229,7 +2037,14 @@ export function AgentMode({ userId }: { userId: string }) { setIsProjectSkillsTrustLoading(false); } }); - }, [isAuthTransitionReady, isInitializing, projectRoot, trackAgentWorkflow, userId]); + }, [ + agentRuntimeService, + isAuthTransitionReady, + isInitializing, + projectRoot, + trackAgentWorkflow, + userId + ]); const saveProjectSkillsTrust = useCallback( async (trusted: boolean) => { @@ -1249,38 +2064,86 @@ export function AgentMode({ userId }: { userId: string }) { setProjectSkillsTrustSavingDecision(null); } }, - [projectSkillsTrustPrompt, projectSkillsTrustSavingDecision, trackAgentWorkflow, userId] + [ + agentRuntimeService, + projectSkillsTrustPrompt, + projectSkillsTrustSavingDecision, + trackAgentWorkflow, + userId + ] ); + const publishSessionPageSnapshot = useCallback(() => { + const snapshot = sessionPaginationCacheRef.current.snapshot(); + setSessions([...snapshot.items]); + setHasMoreSessions(snapshot.hasMore); + setIsLoadingOlderSessions(snapshot.isLoading && snapshot.headLoaded); + }, [sessionPaginationCacheRef]); + const refreshSessionList = useCallback(async () => { return await trackAgentWorkflow(async () => { - if (!isTauriDesktop()) return; - const refreshGeneration = sessionListRefreshGenerationRef.current + 1; - sessionListRefreshGenerationRef.current = refreshGeneration; - const summaryRevisionAtStart = sessionSummaryRevisionRef.current; - const nextSessions = await agentRuntimeService.listSessions(userId, null); - if (refreshGeneration < sessionListAppliedGenerationRef.current) return; - sessionListAppliedGenerationRef.current = refreshGeneration; - const changedAfterRequest = new Set( - [...sessionSummaryRevisionsRef.current.entries()] - .filter(([, revision]) => revision > summaryRevisionAtStart) - .map(([sessionId]) => sessionId) - ); - setSessions((current) => - reconcileAgentSessionSnapshot( - nextSessions, - current, - changedAfterRequest, - deletedSessionIdsRef.current - ) - ); - setIsSessionHistoryReady(true); + const token = sessionPaginationCacheRef.current.beginHead(); + try { + const page = await agentRuntimeService.listSessionsPage(userId, { + projectRoot: null, + limit: DEFAULT_AGENT_PAGE_SIZE + }); + sessionPaginationCacheRef.current.commit(token, page); + publishSessionPageSnapshot(); + setIsSessionHistoryReady(true); + } catch (loadError) { + sessionPaginationCacheRef.current.fail(token); + publishSessionPageSnapshot(); + throw loadError; + } }); - }, [deletedSessionIdsRef, trackAgentWorkflow, userId]); + }, [ + agentRuntimeService, + publishSessionPageSnapshot, + sessionPaginationCacheRef, + trackAgentWorkflow, + userId + ]); + + const loadOlderSessions = useCallback(async () => { + const token = sessionPaginationCacheRef.current.beginOlder(); + if (!token) return; + publishSessionPageSnapshot(); + try { + const page = await trackAgentWorkflow(() => + agentRuntimeService.listSessionsPage(userId, { + projectRoot: null, + cursor: token.cursor, + limit: DEFAULT_AGENT_PAGE_SIZE + }) + ); + sessionPaginationCacheRef.current.commit(token, page); + } catch (loadError) { + if (isAgentPageStaleError(loadError)) { + sessionPaginationCacheRef.current.clear(); + try { + await refreshSessionList(); + } catch (headError) { + setError(errorMessage(headError)); + } + } else { + sessionPaginationCacheRef.current.fail(token); + setError(errorMessage(loadError)); + } + } finally { + publishSessionPageSnapshot(); + } + }, [ + agentRuntimeService, + publishSessionPageSnapshot, + refreshSessionList, + sessionPaginationCacheRef, + trackAgentWorkflow, + userId + ]); const refreshSessions = useCallback(async () => { return await trackAgentWorkflow(async () => { - if (!isTauriDesktop()) return; const runStateGeneration = runStateGenerationRef.current; const status = await agentRuntimeService.getRuntimeStatus(userId); applyRuntimeStatus(status, runStateGeneration); @@ -1291,7 +2154,7 @@ export function AgentMode({ userId }: { userId: string }) { } await refreshSessionList(); }); - }, [applyRuntimeStatus, refreshSessionList, trackAgentWorkflow, userId]); + }, [agentRuntimeService, applyRuntimeStatus, refreshSessionList, trackAgentWorkflow, userId]); const refreshSessionMcpServers = useCallback( async (sessionId: string) => { @@ -1314,7 +2177,7 @@ export function AgentMode({ userId }: { userId: string }) { } } }, - [userId] + [agentRuntimeService, userId] ); const saveMcpServers = useCallback( @@ -1335,7 +2198,7 @@ export function AgentMode({ userId }: { userId: string }) { }); } }, - [mcpServers, refreshSessionMcpServers, userId] + [agentRuntimeService, mcpServers, refreshSessionMcpServers, userId] ); const toggleMcpServer = useCallback( @@ -1377,7 +2240,7 @@ export function AgentMode({ userId }: { userId: string }) { } }); }, - [userId] + [agentRuntimeService, userId] ); useEffect(() => { @@ -1403,7 +2266,6 @@ export function AgentMode({ userId }: { userId: string }) { const initializationGeneration = interactionGenerationRef.current; setIsInitializing(true); async function loadInitialState() { - if (!isTauriDesktop()) return; try { // A mode switch can remount AgentMode while the previous mount is still saving a selected // root or manual order. Read only after that user-scoped queue reaches its latest tail. @@ -1499,6 +2361,7 @@ export function AgentMode({ userId }: { userId: string }) { cancelled = true; }; }, [ + agentRuntimeService, agentModelPreferenceRef, applyAuthoritativeMode, applyRuntimeStatus, @@ -1511,7 +2374,7 @@ export function AgentMode({ userId }: { userId: string }) { ]); const chooseProjectRoot = useCallback(async () => { - if (!isTauriDesktop()) return; + if (!localProjectFolderActionsAvailable) return; try { await trackAgentWorkflow(async () => { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -1528,7 +2391,7 @@ export function AgentMode({ userId }: { userId: string }) { visibleProjectRootsRef.current ); invalidateSessionSelection(); - agentSessionSelection.forget(userId); + agentSessionSelection.forget(agentOwnerKey); shouldAutoScrollRef.current = true; setProjectRoot(registration.projectRoot); activeSessionIdRef.current = null; @@ -1546,18 +2409,19 @@ export function AgentMode({ userId }: { userId: string }) { setError(errorMessage(chooseError)); } }, [ + agentOwnerKey, agentSessionSelection, invalidateSessionSelection, + localProjectFolderActionsAvailable, registerProjectRoot, restoreNewTaskModel, - trackAgentWorkflow, - userId + trackAgentWorkflow ]); const selectProjectRoot = useCallback( (value: string) => { invalidateSessionSelection(); - agentSessionSelection.forget(userId); + agentSessionSelection.forget(agentOwnerKey); const interactionGeneration = interactionGenerationRef.current; setProjectRoot(value); setActiveSessionId(null); @@ -1579,12 +2443,12 @@ export function AgentMode({ userId }: { userId: string }) { })(); }, [ + agentOwnerKey, agentSessionSelection, invalidateSessionSelection, persistSelectedProjectRoot, refreshSessions, - restoreNewTaskModel, - userId + restoreNewTaskModel ] ); @@ -1733,7 +2597,7 @@ export function AgentMode({ userId }: { userId: string }) { } }); }, - [applyAuthoritativeMode, permissionModeUpdateRef, userId] + [agentRuntimeService, applyAuthoritativeMode, permissionModeUpdateRef, userId] ); const startRuntime = useCallback( @@ -1794,6 +2658,7 @@ export function AgentMode({ userId }: { userId: string }) { } }, [ + agentRuntimeService, agentModelPreferenceRef, applyAuthoritativeMode, applyRuntimeStatus, @@ -1836,13 +2701,20 @@ export function AgentMode({ userId }: { userId: string }) { // Goose may reuse the newest deleted session ID. This detail represents // a new persisted session, so it supersedes any local deletion tombstone. deletedSessionIdsRef.current.delete(detail.session.id); - markSessionSummaryChanged(detail.session.id); sessionId = detail.session.id; - setSessions((current) => [ - detail.session, - ...current.filter((item) => item.id !== detail.session.id) - ]); - replaceSessionTimeline(sessionId, detail.timeline); + sessionPaginationCacheRef.current.upsert(detail.session); + publishSessionPageSnapshot(); + let createdTimeline = detail.timeline; + if ( + historyPaginationCacheRef.current.seedLiveTimeline(sessionId, detail.timeline) === + "synchronized-reload-required" + ) { + throw new Error("Agent live history requires a synchronized reconnect"); + } + if (agentRuntimeService.target.kind === "remote") { + createdTimeline = await loadHistoryHead(sessionId); + } + replaceSessionTimeline(sessionId, createdTimeline); // A send that creates a session may finish after the user selects a // different chat. Keep the new chat/run, but never steal focus back. @@ -1855,12 +2727,12 @@ export function AgentMode({ userId }: { userId: string }) { shouldAutoScrollRef.current = true; activeSessionIdRef.current = sessionId; setActiveSessionId(sessionId); - agentSessionSelection.remember(userId, sessionId); + agentSessionSelection.remember(agentOwnerKey, sessionId); isAgentModelLockedRef.current = false; currentAgentModelRef.current = requestModel; setModel(requestModel); applyAuthoritativeMode(normalizeAgentPermissionMode(detail.session.mode)); - replaceSessionTimeline(sessionId, detail.timeline); + replaceSessionTimeline(sessionId, createdTimeline); const mcpError = mcpConnectionErrorMessage(detail.mcpErrors); if (mcpError) setError(mcpError); } @@ -1869,14 +2741,19 @@ export function AgentMode({ userId }: { userId: string }) { return { sessionId, requestModel }; }, [ + agentOwnerKey, + agentRuntimeService, agentModelPreferenceRef, applyAuthoritativeMode, agentSessionSelection, contextLimitForModel, deletedSessionIdsRef, - markSessionSummaryChanged, + historyPaginationCacheRef, + loadHistoryHead, projectRoot, + publishSessionPageSnapshot, replaceSessionTimeline, + sessionPaginationCacheRef, selectedNewChatMcpServerNames, startRuntime, userId @@ -1911,12 +2788,19 @@ export function AgentMode({ userId }: { userId: string }) { }); }); deletedSessionIdsRef.current.delete(detail.session.id); - markSessionSummaryChanged(detail.session.id); - setSessions((current) => [ - detail.session, - ...current.filter((session) => session.id !== detail.session.id) - ]); - replaceSessionTimeline(detail.session.id, detail.timeline); + sessionPaginationCacheRef.current.upsert(detail.session); + publishSessionPageSnapshot(); + let createdTimeline = detail.timeline; + if ( + historyPaginationCacheRef.current.seedLiveTimeline(detail.session.id, detail.timeline) === + "synchronized-reload-required" + ) { + throw new Error("Agent live history requires a synchronized reconnect"); + } + if (agentRuntimeService.target.kind === "remote") { + createdTimeline = await loadHistoryHead(detail.session.id); + } + replaceSessionTimeline(detail.session.id, createdTimeline); if ( isAgentModeMountedRef.current && @@ -1926,13 +2810,13 @@ export function AgentMode({ userId }: { userId: string }) { shouldAutoScrollRef.current = true; activeSessionIdRef.current = detail.session.id; setActiveSessionId(detail.session.id); - agentSessionSelection.remember(userId, detail.session.id); + agentSessionSelection.remember(agentOwnerKey, detail.session.id); setProjectRoot(detail.session.projectRoot); isAgentModelLockedRef.current = false; currentAgentModelRef.current = requestModel; setModel(requestModel); applyAuthoritativeMode(normalizeAgentPermissionMode(detail.session.mode)); - replaceSessionTimeline(detail.session.id, detail.timeline); + replaceSessionTimeline(detail.session.id, createdTimeline); const mcpError = mcpConnectionErrorMessage(detail.mcpErrors); if (mcpError) setError(mcpError); } @@ -1951,6 +2835,8 @@ export function AgentMode({ userId }: { userId: string }) { } }, [ + agentOwnerKey, + agentRuntimeService, agentModelPreferenceRef, applyAuthoritativeMode, agentSessionSelection, @@ -1959,11 +2845,14 @@ export function AgentMode({ userId }: { userId: string }) { deletedSessionIdsRef, finishSessionSelection, isAgentModelCatalogLoading, - markSessionSummaryChanged, + historyPaginationCacheRef, + loadHistoryHead, projectRoot, + publishSessionPageSnapshot, replaceSessionTimeline, runtimeStatus?.running, selectedNewChatMcpServerNames, + sessionPaginationCacheRef, startRuntime, trackAgentWorkflow, userId @@ -1977,17 +2866,11 @@ export function AgentMode({ userId }: { userId: string }) { setError(null); clearCompletedUnreadSession(sessionId); try { - const loaded = await trackAgentWorkflow(async () => { - for (let attempt = 0; attempt < MAX_STABLE_SESSION_LOAD_ATTEMPTS; attempt += 1) { - const timelineRevision = timelineRevisionBySessionRef.current.get(sessionId) || 0; - const detail = await agentRuntimeService.loadSession(userId, sessionId); - if ((timelineRevisionBySessionRef.current.get(sessionId) || 0) === timelineRevision) { - return { detail, timelineRevision }; - } - } - throw new Error("This task is still updating. Try selecting it again shortly."); - }); - const { detail, timelineRevision } = loaded; + const session = sessionPaginationCacheRef.current + .snapshot() + .items.find((candidate) => candidate.id === sessionId); + if (!session) throw new Error("This task is not in the loaded task history."); + const timeline = await trackAgentWorkflow(() => loadHistoryHead(sessionId)); if ( !isAgentModeMountedRef.current || sessionSelectionGenerationRef.current !== selectionGeneration || @@ -1997,53 +2880,43 @@ export function AgentMode({ userId }: { userId: string }) { return; } - // Validate and install the snapshot before switching focus. A live - // event can arrive between the native read and this continuation; in - // that case leave the previous chat intact instead of overwriting the - // newer timeline with a stale snapshot. - if (!replaceSessionTimeline(detail.session.id, detail.timeline, timelineRevision)) { - throw new Error("This task changed while loading. Try selecting it again."); - } - // Commit the selected session and all of its settings together. Until // this point the previous chat remains active and its composer is gated. shouldAutoScrollRef.current = true; - activeSessionIdRef.current = detail.session.id; - clearCompletedUnreadSession(detail.session.id); - setActiveSessionId(detail.session.id); - agentSessionSelection.remember(userId, detail.session.id); - setProjectRoot(detail.session.projectRoot); + activeSessionIdRef.current = session.id; + clearCompletedUnreadSession(session.id); + setActiveSessionId(session.id); + agentSessionSelection.remember(agentOwnerKey, session.id); + setProjectRoot(session.projectRoot); const isModelLocked = - detail.session.messageCount > 0 || - hasAgentUserMessage(detail.timeline) || - Boolean(activeRunsBySessionRef.current[detail.session.id]) || - pendingSendTokensRef.current.has(detail.session.id); + session.messageCount > 0 || + hasAgentUserMessage(timeline) || + Boolean(activeRunsBySessionRef.current[session.id]) || + pendingSendTokensRef.current.has(session.id); isAgentModelLockedRef.current = isModelLocked; const sessionModel = resolveAgentModelForSession( newTaskAgentModel(agentModelPreferenceRef.current), - detail.session.model, + session.model, isModelLocked ); currentAgentModelRef.current = sessionModel; setModel(sessionModel); - applyAuthoritativeMode(normalizeAgentPermissionMode(detail.session.mode)); - setTimelineItems(detail.timeline); - if (activeRunsBySessionRef.current[detail.session.id]) { - thoughtPhaseTrackerRef.current.seedActiveTimeline(detail.session.id, detail.timeline); - observeActiveThoughtPhase(detail.session.id); + applyAuthoritativeMode(normalizeAgentPermissionMode(session.mode)); + publishHistorySnapshot(session.id); + if (activeRunsBySessionRef.current[session.id]) { + thoughtPhaseTrackerRef.current.seedActiveTimeline(session.id, timeline); + observeActiveThoughtPhase(session.id); } - const mcpError = mcpConnectionErrorMessage(detail.mcpErrors); - if (mcpError) setError(mcpError); finishSessionSelection(selectionGeneration); try { - await persistSelectedProjectRoot(detail.session.projectRoot); + await persistSelectedProjectRoot(session.projectRoot); } catch (persistError) { if ( isAgentModeMountedRef.current && sessionSelectionGenerationRef.current === selectionGeneration && interactionGenerationRef.current === interactionGeneration && - activeSessionIdRef.current === detail.session.id + activeSessionIdRef.current === session.id ) { setError(errorMessage(persistError)); } @@ -2063,6 +2936,7 @@ export function AgentMode({ userId }: { userId: string }) { } }, [ + agentOwnerKey, agentModelPreferenceRef, applyAuthoritativeMode, agentSessionSelection, @@ -2073,11 +2947,11 @@ export function AgentMode({ userId }: { userId: string }) { observeActiveThoughtPhase, pendingSendTokensRef, persistSelectedProjectRoot, - replaceSessionTimeline, + loadHistoryHead, + publishHistorySnapshot, + sessionPaginationCacheRef, thoughtPhaseTrackerRef, - timelineRevisionBySessionRef, - trackAgentWorkflow, - userId + trackAgentWorkflow ] ); @@ -2091,16 +2965,22 @@ export function AgentMode({ userId }: { userId: string }) { return; } - hasAttemptedSessionRestoreRef.current = true; - const rememberedSessionId = agentSessionSelection.resolve(userId, visibleSessions); + const rememberedSessionId = agentSessionSelection.resolve(agentOwnerKey, visibleSessions, { + historyComplete: !hasMoreSessions + }); if (rememberedSessionId) { + hasAttemptedSessionRestoreRef.current = true; void loadSession(rememberedSessionId); + } else if (!hasMoreSessions) { + hasAttemptedSessionRestoreRef.current = true; } }, [ + agentOwnerKey, agentSessionSelection, isAuthTransitionReady, isInitializing, isSessionHistoryReady, + hasMoreSessions, loadSession, visibleSessions, userId @@ -2207,6 +3087,7 @@ export function AgentMode({ userId }: { userId: string }) { clearPendingSend(pendingSessionKey, sendToken); } }, [ + agentRuntimeService, activeRunsBySession, availableModels, cancelledPendingSendTokensRef, @@ -2248,7 +3129,13 @@ export function AgentMode({ userId }: { userId: string }) { setError(errorMessage(cancelError)); } } - }, [activeRunId, cancelledPendingSendTokensRef, pendingSendTokensRef, userId]); + }, [ + activeRunId, + agentRuntimeService, + cancelledPendingSendTokensRef, + pendingSendTokensRef, + userId + ]); const respondToPermission = useCallback( async (item: AgentTimelineItem, decision: AgentPermissionDecision) => { @@ -2270,7 +3157,7 @@ export function AgentMode({ userId }: { userId: string }) { } } }, - [userId] + [agentRuntimeService, userId] ); const handleKeyDown = useCallback( @@ -2294,12 +3181,13 @@ export function AgentMode({ userId }: { userId: string }) { const removeSessionFromState = useCallback( (sessionId: string) => { deletedSessionIdsRef.current.add(sessionId); - sessionSummaryRevisionsRef.current.delete(sessionId); + sessionPaginationCacheRef.current.remove(sessionId); + historyPaginationCacheRef.current.remove(sessionId); thoughtPhaseTrackerRef.current.forgetSession(sessionId); cancelThoughtLabelDisplays(sessionId); - agentSessionSelection.forget(userId, sessionId); + agentSessionSelection.forget(agentOwnerKey, sessionId); timelineRevisionBySessionRef.current.delete(sessionId); - setSessions((current) => current.filter((session) => session.id !== sessionId)); + publishSessionPageSnapshot(); setCompletedUnreadSessionIds((current) => { if (!current.has(sessionId)) return current; const next = new Set(current); @@ -2326,14 +3214,17 @@ export function AgentMode({ userId }: { userId: string }) { } }, [ + agentOwnerKey, agentSessionSelection, cancelThoughtLabelDisplays, clearActiveRun, deletedSessionIdsRef, + historyPaginationCacheRef, + publishSessionPageSnapshot, restoreNewTaskModel, + sessionPaginationCacheRef, thoughtPhaseTrackerRef, - timelineRevisionBySessionRef, - userId + timelineRevisionBySessionRef ] ); @@ -2352,7 +3243,7 @@ export function AgentMode({ userId }: { userId: string }) { setError(errorMessage(deleteError)); } }, - [removeSessionFromState, userId] + [agentRuntimeService, removeSessionFromState, userId] ); const removeProjectRoot = useCallback( @@ -2373,7 +3264,7 @@ export function AgentMode({ userId }: { userId: string }) { if (projectRoot === root.path) { invalidateSessionSelection(); - agentSessionSelection.forget(userId); + agentSessionSelection.forget(agentOwnerKey); activeSessionIdRef.current = null; setActiveSessionId(null); setTimelineItems([]); @@ -2393,6 +3284,8 @@ export function AgentMode({ userId }: { userId: string }) { } }, [ + agentOwnerKey, + agentRuntimeService, agentSessionSelection, enqueueProjectRootMutation, invalidateSessionSelection, @@ -2422,35 +3315,33 @@ export function AgentMode({ userId }: { userId: string }) { const upsertSessionSummary = useCallback( (summary: AgentSessionSummary) => { if (deletedSessionIdsRef.current.has(summary.id)) return; - markSessionSummaryChanged(summary.id); - setSessions((current) => { - let replaced = false; - const next = current.map((session) => { - if (session.id !== summary.id) return session; - replaced = true; - return summary; - }); - return replaced ? next : [summary, ...current]; - }); + sessionPaginationCacheRef.current.upsert(summary); + publishSessionPageSnapshot(); }, - [deletedSessionIdsRef, markSessionSummaryChanged] + [deletedSessionIdsRef, publishSessionPageSnapshot, sessionPaginationCacheRef] ); const renameAgentSession = useCallback( async (sessionId: string, title: string) => { - const revision = sessionSummaryRevisionsRef.current.get(sessionId); + const revision = sessionPaginationCacheRef.current.summaryRevision(sessionId); const summary = await agentRuntimeService.renameSession(userId, { sessionId, title }); if ( !isAgentModeMountedRef.current || userIdRef.current !== userId || deletedSessionIdsRef.current.has(sessionId) || - sessionSummaryRevisionsRef.current.get(sessionId) !== revision + sessionPaginationCacheRef.current.summaryRevision(sessionId) !== revision ) { return; } upsertSessionSummary(summary); }, - [deletedSessionIdsRef, upsertSessionSummary, userId] + [ + agentRuntimeService, + deletedSessionIdsRef, + sessionPaginationCacheRef, + upsertSessionSummary, + userId + ] ); const observeLiveThoughtItem = useCallback( @@ -2473,9 +3364,147 @@ export function AgentMode({ userId }: { userId: string }) { [completeThoughtPhase, observeActiveThoughtPhase, thoughtPhaseTrackerRef] ); + const reconcileAgentEventGap = useCallback( + (affectedSessionId: string | null) => { + if (affectedSessionId) { + pendingEventGapSessionIdsRef.current.add(affectedSessionId); + } else { + hasUnknownEventGapRef.current = true; + } + if (eventGapRecoveryRef.current) return; + const recovery = (async () => { + do { + const sessionIds = new Set(pendingEventGapSessionIdsRef.current); + pendingEventGapSessionIdsRef.current.clear(); + const hadUnknownGap = hasUnknownEventGapRef.current; + hasUnknownEventGapRef.current = false; + try { + const runStateGeneration = runStateGenerationRef.current; + const status = await agentRuntimeService.getRuntimeStatus(userId); + applyRuntimeStatus(status, runStateGeneration); + await refreshSessionList(); + if (activeSessionIdRef.current) sessionIds.add(activeSessionIdRef.current); + if (hadUnknownGap && sessionIds.size === 0) { + const newestSession = sessionPaginationCacheRef.current.snapshot().items[0]; + if (newestSession) sessionIds.add(newestSession.id); + } + const reloadableSessionIds = [...sessionIds].filter( + (sessionId) => !deletedSessionIdsRef.current.has(sessionId) + ); + if (agentRuntimeService.target.kind === "remote") { + // One synchronized remote attach replaces every account live + // overlay at the same C0. Loading multiple heads would only + // churn the single account stream and cannot improve recovery. + const sessionId = + activeSessionIdRef.current && + reloadableSessionIds.includes(activeSessionIdRef.current) + ? activeSessionIdRef.current + : reloadableSessionIds[0]; + if (sessionId) await loadHistoryHead(sessionId); + } else { + await Promise.all( + reloadableSessionIds.map((sessionId) => loadHistoryHead(sessionId)) + ); + } + } catch (gapError) { + if (isAgentModeMountedRef.current && userIdRef.current === userId) { + setError(errorMessage(gapError)); + } + } + } while (pendingEventGapSessionIdsRef.current.size > 0 || hasUnknownEventGapRef.current); + })(); + eventGapRecoveryRef.current = recovery; + void recovery.finally(() => { + if (eventGapRecoveryRef.current === recovery) eventGapRecoveryRef.current = null; + }); + }, + [ + agentRuntimeService, + applyRuntimeStatus, + deletedSessionIdsRef, + loadHistoryHead, + pendingEventGapSessionIdsRef, + refreshSessionList, + sessionPaginationCacheRef, + userId + ] + ); + + const settleFinishedAgentRun = useCallback( + (sessionId: string, runId: string, terminal: "completed" | "cancelled" | "failed") => { + runStateGenerationRef.current += 1; + terminalRunIdsRef.current.add(runId); + thoughtPhaseSeededRunIdsRef.current.delete(runId); + const finishedTimelineRevision = bumpTimelineRevision(sessionId); + clearActiveRun(sessionId, runId); + // The terminal event is authoritative for run state. Refresh only + // persisted session metadata here: a concurrent status snapshot could + // otherwise resurrect the completed run. + void refreshSessionList().catch(() => {}); + const thoughtRunFinished = handleAgentModeThoughtRunFinished({ + event: { + eventType: "runFinished", + sessionId, + runId, + message: terminal + }, + timelineRevision: finishedTimelineRevision, + tracker: thoughtPhaseTrackerRef.current, + finalizePhase: completeThoughtPhase, + releaseProvisional: (phase) => { + thoughtLabelProvisionalSchedulerRef.current?.complete(phase.sessionId, phase.phaseId); + }, + cancelAndInvalidateLabels: (finishedSessionId, assistantTurnId) => { + if (assistantTurnId) { + invalidateThoughtLabelsForTurn(finishedSessionId, assistantTurnId); + } else { + invalidateThoughtLabelsForSession(finishedSessionId); + } + }, + loadTimeline: async (finishedSessionId) => await loadHistoryHead(finishedSessionId), + canApplyTimeline: (finishedSessionId) => + isAgentModeMountedRef.current && + userIdRef.current === userId && + !deletedSessionIdsRef.current.has(finishedSessionId), + replaceTimeline: replaceSessionTimeline + }); + if (thoughtRunFinished) { + if (terminal === "completed" && sessionId !== activeSessionIdRef.current) { + markCompletedUnreadSession(sessionId); + } + void thoughtRunFinished.catch(() => {}); + } + }, + [ + bumpTimelineRevision, + clearActiveRun, + completeThoughtPhase, + deletedSessionIdsRef, + invalidateThoughtLabelsForSession, + invalidateThoughtLabelsForTurn, + loadHistoryHead, + markCompletedUnreadSession, + refreshSessionList, + replaceSessionTimeline, + terminalRunIdsRef, + thoughtPhaseSeededRunIdsRef, + thoughtPhaseTrackerRef, + userId + ] + ); + const handleAgentEvent = useCallback( (event: AgentEventEnvelope) => { const eventSessionId = event.sessionId || event.session?.id; + const acceptance = historyPaginationCacheRef.current.acceptEvent(event); + if (acceptance === "duplicate" || acceptance === "invalid") return; + if (acceptance === "gap") { + // Event sequence is account-wide, so reconcile account/runtime and + // bounded affected heads together. The replay journal will become the + // first recovery step when its attach contract is wired. + reconcileAgentEventGap(eventSessionId ?? null); + return; + } if (eventSessionId && deletedSessionIdsRef.current.has(eventSessionId)) { return; } @@ -2500,6 +3529,7 @@ export function AgentMode({ userId }: { userId: string }) { case "runStarted": runStateGenerationRef.current += 1; if (event.sessionId && event.runId && !terminalRunIdsRef.current.has(event.runId)) { + historyPaginationCacheRef.current.startLiveSuffix(event.sessionId); bumpTimelineRevision(event.sessionId); clearCompletedUnreadSession(event.sessionId); recordActiveRun(event.sessionId, event.runId); @@ -2507,54 +3537,26 @@ export function AgentMode({ userId }: { userId: string }) { break; case "timelineItem": if (event.item && event.sessionId) { - observeLiveThoughtItem(event.sessionId, event.item); - mergeSessionTimelineItem(event.sessionId, event.item); + const mergeResult = mergeSessionTimelineItem(event.sessionId, event.item); + if (mergeResult === "applied") { + observeLiveThoughtItem(event.sessionId, event.item); + } else { + setError( + "Agent live history exceeded its safe cache window. Reconnect to reload this task." + ); + reconcileAgentEventGap(event.sessionId); + } } break; case "runFinished": { - runStateGenerationRef.current += 1; - if (event.runId) { - terminalRunIdsRef.current.add(event.runId); - thoughtPhaseSeededRunIdsRef.current.delete(event.runId); - } - let finishedTimelineRevision: number | undefined; - if (event.sessionId) { - finishedTimelineRevision = bumpTimelineRevision(event.sessionId); - clearActiveRun(event.sessionId, event.runId || undefined); - } - // The terminal event is authoritative for run state. Refresh only - // persisted session metadata here: the native task removes its - // active-run entry immediately after emitting this event, so a - // concurrent status snapshot could otherwise resurrect the run. - void refreshSessionList().catch(() => {}); - const thoughtRunFinished = handleAgentModeThoughtRunFinished({ - event, - timelineRevision: finishedTimelineRevision, - tracker: thoughtPhaseTrackerRef.current, - finalizePhase: completeThoughtPhase, - releaseProvisional: (phase) => { - thoughtLabelProvisionalSchedulerRef.current?.complete(phase.sessionId, phase.phaseId); - }, - cancelAndInvalidateLabels: (sessionId, assistantTurnId) => { - if (assistantTurnId) { - invalidateThoughtLabelsForTurn(sessionId, assistantTurnId); - } else { - invalidateThoughtLabelsForSession(sessionId); - } - }, - loadTimeline: async (sessionId) => - (await agentRuntimeService.loadSession(userId, sessionId)).timeline, - canApplyTimeline: (sessionId) => - isAgentModeMountedRef.current && - userIdRef.current === userId && - !deletedSessionIdsRef.current.has(sessionId), - replaceTimeline: replaceSessionTimeline - }); - if (thoughtRunFinished) { - if (event.message === "completed" && event.sessionId !== activeSessionIdRef.current) { - markCompletedUnreadSession(event.sessionId!); - } - void thoughtRunFinished.catch(() => {}); + if ( + event.sessionId && + event.runId && + (event.message === "completed" || + event.message === "cancelled" || + event.message === "failed") + ) { + settleFinishedAgentRun(event.sessionId, event.runId, event.message); } break; } @@ -2567,8 +3569,15 @@ export function AgentMode({ userId }: { userId: string }) { } } if (event.item && event.sessionId) { - observeLiveThoughtItem(event.sessionId, event.item); - mergeSessionTimelineItem(event.sessionId, event.item); + const mergeResult = mergeSessionTimelineItem(event.sessionId, event.item); + if (mergeResult === "applied") { + observeLiveThoughtItem(event.sessionId, event.item); + } else { + setError( + "Agent live history exceeded its safe cache window. Reconnect to reload this task." + ); + reconcileAgentEventGap(event.sessionId); + } } break; case "historyReplaced": @@ -2586,7 +3595,8 @@ export function AgentMode({ userId }: { userId: string }) { } const historyTimelineRevision = bumpTimelineRevision(id); try { - const detail = await agentRuntimeService.loadSession(userId, id); + historyPaginationCacheRef.current.invalidate(id); + const timeline = await loadHistoryHead(id); if ( !isAgentModeMountedRef.current || userIdRef.current !== userId || @@ -2594,9 +3604,9 @@ export function AgentMode({ userId }: { userId: string }) { ) { return; } - const replaced = replaceSessionTimeline(id, detail.timeline, historyTimelineRevision); + const replaced = replaceSessionTimeline(id, timeline, historyTimelineRevision); if (replaced && activeRunsBySessionRef.current[id]) { - thoughtPhaseTrackerRef.current.seedActiveTimeline(id, detail.timeline); + thoughtPhaseTrackerRef.current.seedActiveTimeline(id, timeline); observeActiveThoughtPhase(id); } } catch (historyError) { @@ -2615,34 +3625,176 @@ export function AgentMode({ userId }: { userId: string }) { [ applyRuntimeStatus, bumpTimelineRevision, - clearActiveRun, clearCompletedUnreadSession, - completeThoughtPhase, deletedSessionIdsRef, invalidateThoughtLabelsForSession, invalidateThoughtLabelsForTurn, - markCompletedUnreadSession, + historyPaginationCacheRef, + loadHistoryHead, mergeSessionTimelineItem, observeLiveThoughtItem, observeActiveThoughtPhase, - refreshSessionList, recordActiveRun, refreshSessionMcpServers, + reconcileAgentEventGap, replaceSessionTimeline, + settleFinishedAgentRun, + terminalRunIdsRef, + thoughtPhaseTrackerRef, + upsertSessionSummary, + userId + ] + ); + + const handleAgentLiveChannelFrame = useCallback( + (frame: AgentLiveChannelFrame) => { + if (frame.eventType === "snapshotRequired") { + historyPaginationCacheRef.current.requireSynchronizedReload(); + reconcileAgentEventGap(null); + return; + } + + // The closed stream is account-wide. Consume ordering before deciding + // whether this session is selected, deleted, or has a visible mutation. + const acceptance = historyPaginationCacheRef.current.acceptEvent(frame); + if (acceptance === "duplicate" || acceptance === "invalid") return; + if (acceptance === "gap") { + reconcileAgentEventGap(frame.sessionId); + return; + } + if ( + frame.eventType !== "sessionDeleted" && + deletedSessionIdsRef.current.has(frame.sessionId) + ) { + return; + } + + switch (frame.eventType) { + case "runStarted": + // runStarted is lifecycle only. The durable stream publishes a + // distinct timelineCleared event when the overlay is obsolete. + runStateGenerationRef.current += 1; + if (!terminalRunIdsRef.current.has(frame.runId)) { + clearCompletedUnreadSession(frame.sessionId); + recordActiveRun(frame.sessionId, frame.runId); + } + break; + case "timelineUpsert": + case "userFacingError": + if (mergeSessionTimelineItem(frame.sessionId, frame.item) === "applied") { + observeLiveThoughtItem(frame.sessionId, frame.item); + } else { + setError( + "Agent live history exceeded its safe cache window. Reconnect to reload this task." + ); + reconcileAgentEventGap(frame.sessionId); + } + break; + case "timelineCleared": { + historyPaginationCacheRef.current.clearLiveTimeline(frame.sessionId); + const invalidatedTurnId = thoughtPhaseTrackerRef.current.resetForHistoryReplacement( + frame.sessionId + ); + if (invalidatedTurnId) { + invalidateThoughtLabelsForTurn(frame.sessionId, invalidatedTurnId); + } else { + invalidateThoughtLabelsForSession(frame.sessionId); + } + bumpTimelineRevision(frame.sessionId); + publishHistorySnapshot(frame.sessionId); + break; + } + case "historyReplaced": + void (async () => { + const invalidatedTurnId = thoughtPhaseTrackerRef.current.resetForHistoryReplacement( + frame.sessionId + ); + if (invalidatedTurnId) { + invalidateThoughtLabelsForTurn(frame.sessionId, invalidatedTurnId); + } else { + invalidateThoughtLabelsForSession(frame.sessionId); + } + const historyTimelineRevision = bumpTimelineRevision(frame.sessionId); + try { + historyPaginationCacheRef.current.invalidate(frame.sessionId); + const timeline = await loadHistoryHead(frame.sessionId); + if ( + !isAgentModeMountedRef.current || + userIdRef.current !== userId || + deletedSessionIdsRef.current.has(frame.sessionId) + ) { + return; + } + const replaced = replaceSessionTimeline( + frame.sessionId, + timeline, + historyTimelineRevision + ); + if (replaced && activeRunsBySessionRef.current[frame.sessionId]) { + thoughtPhaseTrackerRef.current.seedActiveTimeline(frame.sessionId, timeline); + observeActiveThoughtPhase(frame.sessionId); + } + } catch (historyError) { + if ( + isAgentModeMountedRef.current && + userIdRef.current === userId && + activeSessionIdRef.current === frame.sessionId + ) { + setError(errorMessage(historyError)); + } + } + })(); + break; + case "cursorAdvanced": + // Ordering-only storage acknowledgement; no presentation mutation. + break; + case "sessionUpdated": + upsertSessionSummary(frame.session); + break; + case "runFinished": + settleFinishedAgentRun(frame.sessionId, frame.runId, frame.terminal); + break; + case "sessionDeleted": + removeSessionFromState(frame.sessionId); + break; + default: { + const exhaustiveFrame: never = frame; + return exhaustiveFrame; + } + } + }, + [ + bumpTimelineRevision, + clearCompletedUnreadSession, + deletedSessionIdsRef, + historyPaginationCacheRef, + invalidateThoughtLabelsForSession, + invalidateThoughtLabelsForTurn, + loadHistoryHead, + mergeSessionTimelineItem, + observeActiveThoughtPhase, + observeLiveThoughtItem, + publishHistorySnapshot, + reconcileAgentEventGap, + recordActiveRun, + removeSessionFromState, + replaceSessionTimeline, + settleFinishedAgentRun, terminalRunIdsRef, - thoughtPhaseSeededRunIdsRef, thoughtPhaseTrackerRef, upsertSessionSummary, userId ] ); + liveChannelHandlerRef.current = handleAgentLiveChannelFrame; useEffect(() => { + if (agentRuntimeService.target.kind === "remote") return; let unlisten: (() => void) | null = null; let cancelled = false; void awaitAgentAuthUser(userId) .then(async () => { - return await agentRuntimeService.listenToEvents((event) => { + return await agentRuntimeService.listenToEvents(userId, (event) => { if (!cancelled) handleAgentEvent(event); }); }) @@ -2661,7 +3813,47 @@ export function AgentMode({ userId }: { userId: string }) { cancelled = true; unlisten?.(); }; - }, [handleAgentEvent, userId]); + }, [agentRuntimeService, handleAgentEvent, userId]); + + useEffect(() => { + if (agentRuntimeService.target.kind !== "remote") return; + const resume = () => { + if (document.visibilityState !== "visible" || liveConnectionsRef.current.hasPending) { + return; + } + const cursor = historyPaginationCacheRef.current.eventCursor(); + const sessionId = activeSessionIdRef.current; + if (!cursor || !sessionId) return; + const resumeOwnerKey = agentOwnerKey; + void resumeAgentLiveConnection().catch((resumeError) => { + if ( + resumeOwnerKey === agentOwnerKeyRef.current && + isAgentModeMountedRef.current && + userIdRef.current === userId + ) { + historyPaginationCacheRef.current.requireSynchronizedReload(); + reconcileAgentEventGap(sessionId); + if (!isAgentLiveSnapshotRequiredError(resumeError)) { + setError(errorMessage(resumeError)); + } + } + }); + }; + document.addEventListener("visibilitychange", resume); + window.addEventListener("online", resume); + return () => { + document.removeEventListener("visibilitychange", resume); + window.removeEventListener("online", resume); + }; + }, [ + agentOwnerKey, + agentRuntimeService.target.kind, + historyPaginationCacheRef, + liveConnectionsRef, + reconcileAgentEventGap, + resumeAgentLiveConnection, + userId + ]); const handleCreateSession = useCallback(() => { void createSession(projectRoot); @@ -2713,6 +3905,7 @@ export function AgentMode({ userId }: { userId: string }) { ); const handleRevealProjectRoot = useCallback( (path: string) => { + if (!localProjectFolderActionsAvailable) return; void revealAgentProjectFolder(path).catch((revealError) => { console.error("Unable to reveal Agent project folder", revealError); showNotification({ @@ -2723,7 +3916,7 @@ export function AgentMode({ userId }: { userId: string }) { }); }); }, - [showNotification] + [localProjectFolderActionsAvailable, showNotification] ); const handleSelectSession = useCallback( (sessionId: string) => { @@ -2741,7 +3934,7 @@ export function AgentMode({ userId }: { userId: string }) { setIsAgentFullscreen((current) => !current); }, []); - if (!isTauriDesktop()) { + if (!isTauriDesktop() && agentRuntimeService.target.kind !== "remote") { return (

@@ -2780,6 +3973,9 @@ export function AgentMode({ userId }: { userId: string }) { inProgressSessionIds={agentRunningSessionIds} runningSessionIds={runningSessionIds} sessions={visibleSessions} + hasMoreSessions={hasMoreSessions} + isLoadingOlderSessions={isLoadingOlderSessions} + localProjectFolderActionsAvailable={localProjectFolderActionsAvailable} onChooseProjectRoot={chooseProjectRoot} onCreateSession={handleCreateSessionForProject} onProjectDisclosureToggle={handleToggleProjectDisclosure} @@ -2790,6 +3986,7 @@ export function AgentMode({ userId }: { userId: string }) { onSessionDelete={setSessionToDelete} onSessionRename={handlePromptSessionRename} onSessionSelect={handleSelectSession} + onLoadOlderSessions={() => void loadOlderSessions()} /> } isNewItemTemporarilyDisabled={isTaskTransitionPending} @@ -2950,7 +4147,7 @@ export function AgentMode({ userId }: { userId: string }) {
)} - {timelineItems.length > 0 ? ( + {activeSessionId ? (
0 + activeSessionId ? "max-w-4xl p-4 md:p-6 landscape-short:p-2" : isAgentFullscreen ? "flex min-h-full max-w-6xl flex-col p-4 md:p-6 landscape-short:p-2" : "flex min-h-full flex-col px-4" )} > - {timelineItems.length === 0 ? ( + {!activeSessionId ? ( ) : ( - + <> + - {timelineItems.length > 0 ? ( + {activeSessionId ? (
; runningSessionIds: Set; sessions: AgentSessionSummary[]; + hasMoreSessions: boolean; + isLoadingOlderSessions: boolean; + localProjectFolderActionsAvailable: boolean; onChooseProjectRoot: () => void; onCreateSession: (projectRoot: string) => void; onProjectDisclosureToggle: (path: string) => void; @@ -3122,6 +4338,7 @@ interface AgentSidebarContentProps { onSessionDelete: (session: AgentSessionSummary) => void; onSessionRename: (session: AgentSessionSummary, menuTrigger: HTMLButtonElement) => void; onSessionSelect: (sessionId: string) => void; + onLoadOlderSessions: () => void; } interface PendingProjectPointer { @@ -3151,6 +4368,7 @@ interface AgentSidebarTaskRowProps { isRunning: boolean; isTouchLayout: boolean; isUnreadCompleted: boolean; + localProjectFolderActionsAvailable: boolean; onDelete: (session: AgentSessionSummary) => void; onRename: (session: AgentSessionSummary, menuTrigger: HTMLButtonElement) => void; onRevealProjectRoot: (projectRoot: string) => void; @@ -3167,6 +4385,7 @@ function AgentSidebarTaskRow({ isRunning, isTouchLayout, isUnreadCompleted, + localProjectFolderActionsAvailable, onDelete, onRename, onRevealProjectRoot, @@ -3286,7 +4505,11 @@ function AgentSidebarTaskRow({ metadata={projectDisplayName} metadataIcon={Folder} onDismiss={() => setInfoCardOpen(false)} - onOpenProjectFolder={() => onRevealProjectRoot(session.projectRoot)} + onOpenProjectFolder={ + localProjectFolderActionsAvailable + ? () => onRevealProjectRoot(session.projectRoot) + : undefined + } progressLabel={isInProgress ? "In progress" : "Not in progress"} title={title} /> @@ -3405,6 +4628,9 @@ function AgentSidebarContent({ inProgressSessionIds, runningSessionIds, sessions, + hasMoreSessions, + isLoadingOlderSessions, + localProjectFolderActionsAvailable, onChooseProjectRoot, onCreateSession, onProjectDisclosureToggle, @@ -3414,7 +4640,8 @@ function AgentSidebarContent({ onRevealProjectRoot, onSessionDelete, onSessionRename, - onSessionSelect + onSessionSelect, + onLoadOlderSessions }: AgentSidebarContentProps) { const rowElementsRef = useLazyRef(() => new Map()); const previousRowTopsRef = useLazyRef(() => new Map()); @@ -3813,29 +5040,37 @@ function AgentSidebarContent({

Projects

- + {localProjectFolderActionsAvailable ? ( + + ) : null}
{projectRows.length === 0 ? ( - + localProjectFolderActionsAvailable ? ( + + ) : ( +

+ No projects are available on this host. +

+ ) ) : (
{projectRows.map((root, rootIndex) => { @@ -3958,7 +5193,11 @@ function AgentSidebarContent({ )} metadataIcon={MessageSquare} onDismiss={() => setOpenProjectInfoCardPath(null)} - onOpenProjectFolder={() => onRevealProjectRoot(root.path)} + onOpenProjectFolder={ + localProjectFolderActionsAvailable + ? () => onRevealProjectRoot(root.path) + : undefined + } progressLabel={agentProjectProgressLabel(inProgressSessionCount)} title={root.displayName} /> @@ -4049,13 +5288,15 @@ function AgentSidebarContent({ /> Rename Project - onRevealProjectRoot(root.path)}> - - Open Project Folder - + {localProjectFolderActionsAvailable ? ( + onRevealProjectRoot(root.path)}> + + Open Project Folder + + ) : null} ) : null} + {hasMoreSessions ? ( + + ) : null} +

Tasks @@ -4189,6 +5445,7 @@ interface AgentComposerProps { isMcpLoading: boolean; isMcpToggleDisabled: boolean; isModelSelectionDisabled: boolean; + localProjectFolderActionsAvailable: boolean; mcpServers: AgentSessionMcpServer[]; mode: AgentPermissionMode; model: string; @@ -4219,6 +5476,7 @@ function AgentComposer({ isMcpLoading, isMcpToggleDisabled, isModelSelectionDisabled, + localProjectFolderActionsAvailable, mcpServers, mode, model, @@ -4313,31 +5571,45 @@ function AgentComposer({ onManage={onManageMcpServers} /> - { + if (value === NEW_PROJECT_OPTION_VALUE) { + onChooseProjectRoot(); + return; + } + onProjectRootChange(value); + }} + > + + + + + + {localProjectFolderActionsAvailable ? ( + <> + New project… + {rootOptions.length > 0 ? : null} + + ) : null} + {rootOptions.map((root) => ( + + {root.displayName} + + ))} + + + ) : ( + - - - - New project… - {rootOptions.length > 0 ? : null} - {rootOptions.map((root) => ( - - {root.displayName} - - ))} - - + No host projects + + )}

@@ -4763,8 +6035,10 @@ function AgentTimeline({ sessionId: string | null; onPermissionDecision: (item: AgentTimelineItem, decision: AgentPermissionDecision) => void; }) { + const assistantTurnKeyRegistryRef = useLazyRef(() => new AgentAssistantTurnKeyRegistry()); const visibleItems = coalesceAdjacentThinkingItems(items).filter(isRenderableAgentTimelineItem); const turns = groupAgentTimelineItems(visibleItems); + const assistantTurnKeys = assistantTurnKeyRegistryRef.current.resolve(sessionId, turns); const activeThinkingItemId = activeAgentThinkingItemId(visibleItems, isRunActive); const showAssistantLoader = shouldShowAgentAssistantLoader(turns, isResponsePending); const trailingTurn = turns[turns.length - 1]; @@ -4779,7 +6053,8 @@ function AgentTimeline({ if (turn.type === "user") { return ( : undefined} > @@ -4800,7 +6075,8 @@ function AgentTimeline({ return ( @@ -5067,34 +6343,6 @@ function ToolDetail({ label, value }: { label: string; value: string }) { ); } -function mergeTimelineItem( - current: AgentTimelineItem[], - incoming: AgentTimelineItem -): AgentTimelineItem[] { - const index = current.findIndex((item) => item.id === incoming.id); - if (index < 0) return [...current, incoming]; - - const next = [...current]; - const previous = next[index]; - const appendText = - incoming.merge === "append" && - (incoming.itemType === "message" || incoming.itemType === "thinking") && - incoming.text; - - next[index] = { - ...previous, - ...incoming, - title: incoming.title ?? previous.title, - input: incoming.input ?? previous.input, - output: incoming.output ?? previous.output, - text: appendText - ? `${previous.text || ""}${incoming.text || ""}` - : (incoming.text ?? previous.text) - }; - - return next; -} - function permissionRequestId(item: AgentTimelineItem): string { return item.id.startsWith("permission-") ? item.id.slice("permission-".length) : item.id; } diff --git a/frontend/src/components/agent/AgentSidebarInfoCard.test.tsx b/frontend/src/components/agent/AgentSidebarInfoCard.test.tsx index 42f70a023..848935430 100644 --- a/frontend/src/components/agent/AgentSidebarInfoCard.test.tsx +++ b/frontend/src/components/agent/AgentSidebarInfoCard.test.tsx @@ -83,4 +83,23 @@ describe("AgentSidebarInfoCard", () => { expect(onOpenProjectFolder).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(["dismiss", "open"]); }); + + test("shows a remote project path without an open-folder affordance", () => { + const markup = renderToStaticMarkup( + {}} + progressLabel="Not in progress" + title="Maple" + /> + ); + + expect(markup).toContain('aria-label="Project folder: /remote/workspace/maple"'); + expect(markup).not.toContain("Open project folder"); + expect(markup).not.toContain(" void; - onOpenProjectFolder: () => void; + onOpenProjectFolder?: () => void; progressLabel: string; title: string; } @@ -26,6 +26,19 @@ export function AgentSidebarInfoCard({ progressLabel, title }: AgentSidebarInfoCardProps) { + const folderPathContent = ( + <> +
= {} +): AgentTimelineItem { + return { + id, + itemType: "message", + role: "assistant", + text, + createdMs: Number(id.replace(/\D/g, "")) || 0, + merge: "replace", + ...overrides + }; +} + +function record( + recordId: string, + items: AgentTimelineItem[] = [item(`item-${recordId}`, recordId)] +): AgentHistoryRecord { + return { + recordId, + role: "assistant", + createdMs: Number(recordId.replace(/\D/g, "")) || 0, + items + }; +} + +function page( + records: AgentHistoryRecord[], + nextCursor: string | null, + historyRevision = "history-1" +): AgentSessionRecordsPage { + return { records, nextCursor, historyRevision }; +} + +function ownedCache(accountId = "user", targetId = "local"): AgentHistoryPaginationCache { + return new AgentHistoryPaginationCache({ accountId, targetId }); +} + +function synchronizedSnapshot( + liveSessions: readonly { + sessionId: string; + liveItems: readonly AgentTimelineItem[]; + }[], + journalId: string, + sequence: number +): AgentSynchronizedLiveSnapshot { + return { + liveSessionsComplete: true, + liveSessionCount: liveSessions.length, + liveSessions, + throughEventCursor: { journalId, sequence } + }; +} + +function largeProjectionPage( + prefix: string, + recordCount: number, + text: string, + nextCursor: string | null +): AgentSessionRecordsPage { + return page( + Array.from({ length: recordCount }, (_, index) => + record(`${prefix}-record-${index}`, [item(`${prefix}-item-${index}`, text)]) + ), + nextCursor + ); +} + +function repeatedReplacementPage( + prefix: string, + recordCount: number, + text: string, + nextCursor: string | null +): AgentSessionRecordsPage { + return page( + Array.from({ length: recordCount }, (_, index) => + record(`${prefix}-record-${index}`, [item(`${prefix}-shared`, text)]) + ), + nextCursor + ); +} + +function boundedLiveItems(prefix: string, itemCount = 13): AgentTimelineItem[] { + const text = "l".repeat(160 * 1024); + return Array.from({ length: itemCount }, (_, index) => item(`${prefix}-live-${index}`, text)); +} + +describe("AgentHistoryPaginationCache", () => { + test("loads four record-count pages without splitting multi-item records", () => { + const cache = ownedCache(); + const sessionId = "session"; + + const head = cache.beginHead(sessionId); + expect( + cache.commit( + head, + page( + [record("r8", [item("i8a", "thinking"), item("i8b", "answer")]), record("r7")], + "after-r7" + ) + ) + ).toBe("applied"); + + for (const [newer, older, cursor] of [ + ["r6", "r5", "after-r5"], + ["r4", "r3", "after-r3"], + ["r2", "r1", null] + ] as const) { + const token = cache.beginOlder(sessionId); + expect(token?.cursor).toBe(token ? token.cursor : null); + expect(token).not.toBeNull(); + expect(cache.commit(token!, page([record(newer), record(older)], cursor))).toBe("applied"); + } + + const snapshot = cache.snapshot(sessionId); + expect(snapshot.records.map((entry) => entry.recordId)).toEqual([ + "r1", + "r2", + "r3", + "r4", + "r5", + "r6", + "r7", + "r8" + ]); + expect(snapshot.timeline.map((entry) => entry.id)).toEqual([ + "item-r1", + "item-r2", + "item-r3", + "item-r4", + "item-r5", + "item-r6", + "item-r7", + "i8a", + "i8b" + ]); + expect(snapshot.hasMore).toBe(false); + }); + + test("rejects repeated valid record appends before building an oversized projected string", () => { + const cache = ownedCache(); + const sessionId = "append-window"; + const token = cache.beginHead(sessionId); + const chunk = "x".repeat(Math.floor(MAX_AGENT_HISTORY_RECORD_PRESENTATION_BYTES / 2) + 1024); + const appendPage = page( + [ + record("append-2", [item("shared", chunk, { merge: "append" })]), + record("append-1", [item("shared", chunk, { merge: "append" })]) + ], + "older" + ); + + expect(() => cache.commit(token, appendPage)).toThrow(AgentHistoryProjectionLimitError); + expect(cache.snapshot(sessionId)).toMatchObject({ + records: [], + nextCursor: null, + headLoaded: false, + isLoading: true + }); + cache.fail(token); + }); + + test("rejects an aggregate page window atomically without advancing its cursor", () => { + const cache = ownedCache(); + const sessionId = "page-window"; + const token = cache.beginHead(sessionId); + const chunk = "p".repeat(Math.floor(MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES / 9) + 1024); + + expect(() => cache.commit(token, largeProjectionPage("page", 9, chunk, "older"))).toThrow( + AgentHistoryProjectionLimitError + ); + expect(cache.snapshot(sessionId)).toMatchObject({ + records: [], + nextCursor: null, + headLoaded: false, + isLoading: true + }); + cache.fail(token); + }); + + test("charges every retained row even when replacement items coalesce", () => { + const cache = ownedCache(); + const sessionId = "replacement-row-window"; + const token = cache.beginHead(sessionId); + const chunk = "r".repeat(Math.floor(MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES / 9) + 1024); + const repeatedReplacementRows = page( + Array.from({ length: 9 }, (_, index) => + record(`replacement-${index}`, [item("shared-replacement", chunk)]) + ), + "older" + ); + + expect(() => cache.commit(token, repeatedReplacementRows)).toThrow( + AgentHistoryProjectionLimitError + ); + expect(cache.snapshot(sessionId)).toMatchObject({ + records: [], + nextCursor: null, + headLoaded: false, + isLoading: true + }); + cache.fail(token); + }); + + test("bounds the retained session projection across otherwise valid pages", () => { + const cache = ownedCache(); + const sessionId = "session-window"; + const chunk = "s".repeat(Math.floor(MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES / 10)); + cache.commit(cache.beginHead(sessionId), largeProjectionPage("head", 9, chunk, "older-1")); + cache.commit(cache.beginOlder(sessionId)!, largeProjectionPage("older-1", 9, chunk, "older-2")); + const before = cache.snapshot(sessionId); + expect(before.records).toHaveLength(18); + const overflow = cache.beginOlder(sessionId)!; + + expect(() => cache.commit(overflow, largeProjectionPage("older-2", 2, chunk, null))).toThrow( + AgentHistoryProjectionLimitError + ); + expect(cache.snapshot(sessionId)).toMatchObject({ + records: before.records, + nextCursor: "older-2", + isLoading: true + }); + cache.fail(overflow); + expect(MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES).toBe( + 2 * MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES + ); + }); + + test("atomically rejects retained rows plus an existing live overlay over the session window", () => { + const cache = ownedCache(); + const sessionId = "combined-session-window"; + for (const liveItem of boundedLiveItems("combined")) { + expect(cache.mergeLiveItem(sessionId, liveItem)).toBe("applied"); + } + const retainedText = "h".repeat(900 * 1024); + cache.commit( + cache.beginHead(sessionId), + repeatedReplacementPage("combined-head", 8, retainedText, "older") + ); + const before = cache.snapshot(sessionId); + const overflow = cache.beginOlder(sessionId)!; + + expect(() => + cache.commit(overflow, repeatedReplacementPage("combined-older", 8, retainedText, null)) + ).toThrow(AgentHistoryProjectionLimitError); + expect(cache.snapshot(sessionId)).toMatchObject({ + records: before.records, + timeline: before.timeline, + nextCursor: "older", + headLoaded: true, + isLoading: true + }); + cache.fail(overflow); + }); + + test("evicts only the oldest inactive session before enforcing the account window", () => { + const cache = ownedCache(); + const chunk = "a".repeat(Math.floor(MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES / 10)); + for (const sessionId of ["a", "b", "c", "d", "e"]) { + cache.commit(cache.beginHead(sessionId), largeProjectionPage(sessionId, 9, chunk, null)); + } + + expect(cache.snapshot("a").records).toEqual([]); + for (const sessionId of ["b", "c", "d", "e"]) { + expect(cache.snapshot(sessionId).records).toHaveLength(9); + } + expect(MAX_AGENT_HISTORY_ACCOUNT_PROJECTION_BYTES).toBe( + 4 * MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES + ); + }); + + test("rejects the account window when every retained session is protected", () => { + const cache = ownedCache(); + const chunk = "z".repeat(Math.floor(MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES / 10)); + const protectedIds = new Set(); + for (const sessionId of ["protected-a", "protected-b", "protected-c", "protected-d"]) { + cache.commit(cache.beginHead(sessionId), largeProjectionPage(sessionId, 9, chunk, null)); + protectedIds.add(sessionId); + cache.reconcileRetention(protectedIds); + } + protectedIds.add("protected-e"); + cache.reconcileRetention(protectedIds); + const overflow = cache.beginHead("protected-e"); + + expect(() => + cache.commit(overflow, largeProjectionPage("protected-e", 9, chunk, null)) + ).toThrow(AgentHistoryProjectionLimitError); + for (const sessionId of ["protected-a", "protected-b", "protected-c", "protected-d"]) { + expect(cache.snapshot(sessionId).records).toHaveLength(9); + } + expect(cache.snapshot("protected-e").records).toEqual([]); + cache.fail(overflow); + }); + + test("does not partially evict inactive sessions when account admission still fails", () => { + const cache = ownedCache(); + const largeChunk = "q".repeat(Math.floor(MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES / 10)); + const protectedIds = new Set(); + for (const sessionId of ["atomic-a", "atomic-b", "atomic-c", "atomic-d"]) { + cache.commit(cache.beginHead(sessionId), largeProjectionPage(sessionId, 9, largeChunk, null)); + protectedIds.add(sessionId); + cache.reconcileRetention(protectedIds); + } + cache.commit( + cache.beginHead("atomic-evictable"), + largeProjectionPage("atomic-evictable", 1, "e".repeat(512 * 1024), null) + ); + const evictableBefore = cache.snapshot("atomic-evictable").records; + const overflow = cache.beginHead("atomic-target"); + + expect(() => + cache.commit(overflow, largeProjectionPage("atomic-target", 9, largeChunk, null)) + ).toThrow(AgentHistoryProjectionLimitError); + expect(cache.snapshot("atomic-evictable").records).toEqual(evictableBefore); + expect(cache.snapshot("atomic-target").records).toEqual([]); + cache.fail(overflow); + }); + + test("bounds empty retained session states without evicting active requests", () => { + const cache = ownedCache(); + const first = cache.beginHead("retained-0"); + cache.fail(first); + for (let index = 1; index <= MAX_AGENT_HISTORY_RETAINED_SESSIONS_PER_ACCOUNT; index += 1) { + const token = cache.beginHead(`retained-${index}`); + cache.fail(token); + } + + expect(cache.commit(first, page([], null))).toBe("stale"); + }); + + test("coalesces a tool request and response across record and page boundaries", () => { + const cache = ownedCache(); + const sessionId = "session"; + const toolRequest = item("tool-1", "", { + itemType: "tool", + title: "Read file", + status: "running", + input: { path: "README.md" } + }); + const toolResponse = item("tool-1", "done", { + itemType: "tool", + title: "Read file", + status: "completed", + output: "marker" + }); + + cache.commit(cache.beginHead(sessionId), page([record("response", [toolResponse])], "older")); + cache.commit(cache.beginOlder(sessionId)!, page([record("request", [toolRequest])], null)); + + expect(cache.snapshot(sessionId).timeline).toEqual([ + expect.objectContaining({ + id: "tool-1", + status: "completed", + input: { path: "README.md" }, + output: "marker", + text: "done" + }) + ]); + }); + + test("keeps a resolved live item authoritative over a persisted head refresh", () => { + const cache = ownedCache(); + const sessionId = "session"; + + cache.mergeLiveItem( + sessionId, + item("tool-1", "live result", { + itemType: "tool", + status: "completed", + output: "live" + }) + ); + cache.commit( + cache.beginHead(sessionId), + page( + [ + record("r1", [ + item("tool-1", "persisted pending", { + itemType: "tool", + status: "running" + }) + ]) + ], + null + ) + ); + + expect(cache.snapshot(sessionId).timeline).toEqual([ + expect.objectContaining({ + id: "tool-1", + status: "completed", + output: "live", + text: "live result" + }) + ]); + }); + + test("rejects the first A load after A-B-A starts a newer A request", () => { + const cache = ownedCache(); + const firstA = cache.beginHead("A"); + const onlyB = cache.beginHead("B"); + const secondA = cache.beginHead("A"); + + expect(cache.commit(firstA, page([record("stale-a")], null))).toBe("stale"); + expect(cache.commit(onlyB, page([record("b")], null))).toBe("applied"); + expect(cache.commit(secondA, page([record("fresh-a")], null))).toBe("applied"); + expect(cache.snapshot("A").records.map((entry) => entry.recordId)).toEqual(["fresh-a"]); + }); + + test("settles only the matching remote-head token when an early return is superseded", () => { + const cache = ownedCache(); + const obsolete = cache.beginHead("session"); + const replacement = cache.beginHead("session"); + + cache.fail(obsolete); + expect(cache.snapshot("session").isLoading).toBe(true); + cache.fail(replacement); + expect(cache.snapshot("session").isLoading).toBe(false); + }); + + test("invalidates an older page from a replaced history generation", () => { + const cache = ownedCache(); + const sessionId = "session"; + cache.commit(cache.beginHead(sessionId), page([record("r2")], "older", "history-1")); + const older = cache.beginOlder(sessionId)!; + + expect(cache.commit(older, page([record("r1")], null, "history-2"))).toBe("history-replaced"); + expect(cache.snapshot(sessionId)).toMatchObject({ + records: [], + timeline: [], + historyRevision: null, + headLoaded: false, + hasMore: false + }); + }); + + test("an explicit history replacement fences a late head and preserves the live suffix", () => { + const cache = ownedCache(); + const sessionId = "session"; + cache.mergeLiveItem(sessionId, item("live", "obsolete")); + const obsolete = cache.beginHead(sessionId); + + cache.invalidate(sessionId); + + expect(cache.commit(obsolete, page([record("obsolete")], null))).toBe("stale"); + expect(cache.snapshot(sessionId).timeline.map((entry) => entry.id)).toEqual(["live"]); + }); + + test("accumulates append deltas once when overlaying a persisted item", () => { + const cache = ownedCache(); + const sessionId = "session"; + cache.commit(cache.beginHead(sessionId), page([record("r1", [item("answer", "base")])], null)); + cache.mergeLiveItem(sessionId, item("answer", " one", { merge: "append" })); + cache.mergeLiveItem(sessionId, item("answer", " two", { merge: "append" })); + + expect(cache.snapshot(sessionId).timeline[0].text).toBe("base one two"); + }); + + test("applies a delta received before a compatibility head exactly once", () => { + const cache = ownedCache(); + const sessionId = "session"; + cache.mergeLiveItem(sessionId, item("answer", " delta", { merge: "append" })); + + cache.commit(cache.beginHead(sessionId), page([record("r1", [item("answer", "base")])], null)); + + expect(cache.snapshot(sessionId).timeline[0].text).toBe("base delta"); + }); + + test("fails closed when a later persisted base makes a retained delta exceed the item budget", () => { + const cache = ownedCache(); + const halfBudget = "x".repeat(MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ITEM / 2 + 1); + expect(cache.mergeLiveItem("session", item("answer", halfBudget, { merge: "append" }))).toBe( + "applied" + ); + + cache.commit( + cache.beginHead("session"), + page([record("r1", [item("answer", halfBudget)])], null) + ); + + expect(cache.snapshot("session")).toMatchObject({ + requiresSynchronizedReload: true, + timeline: [{ id: "answer", text: halfBudget }] + }); + }); + + test("preserves legitimate repeated append deltas", () => { + const cache = ownedCache(); + cache.mergeLiveItem("session", item("answer", "ha", { merge: "append" })); + cache.mergeLiveItem("session", item("answer", "ha", { merge: "append" })); + + expect(cache.snapshot("session").timeline[0].text).toBe("haha"); + }); + + test("caches an ID-indexed long-history projection across a token stream", () => { + const cache = ownedCache(); + const persisted = Array.from({ length: 1_500 }, (_, index) => + record(`history-${index}`, [item(`history-item-${index}`, `${index}`)]) + ); + cache.commit(cache.beginHead("session"), page(persisted, null)); + const initial = cache.snapshot("session").timeline; + expect(cache.snapshot("session").timeline).toBe(initial); + + for (let index = 0; index < 256; index += 1) { + expect( + cache.mergeLiveItem("session", item("streaming-answer", "x", { merge: "append" })) + ).toBe("applied"); + const projected = cache.snapshot("session").timeline; + expect(cache.snapshot("session").timeline).toBe(projected); + } + + const projected = cache.snapshot("session").timeline; + expect(projected).toHaveLength(persisted.length + 1); + expect(projected.at(-1)?.text).toBe("x".repeat(256)); + }); + + test("fails closed before a cumulative multibyte append exceeds its item byte budget", () => { + const cache = ownedCache(); + const chunk = "🪿".repeat(16_000); + for (let index = 0; index < 3; index += 1) { + expect(cache.mergeLiveItem("session", item("answer", chunk, { merge: "append" }))).toBe( + "applied" + ); + } + const retainedText = cache.snapshot("session").timeline[0].text ?? ""; + + expect(cache.mergeLiveItem("session", item("answer", chunk, { merge: "append" }))).toBe( + "synchronized-reload-required" + ); + expect(cache.snapshot("session").timeline[0].text).toBe(retainedText); + expect(new TextEncoder().encode(retainedText).byteLength).toBeLessThanOrEqual( + MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ITEM + ); + expect(cache.snapshot("session").requiresSynchronizedReload).toBe(true); + }); + + test("enforces cumulative live projection budgets per session and account", () => { + const nearItemLimitText = "x".repeat(MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ITEM - 1_024); + const sessionCache = ownedCache(); + let sessionApplied = 0; + while ( + sessionCache.mergeLiveItem( + "session", + item(`session-byte-${sessionApplied}`, nearItemLimitText) + ) === "applied" + ) { + sessionApplied += 1; + } + expect(sessionApplied).toBeGreaterThan(40); + expect(sessionCache.snapshot("session").requiresSynchronizedReload).toBe(true); + expect(MAX_AGENT_LIVE_PROJECTION_BYTES_PER_SESSION).toBe( + MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ACCOUNT + ); + + const accountCache = ownedCache(); + let accountApplied = 0; + while ( + accountCache.mergeLiveItem( + `account-session-${accountApplied % 2}`, + item(`account-byte-${accountApplied}`, nearItemLimitText) + ) === "applied" + ) { + accountApplied += 1; + } + expect(accountApplied).toBe(sessionApplied); + expect(accountCache.snapshot("account-session-0").requiresSynchronizedReload).toBe(true); + }); + + test("rejects duplicate and out-of-order sequenced events without deduping compatibility events", () => { + const cache = ownedCache(); + expect(cache.acceptEvent({})).toBe("accepted"); + expect(cache.acceptEvent({})).toBe("accepted"); + expect(cache.acceptEvent({ eventEpoch: "epoch-1", eventSequence: 1 })).toBe("accepted"); + expect(cache.acceptEvent({ eventEpoch: "epoch-1", eventSequence: 1 })).toBe("duplicate"); + expect(cache.acceptEvent({ eventEpoch: "epoch-1", eventSequence: 0 })).toBe("duplicate"); + expect(cache.acceptEvent({ eventEpoch: "epoch-1", eventSequence: 3 })).toBe("gap"); + expect(cache.acceptEvent({ eventEpoch: "epoch-1", eventSequence: 2 })).toBe("accepted"); + expect(cache.acceptEvent({ eventEpoch: "epoch-2", eventSequence: 1 })).toBe("gap"); + }); + + test("tracks the account-wide journal watermark across interleaved sessions", () => { + const cache = ownedCache(); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 1 })).toBe("accepted"); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 2 })).toBe("accepted"); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 3 })).toBe("accepted"); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 2 })).toBe("duplicate"); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 5 })).toBe("gap"); + }); + + test("requires sequence one or a trusted checkpoint for a new journal epoch", () => { + const cache = ownedCache(); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 4 })).toBe("gap"); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 1 })).toBe("accepted"); + cache.installEventCheckpoint({ journalId: "replacement", sequence: 40 }); + expect(cache.acceptEvent({ eventEpoch: "replacement", eventSequence: 40 })).toBe("duplicate"); + expect(cache.acceptEvent({ eventEpoch: "replacement", eventSequence: 41 })).toBe("accepted"); + }); + + test("history revision rebases committed records without dropping current live state", () => { + const cache = ownedCache(); + cache.commit(cache.beginHead("session"), page([record("old")], null, "history-1")); + cache.mergeLiveItem("session", item("live", "current")); + + cache.commit(cache.beginHead("session"), page([record("new")], null, "history-2")); + + expect(cache.snapshot("session").records.map((entry) => entry.recordId)).toEqual(["new"]); + expect(cache.snapshot("session").timeline.map((entry) => entry.id)).toEqual([ + "item-new", + "live" + ]); + expect(cache.acceptEvent({ eventEpoch: "epoch-1", eventSequence: 1 })).toBe("accepted"); + }); + + test("installs an authoritative live snapshot and atomic journal checkpoint", () => { + const cache = ownedCache(); + cache.mergeLiveItem("session", item("current-live", "current")); + const head = cache.beginHead("session"); + cache.installSynchronizedAccountHead( + head, + page([record("persisted")], null), + synchronizedSnapshot( + [{ sessionId: "session", liveItems: [item("current-live", "authoritative")] }], + "journal", + 12 + ) + ); + + expect(cache.snapshot("session").timeline.map((entry) => entry.id)).toEqual([ + "item-persisted", + "current-live" + ]); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 12 })).toBe("duplicate"); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 13 })).toBe("accepted"); + }); + + test("an authoritative terminal head clears stale live overlays", () => { + const cache = ownedCache(); + cache.mergeLiveItem("session", item("answer", "delta", { merge: "append" })); + cache.installSynchronizedAccountHead( + cache.beginHead("session"), + page([record("terminal", [item("answer", "complete")])], null), + synchronizedSnapshot([], "journal", 2) + ); + + expect(cache.snapshot("session").timeline).toEqual([ + expect.objectContaining({ id: "answer", text: "complete" }) + ]); + }); + + test("splices an authoritative live suffix at its shared user boundary", () => { + const cache = ownedCache(); + cache.installSynchronizedAccountHead( + cache.beginHead("session"), + page( + [ + record("current", [ + item("current-user", "Current turn", { role: "user" }), + item("persisted-thought", "provider-history thought", { itemType: "thinking" }) + ]), + record("prior", [ + item("prior-user", "Earlier turn", { role: "user" }), + item("prior-answer", "Earlier answer") + ]) + ], + null + ), + synchronizedSnapshot( + [ + { + sessionId: "session", + liveItems: [ + item("current-user", "Current turn", { role: "user" }), + item("live-thought", "authoritative thought", { itemType: "thinking" }) + ] + } + ], + "journal", + 4 + ) + ); + + expect(cache.snapshot("session").timeline.map((entry) => entry.id)).toEqual([ + "prior-user", + "prior-answer", + "current-user", + "live-thought" + ]); + }); + + test("scans past unmatched live users to the first shared persisted boundary", () => { + const cache = ownedCache(); + cache.installSynchronizedAccountHead( + cache.beginHead("session"), + page( + [ + record("current", [ + item("shared-user", "Shared turn", { role: "user" }), + item("stale-provider-thought", "stale", { itemType: "thinking" }) + ]), + record("prior", [item("prior-answer", "Earlier answer")]) + ], + null + ), + synchronizedSnapshot( + [ + { + sessionId: "session", + liveItems: [ + item("unmatched-user", "Live-only turn", { role: "user" }), + item("shared-user", "Shared turn", { role: "user" }), + item("live-thought", "authoritative", { itemType: "thinking" }) + ] + } + ], + "journal", + 5 + ) + ); + + expect(cache.snapshot("session").timeline.map((entry) => entry.id)).toEqual([ + "prior-answer", + "unmatched-user", + "shared-user", + "live-thought" + ]); + }); + + test("a synchronized restart removes stale live-only deltas without guessing a boundary", () => { + const cache = ownedCache(); + cache.mergeLiveItem("session", item("stale-delta", "stale", { merge: "append" })); + + cache.installSynchronizedAccountHead( + cache.beginHead("session"), + page([record("persisted")], null), + synchronizedSnapshot( + [{ sessionId: "session", liveItems: [item("new-user", "New turn", { role: "user" })] }], + "journal", + 6 + ) + ); + + expect(cache.snapshot("session").timeline.map((entry) => entry.id)).toEqual([ + "item-persisted", + "new-user" + ]); + }); + + test("does not infer a journal checkpoint from an ordinary page commit", () => { + const cache = ownedCache(); + cache.mergeLiveItem("session", item("live", "compatibility live")); + cache.commit(cache.beginHead("session"), page([record("persisted")], null)); + + expect(cache.snapshot("session").timeline.map((entry) => entry.id)).toEqual([ + "item-persisted", + "live" + ]); + expect(cache.acceptEvent({ eventEpoch: "untrusted", eventSequence: 21 })).toBe("gap"); + }); + + test("resets all paged and sequenced state only when the account-target owner changes", () => { + const cache = ownedCache("account-a", "target-a"); + cache.commit(cache.beginHead("session"), page([record("persisted")], null)); + expect(cache.acceptEvent({ eventEpoch: "journal-a", eventSequence: 1 })).toBe("accepted"); + + expect(cache.bindOwner({ accountId: "account-a", targetId: "target-a" })).toBe("unchanged"); + expect(cache.snapshot("session").records).toHaveLength(1); + expect(cache.acceptEvent({ eventEpoch: "journal-a", eventSequence: 2 })).toBe("accepted"); + + expect(cache.bindOwner({ accountId: "account-a", targetId: "target-b" })).toBe("reset"); + expect(cache.snapshot("session").records).toEqual([]); + expect(cache.acceptEvent({ eventEpoch: "journal-b", eventSequence: 1 })).toBe("accepted"); + }); + + test("never reactivates a retired journal epoch", () => { + const cache = ownedCache(); + expect(cache.acceptEvent({ eventEpoch: "journal-a", eventSequence: 1 })).toBe("accepted"); + expect(cache.acceptEvent({ eventEpoch: "journal-b", eventSequence: 1 })).toBe("gap"); + cache.installEventCheckpoint({ journalId: "journal-b", sequence: 0 }); + expect(cache.acceptEvent({ eventEpoch: "journal-b", eventSequence: 1 })).toBe("accepted"); + expect(cache.acceptEvent({ eventEpoch: "journal-a", eventSequence: 1 })).toBe("duplicate"); + }); + + test("only a trusted checkpoint can rotate an established journal", () => { + const cache = ownedCache(); + cache.installEventCheckpoint({ journalId: "journal-a", sequence: 10 }); + expect(cache.acceptEvent({ eventEpoch: "journal-b", eventSequence: 1 })).toBe("gap"); + cache.installEventCheckpoint({ journalId: "journal-b", sequence: 0 }); + expect(cache.acceptEvent({ eventEpoch: "journal-b", eventSequence: 1 })).toBe("accepted"); + + expect(cache.bindOwner({ accountId: "other", targetId: "local" })).toBe("reset"); + expect(cache.acceptEvent({ eventEpoch: "journal-c", eventSequence: 1 })).toBe("accepted"); + }); + + test("fences a synchronized head when an event arrives after the request begins", () => { + const cache = ownedCache(); + const token = cache.beginHead("session"); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 1 })).toBe("accepted"); + + expect( + cache.installSynchronizedAccountHead( + token, + page([record("stale")], null), + synchronizedSnapshot([], "journal", 0) + ) + ).toBe("stale"); + expect(cache.snapshot("session").records).toEqual([]); + }); + + test("preflights a regressing checkpoint before changing records or live state", () => { + const cache = ownedCache(); + cache.installEventCheckpoint({ journalId: "journal", sequence: 8 }); + cache.mergeLiveItem("session", item("live", "keep")); + const token = cache.beginHead("session"); + + expect(() => + cache.installSynchronizedAccountHead( + token, + page([record("must-not-commit")], null), + synchronizedSnapshot([], "journal", 7) + ) + ).toThrow("regress"); + expect(cache.snapshot("session").records).toEqual([]); + expect(cache.snapshot("session").timeline.map((entry) => entry.id)).toEqual(["live"]); + }); + + test("fences an old response across deletion and same-id reuse", () => { + const cache = ownedCache(); + const deletedRequest = cache.beginHead("same-id"); + + cache.remove("same-id"); + const replacementRequest = cache.beginHead("same-id"); + + expect(cache.commit(deletedRequest, page([record("deleted-owner")], null))).toBe("stale"); + expect(cache.commit(replacementRequest, page([record("replacement")], null))).toBe("applied"); + expect(cache.snapshot("same-id").records.map((entry) => entry.recordId)).toEqual([ + "replacement" + ]); + }); + + test("fences an old response across owner change and same-id reuse", () => { + const cache = ownedCache("account-a", "target-a"); + const previousOwnerRequest = cache.beginHead("same-id"); + + expect(cache.bindOwner({ accountId: "account-b", targetId: "target-b" })).toBe("reset"); + const nextOwnerRequest = cache.beginHead("same-id"); + + expect(cache.commit(previousOwnerRequest, page([record("account-a")], null))).toBe("stale"); + expect(cache.commit(nextOwnerRequest, page([record("account-b")], null))).toBe("applied"); + expect(cache.snapshot("same-id").records.map((entry) => entry.recordId)).toEqual(["account-b"]); + }); + + test("a late fail or snapshot cannot recreate a removed session", () => { + const cache = ownedCache(); + const request = cache.beginHead("removed"); + cache.remove("removed"); + + cache.fail(request); + expect(cache.snapshot("removed")).toMatchObject({ + records: [], + timeline: [], + headLoaded: false + }); + + const replacement = cache.beginHead("removed"); + expect(cache.commit(request, page([record("stale")], null))).toBe("stale"); + expect(cache.commit(replacement, page([record("fresh")], null))).toBe("applied"); + }); + + test("bounds each live suffix and fails closed until a synchronized reload", () => { + const cache = ownedCache(); + for (let index = 0; index < MAX_AGENT_LIVE_ITEMS_PER_SESSION; index += 1) { + expect(cache.mergeLiveItem("session", item(`live-${index}`, `${index}`))).toBe("applied"); + } + + expect(cache.mergeLiveItem("session", item("overflow", "overflow"))).toBe( + "synchronized-reload-required" + ); + expect(cache.mergeLiveItem("session", item("live-0", "replacement"))).toBe( + "synchronized-reload-required" + ); + expect(cache.snapshot("session")).toMatchObject({ + requiresSynchronizedReload: true + }); + expect(cache.snapshot("session").timeline).toHaveLength(MAX_AGENT_LIVE_ITEMS_PER_SESSION); + + cache.installSynchronizedAccountHead( + cache.beginHead("session"), + page([], null), + synchronizedSnapshot([], "journal", 0) + ); + expect(cache.snapshot("session")).toMatchObject({ + timeline: [], + requiresSynchronizedReload: false + }); + }); + + test("bounds live sessions and account-wide live items", () => { + const sessionBoundCache = ownedCache(); + for (let index = 0; index < MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT; index += 1) { + expect(sessionBoundCache.mergeLiveItem(`session-${index}`, item(`item-${index}`, "x"))).toBe( + "applied" + ); + } + expect(sessionBoundCache.mergeLiveItem("one-too-many", item("overflow", "x"))).toBe( + "synchronized-reload-required" + ); + + const itemBoundCache = ownedCache(); + let remaining = MAX_AGENT_LIVE_ITEMS_PER_ACCOUNT; + let sessionIndex = 0; + while (remaining > 0) { + const itemCount = Math.min(remaining, MAX_AGENT_LIVE_ITEMS_PER_SESSION); + for (let itemIndex = 0; itemIndex < itemCount; itemIndex += 1) { + expect( + itemBoundCache.mergeLiveItem( + `account-session-${sessionIndex}`, + item(`account-${sessionIndex}-${itemIndex}`, "x") + ) + ).toBe("applied"); + } + remaining -= itemCount; + sessionIndex += 1; + } + expect( + itemBoundCache.mergeLiveItem( + `account-session-${sessionIndex - 1}`, + item("account-overflow", "x") + ) + ).toBe("synchronized-reload-required"); + }); + + test("counts a pre-created empty state when it becomes the sixty-fifth live session", () => { + const cache = ownedCache(); + const precreated = cache.beginHead("precreated-empty"); + cache.fail(precreated); + for (let index = 0; index < MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT; index += 1) { + expect(cache.mergeLiveItem(`live-${index}`, item(`live-item-${index}`, "x"))).toBe("applied"); + } + + expect(cache.mergeLiveItem("precreated-empty", item("late-live", "x"))).toBe( + "synchronized-reload-required" + ); + expect(cache.snapshot("precreated-empty")).toMatchObject({ + timeline: [], + requiresSynchronizedReload: true + }); + }); + + test("bulk live seeding preflights capacity without applying a prefix", () => { + const cache = ownedCache(); + const oversized = Array.from({ length: MAX_AGENT_LIVE_ITEMS_PER_SESSION + 1 }, (_, index) => + item(`bulk-${index}`, "x") + ); + + expect(cache.seedLiveTimeline("session", oversized)).toBe("synchronized-reload-required"); + expect(cache.snapshot("session").timeline).toEqual([]); + }); + + test("a synchronized head preflights live bounds without mutating records or checkpoint", () => { + const cache = ownedCache(); + cache.commit(cache.beginHead("session"), page([record("existing")], null)); + const token = cache.beginHead("session"); + const oversized = Array.from({ length: MAX_AGENT_LIVE_ITEMS_PER_SESSION + 1 }, (_, index) => + item(`sync-${index}`, "x") + ); + + expect(() => + cache.installSynchronizedAccountHead( + token, + page([record("must-not-commit")], null), + synchronizedSnapshot([{ sessionId: "session", liveItems: oversized }], "journal", 9) + ) + ).toThrow("session limit"); + expect(cache.snapshot("session").records.map((entry) => entry.recordId)).toEqual(["existing"]); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 10 })).toBe("gap"); + }); + + test("a synchronized head rejects retained rows plus prospective live bytes before mutation", () => { + const cache = ownedCache(); + const retainedText = "s".repeat(900 * 1024); + cache.commit( + cache.beginHead("retained-live"), + repeatedReplacementPage("sync-retained-head", 8, retainedText, "older") + ); + cache.commit( + cache.beginOlder("retained-live")!, + repeatedReplacementPage("sync-retained-older", 8, retainedText, null) + ); + const retainedBefore = cache.snapshot("retained-live"); + const target = cache.beginHead("sync-target"); + + expect(() => + cache.installSynchronizedAccountHead( + target, + page([record("must-not-commit")], "must-not-advance"), + synchronizedSnapshot( + [{ sessionId: "retained-live", liveItems: boundedLiveItems("prospective") }], + "journal", + 9 + ) + ) + ).toThrow(AgentHistoryProjectionLimitError); + expect(cache.snapshot("retained-live")).toMatchObject({ + records: retainedBefore.records, + timeline: retainedBefore.timeline + }); + expect(cache.snapshot("sync-target")).toMatchObject({ + records: [], + nextCursor: null, + headLoaded: false, + isLoading: true + }); + expect(cache.eventCursor()).toBeNull(); + cache.fail(target); + }); + + test("a synchronized state-cap failure leaves target, live overlay, and checkpoint untouched", () => { + const cache = ownedCache(); + expect(cache.mergeLiveItem("preserved-live", item("preserved", "keep"))).toBe("applied"); + cache.commit( + cache.beginHead("eligible-but-insufficient"), + page([record("preserved-row")], null) + ); + let target: ReturnType | null = null; + for (let index = 0; index < MAX_AGENT_HISTORY_RETAINED_SESSIONS_PER_ACCOUNT - 2; index += 1) { + const token = cache.beginHead(`busy-${index}`); + if (index === 0) target = token; + } + expect(target).not.toBeNull(); + + expect(() => + cache.installSynchronizedAccountHead( + target!, + page([record("must-not-commit")], "must-not-advance"), + synchronizedSnapshot( + [ + { sessionId: "missing-live-a", liveItems: [item("missing-item-a", "new-a")] }, + { sessionId: "missing-live-b", liveItems: [item("missing-item-b", "new-b")] } + ], + "journal", + 4 + ) + ) + ).toThrow(AgentHistoryProjectionLimitError); + expect(cache.snapshot("busy-0")).toMatchObject({ + records: [], + nextCursor: null, + headLoaded: false, + isLoading: true + }); + expect(cache.snapshot("preserved-live").timeline).toMatchObject([ + { id: "preserved", text: "keep" } + ]); + expect(cache.snapshot("eligible-but-insufficient").records).toMatchObject([ + { recordId: "preserved-row" } + ]); + expect(cache.snapshot("missing-live-a").timeline).toEqual([]); + expect(cache.snapshot("missing-live-b").timeline).toEqual([]); + expect(cache.eventCursor()).toBeNull(); + }); + + test("a synchronized state reservation evicts an eligible state before installing live data", () => { + const cache = ownedCache(); + cache.commit(cache.beginHead("evictable"), page([record("old")], null)); + let target: ReturnType | null = null; + for (let index = 0; index < MAX_AGENT_HISTORY_RETAINED_SESSIONS_PER_ACCOUNT - 1; index += 1) { + const token = cache.beginHead(`reserved-${index}`); + if (index === 0) target = token; + } + + expect( + cache.installSynchronizedAccountHead( + target!, + page([record("installed")], null), + synchronizedSnapshot( + [{ sessionId: "reserved-live", liveItems: [item("reserved-item", "live")] }], + "journal", + 5 + ) + ) + ).toBe("applied"); + expect(cache.snapshot("evictable")).toMatchObject({ records: [], headLoaded: false }); + expect(cache.snapshot("reserved-0").records.map((entry) => entry.recordId)).toEqual([ + "installed" + ]); + expect(cache.snapshot("reserved-live").timeline).toMatchObject([ + { id: "reserved-item", text: "live" } + ]); + expect(cache.eventCursor()).toEqual({ journalId: "journal", sequence: 5 }); + }); + + test("releases all inactive persisted scrollback while preserving protected and live state", () => { + const cache = ownedCache(); + cache.commit(cache.beginHead("inactive"), page([record("head")], "older")); + cache.commit(cache.beginOlder("inactive")!, page([record("old")], null)); + cache.commit(cache.beginHead("protected"), page([record("protected")], null)); + cache.mergeLiveItem("live-inactive", item("live", "keep")); + cache.commit(cache.beginHead("live-inactive"), page([record("live-persisted")], null)); + + const released = cache.reconcileRetention(new Set(["protected"])); + + expect([...released].sort()).toEqual(["inactive", "live-inactive"]); + expect(cache.snapshot("protected").records).toHaveLength(1); + expect(cache.snapshot("live-inactive").timeline.map((entry) => entry.id)).toEqual(["live"]); + expect(cache.snapshot("inactive").records).toEqual([]); + expect(cache.snapshot("inactive").headLoaded).toBe(false); + }); + + test("deleting one session does not cancel another session page", () => { + const cache = ownedCache(); + const deleted = cache.beginHead("deleted"); + const retained = cache.beginHead("retained"); + + cache.remove("deleted"); + + expect(cache.commit(deleted, page([record("deleted")], null))).toBe("stale"); + expect(cache.commit(retained, page([record("retained")], null))).toBe("applied"); + }); + + test("bounds retired journal epochs and treats ancient journals as a gap", () => { + const cache = ownedCache(); + cache.installEventCheckpoint({ journalId: "journal-0", sequence: 0 }); + for (let index = 1; index <= MAX_AGENT_RETIRED_EVENT_EPOCHS + 2; index += 1) { + cache.installEventCheckpoint({ journalId: `journal-${index}`, sequence: 0 }); + } + + expect(cache.acceptEvent({ eventEpoch: "journal-0", eventSequence: 1 })).toBe("gap"); + expect( + cache.acceptEvent({ + eventEpoch: `journal-${MAX_AGENT_RETIRED_EVENT_EPOCHS + 1}`, + eventSequence: 1 + }) + ).toBe("duplicate"); + }); + + test("a new run explicitly replaces the bounded compatibility live suffix", () => { + const cache = ownedCache(); + cache.mergeLiveItem("session", item("old-run", "old")); + + cache.startLiveSuffix("session"); + cache.mergeLiveItem("session", item("new-run", "new")); + + expect(cache.snapshot("session").timeline.map((entry) => entry.id)).toEqual(["new-run"]); + }); + + test("a run start cannot clear a synchronized-reload requirement", () => { + const cache = ownedCache(); + for (let index = 0; index < MAX_AGENT_LIVE_ITEMS_PER_SESSION; index += 1) { + cache.mergeLiveItem("session", item(`old-${index}`, "x")); + } + expect(cache.mergeLiveItem("session", item("overflow", "x"))).toBe( + "synchronized-reload-required" + ); + + cache.startLiveSuffix("session"); + + expect(cache.mergeLiveItem("session", item("new-run", "x"))).toBe( + "synchronized-reload-required" + ); + expect(cache.snapshot("session").requiresSynchronizedReload).toBe(true); + }); + + test("an explicit closed-stream clear replaces the live overlay without clearing account poison", () => { + const cache = ownedCache(); + expect(cache.mergeLiveItem("session", item("live", "transient"))).toBe("applied"); + cache.requireSynchronizedReload(); + + cache.clearLiveTimeline("session"); + + const snapshot = cache.snapshot("session"); + expect(snapshot.timeline).toEqual([]); + expect(snapshot.requiresSynchronizedReload).toBe(true); + expect(cache.mergeLiveItem("session", item("later", "must-recover"))).toBe( + "synchronized-reload-required" + ); + }); + + test("an untracked live-session overflow stays fail-closed after capacity frees", () => { + const cache = ownedCache(); + for (let index = 0; index < MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT; index += 1) { + cache.mergeLiveItem(`session-${index}`, item(`live-${index}`, "x")); + } + expect(cache.mergeLiveItem("overflow-session", item("lost", "x"))).toBe( + "synchronized-reload-required" + ); + + cache.remove("session-0"); + + expect(cache.mergeLiveItem("overflow-session", item("later", "x"))).toBe( + "synchronized-reload-required" + ); + }); + + test("an incomplete B snapshot cannot clear A overflow but one complete C0 snapshot can", () => { + const cache = ownedCache(); + cache.mergeLiveItem("B", item("b-live", "B")); + for (let index = 0; index < MAX_AGENT_LIVE_ITEMS_PER_SESSION; index += 1) { + cache.mergeLiveItem("A", item(`a-${index}`, "A")); + } + expect(cache.mergeLiveItem("A", item("a-overflow", "lost"))).toBe( + "synchronized-reload-required" + ); + cache.commit(cache.beginHead("B"), page([record("old-b")], null)); + const token = cache.beginHead("B"); + + expect(() => + cache.installSynchronizedAccountHead(token, page([record("new-b")], null), { + ...synchronizedSnapshot( + [{ sessionId: "B", liveItems: [item("b-live", "B authoritative")] }], + "journal", + 20 + ), + liveSessionsComplete: false + } as unknown as AgentSynchronizedLiveSnapshot) + ).toThrow("complete"); + expect(cache.snapshot("A").requiresSynchronizedReload).toBe(true); + expect(cache.snapshot("B").records.map((entry) => entry.recordId)).toEqual(["old-b"]); + + expect( + cache.installSynchronizedAccountHead( + token, + page([record("new-b")], null), + synchronizedSnapshot( + [ + { sessionId: "A", liveItems: [item("a-authoritative", "A authoritative")] }, + { sessionId: "B", liveItems: [item("b-live", "B authoritative")] } + ], + "journal", + 20 + ) + ) + ).toBe("applied"); + expect(cache.snapshot("A")).toMatchObject({ requiresSynchronizedReload: false }); + expect(cache.snapshot("A").timeline.map((entry) => entry.id)).toEqual(["a-authoritative"]); + expect(cache.snapshot("B").records.map((entry) => entry.recordId)).toEqual(["old-b", "new-b"]); + }); + + test("keeps identical timeline item IDs isolated by session", () => { + const cache = ownedCache(); + const token = cache.beginHead("A"); + + expect( + cache.installSynchronizedAccountHead( + token, + page([], null), + synchronizedSnapshot( + [ + { sessionId: "A", liveItems: [item("shared", "A")] }, + { sessionId: "B", liveItems: [item("shared", "B")] } + ], + "journal", + 20 + ) + ) + ).toBe("applied"); + expect(cache.snapshot("A").timeline).toMatchObject([{ id: "shared", text: "A" }]); + expect(cache.snapshot("B").timeline).toMatchObject([{ id: "shared", text: "B" }]); + + expect(cache.mergeLiveItem("B", item("shared", "!", { merge: "append" }))).toBe("applied"); + expect(cache.snapshot("A").timeline).toMatchObject([{ id: "shared", text: "A" }]); + expect(cache.snapshot("B").timeline).toMatchObject([{ id: "shared", text: "B!" }]); + }); + + test("validates synchronized session order by canonical UTF-8 bytes", () => { + const utf8Earlier = "\ue000"; + const utf8Later = "😀"; + const accepted = ownedCache(); + expect( + accepted.installSynchronizedAccountHead( + accepted.beginHead("head"), + page([], null), + synchronizedSnapshot( + [ + { sessionId: utf8Earlier, liveItems: [] }, + { sessionId: utf8Later, liveItems: [] } + ], + "journal", + 1 + ) + ) + ).toBe("applied"); + + const rejected = ownedCache(); + expect(() => + rejected.installSynchronizedAccountHead( + rejected.beginHead("head"), + page([], null), + synchronizedSnapshot( + [ + { sessionId: utf8Later, liveItems: [] }, + { sessionId: utf8Earlier, liveItems: [] } + ], + "journal", + 1 + ) + ) + ).toThrow("unique sorted IDs"); + }); + + test("rejects count, ordering, duplicate, and delta snapshot violations before mutation", () => { + const invalidSnapshots: AgentSynchronizedLiveSnapshot[] = [ + { + ...synchronizedSnapshot([], "journal", 3), + liveSessionCount: 1 + }, + synchronizedSnapshot( + [ + { sessionId: "B", liveItems: [] }, + { sessionId: "A", liveItems: [] } + ], + "journal", + 3 + ), + synchronizedSnapshot( + [ + { + sessionId: "A", + liveItems: [item("duplicate", "one"), item("duplicate", "two")] + } + ], + "journal", + 3 + ), + synchronizedSnapshot( + [ + { + sessionId: "A", + liveItems: [item("delta", "chunk", { merge: "append" })] + } + ], + "journal", + 3 + ) + ]; + + for (const snapshot of invalidSnapshots) { + const cache = ownedCache(); + cache.mergeLiveItem("A", item("keep-live", "keep")); + cache.commit(cache.beginHead("A"), page([record("keep-record")], null)); + const token = cache.beginHead("A"); + + expect(() => + cache.installSynchronizedAccountHead( + token, + page([record("must-not-commit")], null), + snapshot + ) + ).toThrow(); + expect(cache.snapshot("A").records.map((entry) => entry.recordId)).toEqual(["keep-record"]); + expect(cache.snapshot("A").timeline.map((entry) => entry.id)).toEqual([ + "item-keep-record", + "keep-live" + ]); + expect(cache.acceptEvent({ eventEpoch: "journal", eventSequence: 4 })).toBe("gap"); + } + }); +}); diff --git a/frontend/src/services/agentHistoryPagination.ts b/frontend/src/services/agentHistoryPagination.ts new file mode 100644 index 000000000..35b2ba0da --- /dev/null +++ b/frontend/src/services/agentHistoryPagination.ts @@ -0,0 +1,1402 @@ +import { + MAX_AGENT_HISTORY_RECORD_PRESENTATION_BYTES, + type AgentHistoryRecord, + type AgentSessionRecordsPage, + type AgentTimelineItem +} from "./agentRuntimeService"; + +export type AgentHistoryPageKind = "head" | "older"; + +export interface AgentHistoryPageToken { + readonly sessionId: string; + readonly kind: AgentHistoryPageKind; + readonly cursor: string | null; + readonly lifecycleGeneration: number; + readonly stateInstanceId: number; + readonly cacheEpoch: number; + readonly requestId: number; + readonly eventSequence: number | null; + readonly eventStateRevision: number; +} + +export interface AgentHistorySnapshot { + readonly records: readonly AgentHistoryRecord[]; + readonly timeline: readonly AgentTimelineItem[]; + readonly nextCursor: string | null; + readonly historyRevision: string | null; + readonly headLoaded: boolean; + readonly isLoading: boolean; + readonly hasMore: boolean; + readonly requiresSynchronizedReload: boolean; +} + +export type AgentHistoryCommitResult = "applied" | "stale" | "history-replaced"; +export type AgentEventAcceptance = "accepted" | "duplicate" | "gap" | "invalid"; +export type AgentHistoryOwnerBindResult = "unchanged" | "reset"; +export type AgentLiveMergeResult = "applied" | "synchronized-reload-required"; + +export interface AgentHistoryOwner { + readonly accountId: string; + readonly targetId: string; +} + +export interface AgentLiveSessionSnapshot { + readonly sessionId: string; + readonly liveItems: readonly AgentTimelineItem[]; +} + +export interface AgentSynchronizedLiveSnapshot { + readonly liveSessionsComplete: true; + readonly liveSessionCount: number; + readonly liveSessions: readonly AgentLiveSessionSnapshot[]; + readonly throughEventCursor: { readonly journalId: string; readonly sequence: number }; +} + +interface LiveTimelineItem { + item: AgentTimelineItem; + deltaOnly: boolean; + budgetBytes: number; + textBytes: number; +} + +interface SessionHistoryState { + stateInstanceId: number; + records: AgentHistoryRecord[]; + nextCursor: string | null; + historyRevision: string | null; + headLoaded: boolean; + cacheEpoch: number; + nextRequestId: number; + activeRequestId: number | null; + persistedProjectionBytes: number; + retentionOrdinal: number; + liveItemOrder: string[]; + liveItems: Map; + liveProjectionBytes: number; + projectedTimeline: AgentTimelineItem[]; + projectedIndexById: Map; + authoritativeLiveSuffix: boolean; + requiresSynchronizedReload: boolean; +} + +interface RetainedStateAdmissionPlan { + readonly requiredSessionIds: readonly string[]; + readonly evictSessionIds: readonly string[]; + readonly createSessionIds: readonly string[]; +} + +interface SynchronizedCommitAdmission { + readonly accountLiveProjectionBytes: number; + readonly protectedSessionIds: ReadonlySet; + readonly targetLiveProjectionBytes: number; + readonly targetLiveItemOrder: readonly string[]; + readonly targetLiveItems: ReadonlyMap; + readonly targetAuthoritativeLiveSuffix: boolean; +} + +export const MAX_AGENT_LIVE_ITEMS_PER_SESSION = 200; +export const MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT = 64; +export const MAX_AGENT_LIVE_ITEMS_PER_ACCOUNT = 512; +export const MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ITEM = 192 * 1024; +// Native currently permits one session to consume the whole bounded account +// projection. Track the session total independently without inventing a lower +// frontend-only ceiling that a valid synchronized snapshot could never load. +export const MAX_AGENT_LIVE_PROJECTION_BYTES_PER_SESSION = 8 * 1024 * 1024; +export const MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ACCOUNT = 8 * 1024 * 1024; +export const MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES = 8 * 1024 * 1024; +export const MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES = 16 * 1024 * 1024; +export const MAX_AGENT_HISTORY_ACCOUNT_PROJECTION_BYTES = 32 * 1024 * 1024; +export const MAX_AGENT_HISTORY_RETAINED_SESSIONS_PER_ACCOUNT = 128; +export const MAX_AGENT_RETIRED_EVENT_EPOCHS = 8; + +const AGENT_LIVE_ITEM_PROJECTION_OVERHEAD_BYTES = 256; +const AGENT_LIVE_UTF8_ENCODER = new TextEncoder(); + +const EMPTY_AGENT_HISTORY_SNAPSHOT: AgentHistorySnapshot = Object.freeze({ + records: Object.freeze([]), + timeline: Object.freeze([]), + nextCursor: null, + historyRevision: null, + headLoaded: false, + isLoading: false, + hasMore: false, + requiresSynchronizedReload: false +}); + +function newSessionHistoryState( + stateInstanceId: number, + retentionOrdinal: number +): SessionHistoryState { + return { + stateInstanceId, + records: [], + nextCursor: null, + historyRevision: null, + headLoaded: false, + cacheEpoch: 0, + nextRequestId: 0, + activeRequestId: null, + persistedProjectionBytes: 0, + retentionOrdinal, + liveItemOrder: [], + liveItems: new Map(), + liveProjectionBytes: 0, + projectedTimeline: [], + projectedIndexById: new Map(), + authoritativeLiveSuffix: false, + requiresSynchronizedReload: false + }; +} + +function utf8ByteLength(value: string | null | undefined): number { + return value ? AGENT_LIVE_UTF8_ENCODER.encode(value).byteLength : 0; +} + +function compareUtf8Bytes(left: string, right: string): number { + const leftBytes = AGENT_LIVE_UTF8_ENCODER.encode(left); + const rightBytes = AGENT_LIVE_UTF8_ENCODER.encode(right); + const commonLength = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < commonLength; index += 1) { + const difference = leftBytes[index] - rightBytes[index]; + if (difference !== 0) return difference; + } + return leftBytes.length - rightBytes.length; +} + +function timelineItemBudget(item: AgentTimelineItem): { + readonly budgetBytes: number; + readonly textBytes: number; +} { + const textBytes = utf8ByteLength(item.text); + return { + textBytes, + budgetBytes: + AGENT_LIVE_ITEM_PROJECTION_OVERHEAD_BYTES + + utf8ByteLength(item.id) + + utf8ByteLength(item.itemType) + + utf8ByteLength(item.role) + + utf8ByteLength(item.title) + + textBytes + + utf8ByteLength(item.status) + + utf8ByteLength(item.merge) + }; +} + +function mergedTimelineItemBudget( + previous: AgentTimelineItem, + previousTextBytes: number, + incoming: AgentTimelineItem +): { readonly budgetBytes: number; readonly textBytes: number } { + const appendText = + incoming.merge === "append" && + (incoming.itemType === "message" || incoming.itemType === "thinking") && + incoming.text !== undefined && + incoming.text !== null; + const textBytes = appendText + ? previousTextBytes + utf8ByteLength(incoming.text) + : utf8ByteLength(incoming.text ?? previous.text); + const mergedWithoutText = { + ...previous, + ...incoming, + title: incoming.title ?? previous.title, + text: undefined + }; + return { + textBytes, + budgetBytes: + AGENT_LIVE_ITEM_PROJECTION_OVERHEAD_BYTES + + utf8ByteLength(mergedWithoutText.id) + + utf8ByteLength(mergedWithoutText.itemType) + + utf8ByteLength(mergedWithoutText.role) + + utf8ByteLength(mergedWithoutText.title) + + textBytes + + utf8ByteLength(mergedWithoutText.status) + + utf8ByteLength(mergedWithoutText.merge) + }; +} + +function historyRecordBudgetBytes(record: AgentHistoryRecord): number { + let budgetBytes = 512 + utf8ByteLength(record.recordId) + utf8ByteLength(record.role); + for (const item of record.items) { + budgetBytes += timelineItemBudget(item).budgetBytes; + if (budgetBytes > MAX_AGENT_HISTORY_RECORD_PRESENTATION_BYTES) { + throw new AgentHistoryProjectionLimitError(); + } + } + return budgetBytes; +} + +function historyRecordsBudgetBytes( + records: readonly AgentHistoryRecord[], + maximumBytes: number +): number { + let budgetBytes = 0; + for (const record of records) { + budgetBytes += historyRecordBudgetBytes(record); + if (budgetBytes > maximumBytes) throw new AgentHistoryProjectionLimitError(); + } + return budgetBytes; +} + +function mergeTimelineItem( + previous: AgentTimelineItem, + incoming: AgentTimelineItem +): AgentTimelineItem { + const appendText = + incoming.merge === "append" && + (incoming.itemType === "message" || incoming.itemType === "thinking") && + incoming.text !== undefined && + incoming.text !== null; + + return { + ...previous, + ...incoming, + title: incoming.title ?? previous.title, + input: incoming.input ?? previous.input, + output: incoming.output ?? previous.output, + text: appendText + ? `${previous.text || ""}${incoming.text || ""}` + : (incoming.text ?? previous.text) + }; +} + +export function mergeAgentTimelineItems( + current: readonly AgentTimelineItem[], + incoming: AgentTimelineItem +): AgentTimelineItem[] { + const index = current.findIndex((item) => item.id === incoming.id); + if (index < 0) return [...current, incoming]; + + const next = [...current]; + next[index] = mergeTimelineItem(next[index], incoming); + return next; +} + +function mergeProjectedItem( + timeline: AgentTimelineItem[], + indexById: Map, + budgetById: Map, + totalBytes: number, + incoming: AgentTimelineItem, + maximumBytes: number +): number { + const index = indexById.get(incoming.id); + if (index === undefined) { + const budget = timelineItemBudget(incoming); + if ( + budget.budgetBytes > MAX_AGENT_HISTORY_RECORD_PRESENTATION_BYTES || + totalBytes + budget.budgetBytes > maximumBytes + ) { + throw new AgentHistoryProjectionLimitError(); + } + indexById.set(incoming.id, timeline.length); + timeline.push(incoming); + budgetById.set(incoming.id, budget); + return totalBytes + budget.budgetBytes; + } + const previous = timeline[index]; + const previousBudget = budgetById.get(incoming.id) ?? timelineItemBudget(previous); + const budget = mergedTimelineItemBudget(previous, previousBudget.textBytes, incoming); + const nextTotalBytes = totalBytes - previousBudget.budgetBytes + budget.budgetBytes; + if ( + budget.budgetBytes > MAX_AGENT_HISTORY_RECORD_PRESENTATION_BYTES || + nextTotalBytes > maximumBytes + ) { + throw new AgentHistoryProjectionLimitError(); + } + timeline[index] = mergeTimelineItem(previous, incoming); + budgetById.set(incoming.id, budget); + return nextTotalBytes; +} + +function projectedItems( + records: readonly AgentHistoryRecord[], + liveItemOrder: readonly string[], + liveItems: ReadonlyMap, + authoritativeLiveSuffix: boolean, + maximumBytes = MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES +): { timeline: AgentTimelineItem[]; indexById: Map; budgetBytes: number } { + let timeline: AgentTimelineItem[] = []; + let indexById = new Map(); + let budgetById = new Map(); + let budgetBytes = 0; + for (const record of records) { + for (const item of record.items) { + budgetBytes = mergeProjectedItem( + timeline, + indexById, + budgetById, + budgetBytes, + item, + maximumBytes + ); + } + } + if (authoritativeLiveSuffix) { + const suffix = liveItemOrder + .map((itemId) => liveItems.get(itemId)?.item) + .filter((item): item is AgentTimelineItem => item !== undefined); + for (const item of suffix) { + if (item.itemType !== "message" || item.role !== "user") continue; + const persistedBoundary = indexById.get(item.id); + if (persistedBoundary !== undefined) { + timeline = timeline.slice(0, persistedBoundary); + indexById = new Map(timeline.map((persistedItem, index) => [persistedItem.id, index])); + budgetById = new Map(timeline.map((item) => [item.id, timelineItemBudget(item)])); + budgetBytes = [...budgetById.values()].reduce( + (total, budget) => total + budget.budgetBytes, + 0 + ); + break; + } + } + for (const item of suffix) { + budgetBytes = mergeProjectedItem( + timeline, + indexById, + budgetById, + budgetBytes, + { ...item, merge: "replace" }, + maximumBytes + ); + } + return { timeline, indexById, budgetBytes }; + } + for (const itemId of liveItemOrder) { + const live = liveItems.get(itemId); + if (live) { + budgetBytes = mergeProjectedItem( + timeline, + indexById, + budgetById, + budgetBytes, + live.deltaOnly ? live.item : { ...live.item, merge: "replace" }, + maximumBytes + ); + } + } + return { timeline, indexById, budgetBytes }; +} + +function rebuildProjection(state: SessionHistoryState): void { + const projection = projectedItems( + state.records, + state.liveItemOrder, + state.liveItems, + state.authoritativeLiveSuffix + ); + state.projectedTimeline = projection.timeline; + state.projectedIndexById = projection.indexById; +} + +export class AgentHistoryProjectionLimitError extends Error { + constructor() { + super("Agent history projection exceeds the bounded frontend window"); + this.name = "AgentHistoryProjectionLimitError"; + } +} + +function persistedTimelineItem( + state: SessionHistoryState, + itemId: string +): AgentTimelineItem | undefined { + let persisted: AgentTimelineItem | undefined; + for (const record of state.records) { + for (const item of record.items) { + if (item.id !== itemId) continue; + persisted = persisted ? mergeTimelineItem(persisted, item) : item; + } + } + return persisted; +} + +function updateProjectedLiveItem( + state: SessionHistoryState, + live: LiveTimelineItem, + wasNewLiveItem: boolean +): void { + if ( + wasNewLiveItem && + state.authoritativeLiveSuffix && + live.item.itemType === "message" && + live.item.role === "user" && + state.projectedIndexById.has(live.item.id) + ) { + rebuildProjection(state); + return; + } + + const index = state.projectedIndexById.get(live.item.id); + const absoluteItem = live.deltaOnly ? live.item : { ...live.item, merge: "replace" }; + if (index === undefined) { + state.projectedIndexById.set(live.item.id, state.projectedTimeline.length); + state.projectedTimeline = [...state.projectedTimeline, absoluteItem]; + return; + } + const next = [...state.projectedTimeline]; + next[index] = { ...live.item, merge: "replace" }; + state.projectedTimeline = next; +} + +function uniqueRecords(records: readonly AgentHistoryRecord[]): AgentHistoryRecord[] { + const result: AgentHistoryRecord[] = []; + const indexById = new Map(); + for (const record of records) { + const existingIndex = indexById.get(record.recordId); + if (existingIndex === undefined) { + indexById.set(record.recordId, result.length); + result.push(record); + } else { + result[existingIndex] = record; + } + } + return result; +} + +function recordsInChronologicalOrder(page: AgentSessionRecordsPage): AgentHistoryRecord[] { + // The native page is newest-first. Reverse record containers as units so a + // record's internal projected-item ordering remains untouched. + return uniqueRecords([...page.records].reverse()); +} + +/** + * Account-owned instances keep bounded, per-session history projections. The + * cache never interprets native cursors and never turns projected timeline + * items into pagination units. + */ +export class AgentHistoryPaginationCache { + private readonly sessions = new Map(); + private owner: AgentHistoryOwner; + private lifecycleGeneration = 0; + private nextStateInstanceId = 0; + private nextRetentionOrdinal = 0; + private eventEpoch: string | null = null; + private eventSequence: number | null = null; + private eventStateRevision = 0; + private readonly retiredEventEpochs = new Set(); + private readonly retiredEventEpochOrder: string[] = []; + private accountRequiresSynchronizedReload = false; + private accountLiveProjectionBytes = 0; + private accountPersistedProjectionBytes = 0; + private protectedSessionIds = new Set(); + + constructor(owner: AgentHistoryOwner) { + this.owner = this.validOwner(owner); + } + + bindOwner(owner: AgentHistoryOwner): AgentHistoryOwnerBindResult { + const validOwner = this.validOwner(owner); + if ( + this.owner.accountId === validOwner.accountId && + this.owner.targetId === validOwner.targetId + ) { + return "unchanged"; + } + + this.owner = validOwner; + this.resetState(); + return "reset"; + } + + beginHead(sessionId: string): AgentHistoryPageToken { + return this.begin(sessionId, "head", null); + } + + beginOlder(sessionId: string): AgentHistoryPageToken | null { + const state = this.sessions.get(sessionId); + if (!state) return null; + if (!state.headLoaded || !state.nextCursor || state.activeRequestId !== null) return null; + return this.begin(sessionId, "older", state.nextCursor); + } + + commit(token: AgentHistoryPageToken, page: AgentSessionRecordsPage): AgentHistoryCommitResult { + return this.commitWithAdmission(token, page); + } + + private commitWithAdmission( + token: AgentHistoryPageToken, + page: AgentSessionRecordsPage, + admission?: SynchronizedCommitAdmission + ): AgentHistoryCommitResult { + if (token.lifecycleGeneration !== this.lifecycleGeneration) return "stale"; + const state = this.sessions.get(token.sessionId); + if (!state) return "stale"; + if (token.stateInstanceId !== state.stateInstanceId) return "stale"; + if (state.cacheEpoch !== token.cacheEpoch || state.activeRequestId !== token.requestId) { + return "stale"; + } + + const chronologicalPage = recordsInChronologicalOrder(page); + // A page remains an indivisible vector of native records. Preflight its + // complete retained presentation before touching records, cursors, or + // request state. Counting each row prevents repeated replacement items + // from collapsing into an artificially small page budget. + historyRecordsBudgetBytes(chronologicalPage, MAX_AGENT_HISTORY_PAGE_PROJECTION_BYTES); + let candidateRecords: AgentHistoryRecord[]; + let candidateHistoryRevision = state.historyRevision; + let candidateHeadLoaded = state.headLoaded; + let candidateNextCursor = state.nextCursor; + if (token.kind === "older") { + if (!state.historyRevision || state.historyRevision !== page.historyRevision) { + this.invalidate(token.sessionId); + return "history-replaced"; + } + const existingIds = new Set(state.records.map((record) => record.recordId)); + candidateRecords = [ + ...chronologicalPage.filter((record) => !existingIds.has(record.recordId)), + ...state.records + ]; + candidateNextCursor = page.nextCursor ?? null; + } else { + const isFirstHead = !state.headLoaded; + const revisionChanged = + state.historyRevision !== null && state.historyRevision !== page.historyRevision; + const retainedRecords = revisionChanged ? [] : state.records; + const headRecordIds = new Set(chronologicalPage.map((record) => record.recordId)); + candidateRecords = [ + ...retainedRecords.filter((record) => !headRecordIds.has(record.recordId)), + ...chronologicalPage + ]; + candidateHistoryRevision = page.historyRevision; + candidateHeadLoaded = true; + if (isFirstHead || revisionChanged) candidateNextCursor = page.nextCursor ?? null; + } + + const persistedProjectionBytes = historyRecordsBudgetBytes( + candidateRecords, + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES + ); + const targetLiveProjectionBytes = + admission?.targetLiveProjectionBytes ?? state.liveProjectionBytes; + if ( + persistedProjectionBytes + targetLiveProjectionBytes > + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES + ) { + throw new AgentHistoryProjectionLimitError(); + } + projectedItems( + candidateRecords, + [], + new Map(), + false, + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES + ); + projectedItems( + candidateRecords, + admission?.targetLiveItemOrder ?? state.liveItemOrder, + admission?.targetLiveItems ?? state.liveItems, + admission?.targetAuthoritativeLiveSuffix ?? state.authoritativeLiveSuffix, + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES + ); + this.admitPersistedProjection( + token.sessionId, + state, + persistedProjectionBytes, + admission?.accountLiveProjectionBytes ?? this.accountLiveProjectionBytes, + admission?.protectedSessionIds ?? this.protectedSessionIds + ); + + state.activeRequestId = null; + this.accountPersistedProjectionBytes += + persistedProjectionBytes - state.persistedProjectionBytes; + state.records = candidateRecords; + state.nextCursor = candidateNextCursor; + state.historyRevision = candidateHistoryRevision; + state.headLoaded = candidateHeadLoaded; + state.persistedProjectionBytes = persistedProjectionBytes; + this.touchState(state); + this.rebaseDeltaOnlyLiveItems(state); + rebuildProjection(state); + return "applied"; + } + + /** + * Trusted attach/head coordinator boundary. A plain page commit only mutates + * persisted records: it must never imply that an arbitrary page carried an + * atomic live-journal checkpoint. The coordinator calls this method for the + * ordered `head -> absolute live@C0 -> replay(C0, C1]` attach sequence. + */ + installSynchronizedAccountHead( + token: AgentHistoryPageToken, + page: AgentSessionRecordsPage, + snapshot: AgentSynchronizedLiveSnapshot + ): AgentHistoryCommitResult { + if (token.kind !== "head") { + throw new Error("A synchronized Agent history snapshot must be installed from a head page"); + } + // Any accepted event or competing checkpoint after this request began may + // be newer than the page's C0. Preserve it and require the coordinator to + // retry instead of regressing the account-wide watermark or live overlay. + if ( + token.eventStateRevision !== this.eventStateRevision || + token.eventSequence !== this.eventSequence + ) { + this.fail(token); + return "stale"; + } + const absoluteLiveSessions = this.validateSynchronizedLiveSnapshot(snapshot); + const synchronizedLiveSessionIds = new Set( + absoluteLiveSessions.map((liveSession) => liveSession.sessionId) + ); + this.assertCheckpointCanInstall(snapshot.throughEventCursor); + const stateBeforeCommit = this.sessions.get(token.sessionId); + if (!stateBeforeCommit || token.stateInstanceId !== stateBeforeCommit.stateInstanceId) { + return "stale"; + } + if ( + token.lifecycleGeneration !== this.lifecycleGeneration || + stateBeforeCommit.cacheEpoch !== token.cacheEpoch || + stateBeforeCommit.activeRequestId !== token.requestId + ) { + return "stale"; + } + const chronologicalPage = recordsInChronologicalOrder(page); + const revisionChanged = + stateBeforeCommit.historyRevision !== null && + stateBeforeCommit.historyRevision !== page.historyRevision; + const retainedRecords = revisionChanged ? [] : stateBeforeCommit.records; + const headRecordIds = new Set(chronologicalPage.map((record) => record.recordId)); + const candidateHeadRecords = [ + ...retainedRecords.filter((record) => !headRecordIds.has(record.recordId)), + ...chronologicalPage + ]; + let snapshotLiveProjectionBytes = 0; + let targetLiveProjectionBytes = 0; + let targetLiveItemOrder: readonly string[] = []; + let targetLiveItems: ReadonlyMap = new Map(); + for (const liveSession of absoluteLiveSessions) { + let sessionLiveProjectionBytes = 0; + const liveItems = new Map( + liveSession.liveItems.map((item) => { + const budget = timelineItemBudget(item); + sessionLiveProjectionBytes += budget.budgetBytes; + snapshotLiveProjectionBytes += budget.budgetBytes; + return [item.id, { item, deltaOnly: false, ...budget }] as const; + }) + ); + const records = + liveSession.sessionId === token.sessionId + ? candidateHeadRecords + : (this.sessions.get(liveSession.sessionId)?.records ?? []); + const retainedProjectionBytes = historyRecordsBudgetBytes( + records, + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES + ); + if ( + retainedProjectionBytes + sessionLiveProjectionBytes > + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES + ) { + throw new AgentHistoryProjectionLimitError(); + } + if (liveSession.sessionId === token.sessionId) { + targetLiveProjectionBytes = sessionLiveProjectionBytes; + targetLiveItemOrder = liveSession.liveItems.map((item) => item.id); + targetLiveItems = liveItems; + } + projectedItems( + records, + liveSession.liveItems.map((item) => item.id), + liveItems, + true, + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES + ); + } + const retainedStateAdmissionPlan = this.planRetainedStateAdmission( + synchronizedLiveSessionIds, + token.sessionId + ); + const result = this.commitWithAdmission(token, page, { + accountLiveProjectionBytes: snapshotLiveProjectionBytes, + protectedSessionIds: synchronizedLiveSessionIds, + targetLiveProjectionBytes, + targetLiveItemOrder, + targetLiveItems, + targetAuthoritativeLiveSuffix: true + }); + if (result !== "applied") return result; + + const synchronizedStates = this.applyRetainedStateAdmissionPlan(retainedStateAdmissionPlan); + + // The snapshot is explicitly complete for this account-target checkpoint: + // clear every cached overlay first, including sessions absent at C0. + for (const state of this.sessions.values()) { + state.liveItemOrder = []; + state.liveItems.clear(); + state.liveProjectionBytes = 0; + state.authoritativeLiveSuffix = true; + state.requiresSynchronizedReload = false; + } + this.accountLiveProjectionBytes = 0; + for (const liveSession of absoluteLiveSessions) { + const state = synchronizedStates.get(liveSession.sessionId)!; + for (const item of liveSession.liveItems) { + const budget = timelineItemBudget(item); + state.liveItemOrder.push(item.id); + state.liveItems.set(item.id, { item, deltaOnly: false, ...budget }); + state.liveProjectionBytes += budget.budgetBytes; + this.accountLiveProjectionBytes += budget.budgetBytes; + } + } + for (const state of this.sessions.values()) rebuildProjection(state); + this.applyEventCheckpoint(snapshot.throughEventCursor); + this.accountRequiresSynchronizedReload = false; + return "applied"; + } + + fail(token: AgentHistoryPageToken): void { + if (token.lifecycleGeneration !== this.lifecycleGeneration) return; + const state = this.sessions.get(token.sessionId); + if (!state) return; + if (token.stateInstanceId !== state.stateInstanceId) return; + if (state.cacheEpoch === token.cacheEpoch && state.activeRequestId === token.requestId) { + state.activeRequestId = null; + } + } + + mergeLiveItem(sessionId: string, incoming: AgentTimelineItem): AgentLiveMergeResult { + if (this.accountRequiresSynchronizedReload) return "synchronized-reload-required"; + let state = this.sessions.get(sessionId); + if (!state) { + if (this.liveSessionCount() >= MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT) { + this.accountRequiresSynchronizedReload = true; + return "synchronized-reload-required"; + } + state = this.createState(sessionId); + } + if (state.requiresSynchronizedReload) return "synchronized-reload-required"; + const current = state.liveItems.get(incoming.id); + if (!current) { + if ( + state.liveItems.size >= MAX_AGENT_LIVE_ITEMS_PER_SESSION || + this.liveItemCount() >= MAX_AGENT_LIVE_ITEMS_PER_ACCOUNT || + (state.liveItems.size === 0 && + this.liveSessionCount() >= MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT) + ) { + state.requiresSynchronizedReload = true; + this.accountRequiresSynchronizedReload = true; + return "synchronized-reload-required"; + } + } + + let persisted: AgentTimelineItem | undefined; + if (!current) { + persisted = persistedTimelineItem(state, incoming.id); + } + const previous = current?.item ?? persisted; + const previousTextBytes = + current?.textBytes ?? (persisted ? timelineItemBudget(persisted).textBytes : 0); + const budget = previous + ? mergedTimelineItemBudget(previous, previousTextBytes, incoming) + : timelineItemBudget(incoming); + const previousBudgetBytes = current?.budgetBytes ?? 0; + const nextSessionBytes = state.liveProjectionBytes - previousBudgetBytes + budget.budgetBytes; + const nextAccountBytes = + this.accountLiveProjectionBytes - previousBudgetBytes + budget.budgetBytes; + if ( + budget.textBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ITEM || + nextSessionBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_SESSION || + nextAccountBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ACCOUNT || + state.persistedProjectionBytes + nextSessionBytes > + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES || + this.accountPersistedProjectionBytes + nextAccountBytes > + MAX_AGENT_HISTORY_ACCOUNT_PROJECTION_BYTES + ) { + state.requiresSynchronizedReload = true; + this.accountRequiresSynchronizedReload = true; + return "synchronized-reload-required"; + } + + const live: LiveTimelineItem = { + item: previous ? mergeTimelineItem(previous, incoming) : incoming, + deltaOnly: current + ? current.deltaOnly && incoming.merge === "append" + : !persisted && incoming.merge === "append", + ...budget + }; + if (!current) state.liveItemOrder.push(incoming.id); + state.liveItems.set(incoming.id, live); + state.liveProjectionBytes = nextSessionBytes; + this.accountLiveProjectionBytes = nextAccountBytes; + updateProjectedLiveItem(state, live, !current); + return "applied"; + } + + acceptEvent(event: { + eventEpoch?: string | null; + eventSequence?: number | null; + }): AgentEventAcceptance { + const epoch = event.eventEpoch; + const sequence = event.eventSequence; + // Embedded events are temporarily unsequenced. Remote/journal events must + // provide both fields together before ordering claims are made. + if (epoch === undefined && sequence === undefined) { + this.eventStateRevision += 1; + return "accepted"; + } + if (!epoch || !Number.isSafeInteger(sequence) || (sequence as number) < 0) return "invalid"; + + if (this.eventEpoch !== epoch) { + if (this.retiredEventEpochs.has(epoch)) return "duplicate"; + // Once an account-target stream has an established journal, only the + // trusted attach/replay coordinator may rotate it. A live event cannot + // prove that every event from the replacement epoch was observed. + if (this.eventEpoch) return "gap"; + if (sequence !== 1) return "gap"; + this.eventEpoch = epoch; + this.eventSequence = sequence as number; + this.eventStateRevision += 1; + return "accepted"; + } + if (this.eventSequence !== null && (sequence as number) <= this.eventSequence) { + return "duplicate"; + } + if (this.eventSequence !== null && sequence !== this.eventSequence + 1) return "gap"; + this.eventSequence = sequence as number; + this.eventStateRevision += 1; + return "accepted"; + } + + installEventCheckpoint(cursor: { journalId: string; sequence: number }): void { + this.assertCheckpointCanInstall(cursor); + this.applyEventCheckpoint(cursor); + } + + eventCursor(): { journalId: string; sequence: number } | null { + return this.eventEpoch !== null && this.eventSequence !== null + ? { journalId: this.eventEpoch, sequence: this.eventSequence } + : null; + } + + requireSynchronizedReload(): void { + this.accountRequiresSynchronizedReload = true; + } + + private applyEventCheckpoint(cursor: { journalId: string; sequence: number }): void { + if (this.eventEpoch === cursor.journalId) { + if (this.eventSequence === cursor.sequence) return; + } else if (this.eventEpoch) { + this.retireEventEpoch(this.eventEpoch); + } + this.eventEpoch = cursor.journalId; + this.eventSequence = cursor.sequence; + this.eventStateRevision += 1; + } + + seedLiveTimeline(sessionId: string, items: readonly AgentTimelineItem[]): AgentLiveMergeResult { + if (this.accountRequiresSynchronizedReload) { + return "synchronized-reload-required"; + } + if (items.length === 0) return "applied"; + let state = this.sessions.get(sessionId); + const existingItemIds = new Set(state?.liveItems.keys() ?? []); + const additionalItemIds = new Set(); + for (const item of items) { + if (!existingItemIds.has(item.id)) additionalItemIds.add(item.id); + } + const nextSessionItemCount = (state?.liveItems.size ?? 0) + additionalItemIds.size; + const nextAccountItemCount = this.liveItemCount() + additionalItemIds.size; + const createsLiveSession = + !state || (state.liveItems.size === 0 && !state.requiresSynchronizedReload); + if ( + nextSessionItemCount > MAX_AGENT_LIVE_ITEMS_PER_SESSION || + nextAccountItemCount > MAX_AGENT_LIVE_ITEMS_PER_ACCOUNT || + (createsLiveSession && this.liveSessionCount() >= MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT) + ) { + if (state) state.requiresSynchronizedReload = true; + this.accountRequiresSynchronizedReload = true; + return "synchronized-reload-required"; + } + const plannedItems = new Map(state?.liveItems ?? []); + let plannedSessionBytes = state?.liveProjectionBytes ?? 0; + let plannedAccountBytes = this.accountLiveProjectionBytes; + for (const item of items) { + const current = plannedItems.get(item.id); + let persisted: AgentTimelineItem | undefined; + if (!current && state) { + persisted = persistedTimelineItem(state, item.id); + } + const previous = current?.item ?? persisted; + const previousTextBytes = + current?.textBytes ?? (persisted ? timelineItemBudget(persisted).textBytes : 0); + const budget = previous + ? mergedTimelineItemBudget(previous, previousTextBytes, item) + : timelineItemBudget(item); + const previousBudgetBytes = current?.budgetBytes ?? 0; + plannedSessionBytes += budget.budgetBytes - previousBudgetBytes; + plannedAccountBytes += budget.budgetBytes - previousBudgetBytes; + if ( + budget.textBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ITEM || + plannedSessionBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_SESSION || + plannedAccountBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ACCOUNT || + (state?.persistedProjectionBytes ?? 0) + plannedSessionBytes > + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES || + this.accountPersistedProjectionBytes + plannedAccountBytes > + MAX_AGENT_HISTORY_ACCOUNT_PROJECTION_BYTES + ) { + if (state) state.requiresSynchronizedReload = true; + this.accountRequiresSynchronizedReload = true; + return "synchronized-reload-required"; + } + plannedItems.set(item.id, { + item: previous ? mergeTimelineItem(previous, item) : item, + deltaOnly: current + ? current.deltaOnly && item.merge === "append" + : !persisted && item.merge === "append", + ...budget + }); + } + if (!state) state = this.createState(sessionId); + for (const item of items) { + if (this.mergeLiveItem(sessionId, item) !== "applied") { + throw new Error("Agent live seed changed after its capacity preflight"); + } + } + return "applied"; + } + + /** Legacy embedded compatibility: its old runStarted event implied a clear. */ + startLiveSuffix(sessionId: string): void { + const state = this.sessions.get(sessionId); + if (!state) return; + this.releaseLiveProjection(state); + state.authoritativeLiveSuffix = false; + rebuildProjection(state); + } + + /** + * Apply the closed stream's explicit timelineCleared mutation. The event is + * ordered but is not a synchronized account snapshot, so it must never clear + * an overflow/gap marker or advance/reset the account checkpoint itself. + */ + clearLiveTimeline(sessionId: string): void { + const state = this.sessions.get(sessionId); + if (!state) return; + this.releaseLiveProjection(state); + state.authoritativeLiveSuffix = true; + rebuildProjection(state); + } + + invalidate(sessionId: string): void { + const state = this.sessions.get(sessionId); + if (!state) return; + this.accountPersistedProjectionBytes -= state.persistedProjectionBytes; + state.persistedProjectionBytes = 0; + state.records = []; + state.nextCursor = null; + state.historyRevision = null; + state.headLoaded = false; + state.cacheEpoch += 1; + state.activeRequestId = null; + rebuildProjection(state); + } + + remove(sessionId: string): void { + const state = this.sessions.get(sessionId); + if (state) { + this.accountLiveProjectionBytes -= state.liveProjectionBytes; + this.accountPersistedProjectionBytes -= state.persistedProjectionBytes; + } + this.sessions.delete(sessionId); + this.protectedSessionIds.delete(sessionId); + } + + clear(): void { + this.resetState(); + } + + private resetState(): void { + this.sessions.clear(); + this.eventEpoch = null; + this.eventSequence = null; + this.retiredEventEpochs.clear(); + this.retiredEventEpochOrder.splice(0); + this.accountRequiresSynchronizedReload = false; + this.accountLiveProjectionBytes = 0; + this.accountPersistedProjectionBytes = 0; + this.protectedSessionIds.clear(); + this.eventStateRevision += 1; + this.bumpLifecycleGeneration(); + } + + snapshot(sessionId: string): AgentHistorySnapshot { + const state = this.sessions.get(sessionId); + if (!state) return EMPTY_AGENT_HISTORY_SNAPSHOT; + this.touchState(state); + return { + records: state.records, + timeline: state.projectedTimeline, + nextCursor: state.nextCursor, + historyRevision: state.historyRevision, + headLoaded: state.headLoaded, + isLoading: state.activeRequestId !== null, + hasMore: Boolean(state.nextCursor), + requiresSynchronizedReload: + this.accountRequiresSynchronizedReload || state.requiresSynchronizedReload + }; + } + + /** + * Only explicitly protected sessions retain arbitrary paged records. Every + * inactive projection releases persisted scrollback while preserving its + * bounded live suffix/reload marker and any request that is still in flight. + */ + reconcileRetention(protectedSessionIds: ReadonlySet): readonly string[] { + this.protectedSessionIds = new Set(protectedSessionIds); + const released: string[] = []; + for (const [sessionId, state] of this.sessions) { + if (protectedSessionIds.has(sessionId) || state.activeRequestId !== null) continue; + if (state.records.length === 0 && !state.headLoaded) continue; + this.releasePersistedProjection(state); + released.push(sessionId); + if (state.liveItems.size === 0 && !state.requiresSynchronizedReload) { + this.sessions.delete(sessionId); + } + } + return released; + } + + private begin( + sessionId: string, + kind: AgentHistoryPageKind, + cursor: string | null + ): AgentHistoryPageToken { + const state = this.ensureState(sessionId); + this.touchState(state); + state.nextRequestId += 1; + state.activeRequestId = state.nextRequestId; + return Object.freeze({ + sessionId, + kind, + cursor, + lifecycleGeneration: this.lifecycleGeneration, + stateInstanceId: state.stateInstanceId, + cacheEpoch: state.cacheEpoch, + requestId: state.nextRequestId, + eventSequence: this.eventSequence, + eventStateRevision: this.eventStateRevision + }); + } + + private validOwner(owner: AgentHistoryOwner): AgentHistoryOwner { + if (!owner.accountId || !owner.targetId) { + throw new Error("Agent history owner requires an account and execution target"); + } + return Object.freeze({ accountId: owner.accountId, targetId: owner.targetId }); + } + + private assertCheckpointCanInstall(cursor: { journalId: string; sequence: number }): void { + if (!cursor.journalId || !Number.isSafeInteger(cursor.sequence) || cursor.sequence < 0) { + throw new Error("Agent event checkpoint is invalid"); + } + if (this.retiredEventEpochs.has(cursor.journalId)) { + throw new Error("Agent event checkpoint belongs to a retired journal"); + } + if ( + this.eventEpoch === cursor.journalId && + this.eventSequence !== null && + cursor.sequence < this.eventSequence + ) { + throw new Error("Agent event checkpoint would regress the journal sequence"); + } + } + + private planRetainedStateAdmission( + requiredSessionIds: ReadonlySet, + excludedSessionId: string + ): RetainedStateAdmissionPlan { + const required = [...requiredSessionIds]; + const createSessionIds = required.filter((sessionId) => !this.sessions.has(sessionId)); + const requiredEvictions = Math.max( + 0, + this.sessions.size + createSessionIds.length - MAX_AGENT_HISTORY_RETAINED_SESSIONS_PER_ACCOUNT + ); + const candidates = this.inactiveSessionStateCandidates(excludedSessionId, requiredSessionIds); + if (candidates.length < requiredEvictions) { + throw new AgentHistoryProjectionLimitError(); + } + return { + requiredSessionIds: required, + createSessionIds, + evictSessionIds: candidates.slice(0, requiredEvictions).map(([sessionId]) => sessionId) + }; + } + + private applyRetainedStateAdmissionPlan( + plan: RetainedStateAdmissionPlan + ): ReadonlyMap { + for (const sessionId of plan.evictSessionIds) { + const state = this.sessions.get(sessionId); + if (!state) continue; + this.releasePersistedProjection(state); + this.sessions.delete(sessionId); + } + for (const sessionId of plan.createSessionIds) { + if (!this.sessions.has(sessionId)) this.createState(sessionId); + } + return new Map( + plan.requiredSessionIds.map((sessionId) => [sessionId, this.sessions.get(sessionId)!]) + ); + } + + private ensureState( + sessionId: string, + admissionProtectedSessionIds: ReadonlySet = this.protectedSessionIds + ): SessionHistoryState { + let state = this.sessions.get(sessionId); + if (!state) { + this.evictInactiveSessionStates( + sessionId, + () => this.sessions.size < MAX_AGENT_HISTORY_RETAINED_SESSIONS_PER_ACCOUNT, + admissionProtectedSessionIds + ); + if (this.sessions.size >= MAX_AGENT_HISTORY_RETAINED_SESSIONS_PER_ACCOUNT) { + throw new AgentHistoryProjectionLimitError(); + } + state = this.createState(sessionId); + } + return state; + } + + private createState(sessionId: string): SessionHistoryState { + const state = newSessionHistoryState(++this.nextStateInstanceId, ++this.nextRetentionOrdinal); + this.sessions.set(sessionId, state); + return state; + } + + private touchState(state: SessionHistoryState): void { + state.retentionOrdinal = ++this.nextRetentionOrdinal; + } + + private admitPersistedProjection( + sessionId: string, + state: SessionHistoryState, + candidateBytes: number, + admissionLiveProjectionBytes: number, + admissionProtectedSessionIds: ReadonlySet + ): void { + const candidateAccountBytes = + this.accountPersistedProjectionBytes - + state.persistedProjectionBytes + + candidateBytes + + admissionLiveProjectionBytes; + if (candidateAccountBytes <= MAX_AGENT_HISTORY_ACCOUNT_PROJECTION_BYTES) return; + + let plannedAccountBytes = candidateAccountBytes; + const evictionPlan: [string, SessionHistoryState][] = []; + for (const candidate of this.inactiveSessionStateCandidates( + sessionId, + admissionProtectedSessionIds + )) { + evictionPlan.push(candidate); + plannedAccountBytes -= candidate[1].persistedProjectionBytes; + if (plannedAccountBytes <= MAX_AGENT_HISTORY_ACCOUNT_PROJECTION_BYTES) break; + } + if (plannedAccountBytes > MAX_AGENT_HISTORY_ACCOUNT_PROJECTION_BYTES) { + throw new AgentHistoryProjectionLimitError(); + } + for (const [evictedSessionId, evictedState] of evictionPlan) { + this.releasePersistedProjection(evictedState); + this.sessions.delete(evictedSessionId); + } + } + + private evictInactiveSessionStates( + excludedSessionId: string, + stop = () => this.sessions.size < MAX_AGENT_HISTORY_RETAINED_SESSIONS_PER_ACCOUNT, + admissionProtectedSessionIds: ReadonlySet = this.protectedSessionIds + ): void { + for (const [sessionId, state] of this.inactiveSessionStateCandidates( + excludedSessionId, + admissionProtectedSessionIds + )) { + if (stop()) break; + this.releasePersistedProjection(state); + this.sessions.delete(sessionId); + } + } + + private inactiveSessionStateCandidates( + excludedSessionId: string, + admissionProtectedSessionIds: ReadonlySet + ): [string, SessionHistoryState][] { + return [...this.sessions.entries()] + .filter( + ([sessionId, state]) => + sessionId !== excludedSessionId && + !this.protectedSessionIds.has(sessionId) && + !admissionProtectedSessionIds.has(sessionId) && + state.activeRequestId === null && + state.liveItems.size === 0 && + !state.requiresSynchronizedReload + ) + .sort( + ([leftId, left], [rightId, right]) => + left.retentionOrdinal - right.retentionOrdinal || compareUtf8Bytes(leftId, rightId) + ); + } + + private releasePersistedProjection(state: SessionHistoryState): void { + this.accountPersistedProjectionBytes -= state.persistedProjectionBytes; + state.persistedProjectionBytes = 0; + state.records = []; + state.nextCursor = null; + state.historyRevision = null; + state.headLoaded = false; + state.cacheEpoch += 1; + rebuildProjection(state); + } + + private liveSessionCount(): number { + let count = 0; + for (const state of this.sessions.values()) { + if (state.liveItems.size > 0 || state.requiresSynchronizedReload) count += 1; + } + return count; + } + + private liveItemCount(): number { + let count = 0; + for (const state of this.sessions.values()) count += state.liveItems.size; + return count; + } + + private releaseLiveProjection(state: SessionHistoryState): void { + this.accountLiveProjectionBytes -= state.liveProjectionBytes; + state.liveProjectionBytes = 0; + state.liveItemOrder = []; + state.liveItems.clear(); + } + + private rebaseDeltaOnlyLiveItems(state: SessionHistoryState): void { + if (state.liveItems.size === 0 || state.records.length === 0) return; + const persistedById = new Map(); + for (const record of state.records) { + for (const persisted of record.items) { + const previous = persistedById.get(persisted.id); + persistedById.set( + persisted.id, + previous ? mergeTimelineItem(previous, persisted) : persisted + ); + } + } + const rejectedIds = new Set(); + for (const [itemId, live] of state.liveItems) { + if (!live.deltaOnly) continue; + const persisted = persistedById.get(itemId); + if (!persisted) continue; + const budget = mergedTimelineItemBudget( + persisted, + timelineItemBudget(persisted).textBytes, + live.item + ); + const nextSessionBytes = state.liveProjectionBytes - live.budgetBytes + budget.budgetBytes; + const nextAccountBytes = + this.accountLiveProjectionBytes - live.budgetBytes + budget.budgetBytes; + if ( + budget.textBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ITEM || + nextSessionBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_SESSION || + nextAccountBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ACCOUNT || + state.persistedProjectionBytes + nextSessionBytes > + MAX_AGENT_HISTORY_SESSION_PROJECTION_BYTES || + this.accountPersistedProjectionBytes + nextAccountBytes > + MAX_AGENT_HISTORY_ACCOUNT_PROJECTION_BYTES + ) { + rejectedIds.add(itemId); + state.liveItems.delete(itemId); + state.liveProjectionBytes -= live.budgetBytes; + this.accountLiveProjectionBytes -= live.budgetBytes; + state.requiresSynchronizedReload = true; + this.accountRequiresSynchronizedReload = true; + continue; + } + const rebased: LiveTimelineItem = { + item: mergeTimelineItem(persisted, live.item), + deltaOnly: false, + ...budget + }; + state.liveItems.set(itemId, rebased); + state.liveProjectionBytes = nextSessionBytes; + this.accountLiveProjectionBytes = nextAccountBytes; + } + if (rejectedIds.size > 0) { + state.liveItemOrder = state.liveItemOrder.filter((itemId) => !rejectedIds.has(itemId)); + } + } + + private bumpLifecycleGeneration(): void { + this.lifecycleGeneration += 1; + for (const state of this.sessions.values()) state.activeRequestId = null; + } + + private validateSynchronizedLiveSnapshot( + snapshot: AgentSynchronizedLiveSnapshot + ): AgentLiveSessionSnapshot[] { + if (snapshot.liveSessionsComplete !== true) { + throw new Error("Agent synchronized live snapshot must be complete"); + } + if ( + !Number.isSafeInteger(snapshot.liveSessionCount) || + snapshot.liveSessionCount < 0 || + snapshot.liveSessionCount !== snapshot.liveSessions.length || + snapshot.liveSessionCount > MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT + ) { + throw new Error("Agent synchronized live session count is invalid"); + } + let previousSessionId: string | null = null; + let totalItemCount = 0; + let totalProjectionBytes = 0; + const absoluteSessions: AgentLiveSessionSnapshot[] = []; + for (const liveSession of snapshot.liveSessions) { + if ( + !liveSession.sessionId || + (previousSessionId !== null && + compareUtf8Bytes(previousSessionId, liveSession.sessionId) >= 0) + ) { + throw new Error("Agent synchronized live sessions must have unique sorted IDs"); + } + previousSessionId = liveSession.sessionId; + if (liveSession.liveItems.length > MAX_AGENT_LIVE_ITEMS_PER_SESSION) { + throw new Error("Agent synchronized live suffix exceeds its session limit"); + } + const itemIds = new Set(); + const absoluteItems: AgentTimelineItem[] = []; + let sessionProjectionBytes = 0; + for (const item of liveSession.liveItems) { + if (item.merge !== "replace") { + throw new Error("Agent synchronized live suffix must contain absolute items"); + } + if (itemIds.has(item.id)) { + throw new Error("Agent synchronized live suffix contains a duplicate item ID"); + } + const budget = timelineItemBudget(item); + sessionProjectionBytes += budget.budgetBytes; + totalProjectionBytes += budget.budgetBytes; + if ( + budget.textBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ITEM || + sessionProjectionBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_SESSION || + totalProjectionBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ACCOUNT + ) { + throw new Error("Agent synchronized live suffix exceeds its byte budget"); + } + itemIds.add(item.id); + absoluteItems.push(item); + } + totalItemCount += absoluteItems.length; + if (totalItemCount > MAX_AGENT_LIVE_ITEMS_PER_ACCOUNT) { + throw new Error("Agent synchronized live suffix exceeds the account item limit"); + } + absoluteSessions.push({ sessionId: liveSession.sessionId, liveItems: absoluteItems }); + } + return absoluteSessions; + } + + private retireEventEpoch(epoch: string): void { + if (this.retiredEventEpochs.has(epoch)) return; + this.retiredEventEpochs.add(epoch); + this.retiredEventEpochOrder.push(epoch); + while (this.retiredEventEpochOrder.length > MAX_AGENT_RETIRED_EVENT_EPOCHS) { + const oldest = this.retiredEventEpochOrder.shift(); + if (oldest) this.retiredEventEpochs.delete(oldest); + } + } +} diff --git a/frontend/src/services/agentLiveConnectionLifecycle.test.ts b/frontend/src/services/agentLiveConnectionLifecycle.test.ts new file mode 100644 index 000000000..4c552ba89 --- /dev/null +++ b/frontend/src/services/agentLiveConnectionLifecycle.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test"; + +import { + AgentLiveConnectionError, + AgentLiveConnectionRegistry, + recoverAgentLiveConnectionAfterReplacementFailure +} from "./agentLiveConnectionLifecycle"; +import type { AgentActiveLiveStream, AgentPendingHistoryAttach } from "./agentRuntimeService"; + +function pending(cancel: () => Promise): AgentPendingHistoryAttach { + return { + response: {} as AgentPendingHistoryAttach["response"], + activate: async () => { + throw new Error("not used by retirement tests"); + }, + cancel + }; +} + +function active(cancel: () => Promise): AgentActiveLiveStream { + return { + throughEventCursor: { journalId: "00112233445566778899aabbccddeeff", sequence: 7 }, + liveStreamId: "live-1", + cancel + }; +} + +describe("AgentLiveConnectionRegistry", () => { + test("publishes cancellation intent before retirement yields", async () => { + let cancelStarted = false; + let finishCancel: (() => void) | undefined; + const registry = new AgentLiveConnectionRegistry(); + registry.trackActive( + active(async () => { + cancelStarted = true; + await new Promise((resolve) => { + finishCancel = resolve; + }); + }) + ); + + const retirement = registry.retire(); + expect(cancelStarted).toBe(true); + finishCancel?.(); + await retirement; + }); + + test("clears every handle after both cancellations succeed", async () => { + const calls: string[] = []; + const registry = new AgentLiveConnectionRegistry(); + registry.trackPending( + pending(async () => { + calls.push("pending"); + }) + ); + registry.trackActive( + active(async () => { + calls.push("active"); + }) + ); + + await registry.retire(); + + expect(calls.sort()).toEqual(["active", "pending"]); + expect(registry.pendingCount).toBe(0); + expect(registry.activeCount).toBe(0); + }); + + test("propagates a failed cancellation and retains only its handle for retry", async () => { + const cancellationError = new Error("host refused live cancellation"); + let activeAttempts = 0; + const registry = new AgentLiveConnectionRegistry(); + registry.trackPending(pending(async () => {})); + registry.trackActive( + active(async () => { + activeAttempts += 1; + if (activeAttempts === 1) throw cancellationError; + }) + ); + + let caught: unknown; + try { + await registry.retire(); + } catch (error) { + caught = error; + } + + expect(caught).toBe(cancellationError); + expect(registry.pendingCount).toBe(0); + expect(registry.activeCount).toBe(1); + + await registry.retire(); + expect(activeAttempts).toBe(2); + expect(registry.activeCount).toBe(0); + }); + + test("attempts both cancellations even when one throws synchronously", async () => { + const pendingError = new Error("pending cancellation failed"); + const activeError = new Error("active cancellation failed"); + let activeAttempted = false; + const registry = new AgentLiveConnectionRegistry(); + registry.trackPending( + pending(() => { + throw pendingError; + }) + ); + registry.trackActive( + active(async () => { + activeAttempted = true; + throw activeError; + }) + ); + + let caught: unknown; + try { + await registry.retire(); + } catch (error) { + caught = error; + } + + expect(activeAttempted).toBe(true); + expect(caught).toBeInstanceOf(AgentLiveConnectionError); + expect((caught as AgentLiveConnectionError).errors).toEqual([pendingError, activeError]); + expect(registry.pendingCount).toBe(1); + expect(registry.activeCount).toBe(1); + }); + + test("retains a late stale stream until its direct cancellation succeeds", async () => { + const cancellationError = new Error("late stream cancellation failed"); + let attempts = 0; + const registry = new AgentLiveConnectionRegistry(); + const stream = active(async () => { + attempts += 1; + if (attempts === 1) throw cancellationError; + }); + + await expect(registry.cancelActive(stream)).rejects.toBe(cancellationError); + expect(registry.activeCount).toBe(1); + + await registry.retire(); + expect(attempts).toBe(2); + expect(registry.activeCount).toBe(0); + }); +}); + +describe("recoverAgentLiveConnectionAfterReplacementFailure", () => { + test("retires the failed replacement and resumes from the retained cursor", async () => { + const replacementError = new Error("replacement attach failed"); + const cursor = { journalId: "00112233445566778899aabbccddeeff", sequence: 11 }; + const calls: string[] = []; + let resumedCursor: unknown = null; + + let caught: unknown; + try { + await recoverAgentLiveConnectionAfterReplacementFailure({ + replacementError, + cursor, + retire: async () => { + calls.push("retire"); + }, + resume: async (retainedCursor) => { + calls.push("resume"); + resumedCursor = retainedCursor; + } + }); + } catch (error) { + caught = error; + } + + expect(calls).toEqual(["retire", "resume"]); + expect(resumedCursor).toEqual(cursor); + expect(caught).toBe(replacementError); + }); + + test("propagates retirement failure without opening a replacement stream", async () => { + const replacementError = new Error("replacement attach failed"); + const retirementError = new Error("cancel failed"); + let resumeCount = 0; + + let caught: unknown; + try { + await recoverAgentLiveConnectionAfterReplacementFailure({ + replacementError, + cursor: "event-cursor", + retire: async () => { + throw retirementError; + }, + resume: async () => { + resumeCount += 1; + } + }); + } catch (error) { + caught = error; + } + + expect(resumeCount).toBe(0); + expect(caught).toBeInstanceOf(AgentLiveConnectionError); + expect((caught as AgentLiveConnectionError).errors).toEqual([ + replacementError, + retirementError + ]); + }); + + test("reports both the replacement and cursor-resume failures", async () => { + const replacementError = new Error("replacement attach failed"); + const resumeError = new Error("cursor resume failed"); + + let caught: unknown; + try { + await recoverAgentLiveConnectionAfterReplacementFailure({ + replacementError, + cursor: "event-cursor", + retire: async () => {}, + resume: async () => { + throw resumeError; + } + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(AgentLiveConnectionError); + expect((caught as AgentLiveConnectionError).errors).toEqual([replacementError, resumeError]); + }); +}); diff --git a/frontend/src/services/agentLiveConnectionLifecycle.ts b/frontend/src/services/agentLiveConnectionLifecycle.ts new file mode 100644 index 000000000..90e3392d5 --- /dev/null +++ b/frontend/src/services/agentLiveConnectionLifecycle.ts @@ -0,0 +1,130 @@ +import type { AgentActiveLiveStream, AgentPendingHistoryAttach } from "./agentRuntimeService"; + +export class AgentLiveConnectionError extends Error { + constructor( + message: string, + readonly errors: readonly unknown[] + ) { + super(message); + this.name = "AgentLiveConnectionError"; + } +} + +async function throwCancellationFailures(results: readonly PromiseSettledResult[]) { + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [] + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AgentLiveConnectionError("Unable to retire the Agent live connection", failures); + } +} + +function invokeCancellation(cancel: () => Promise): Promise { + try { + return Promise.resolve(cancel()); + } catch (error) { + return Promise.reject(error); + } +} + +/** + * Owns every frontend live-channel handle until its cancellation succeeds. + * The handles themselves already normalize benign not-found, stale-lease, and + * channel-closed cleanup outcomes, so a rejection here is always fail-loud. + */ +export class AgentLiveConnectionRegistry { + private readonly pendingHandles = new Set(); + private readonly activeHandles = new Set(); + + get hasPending(): boolean { + return this.pendingHandles.size > 0; + } + + get pendingCount(): number { + return this.pendingHandles.size; + } + + get activeCount(): number { + return this.activeHandles.size; + } + + trackPending(pending: AgentPendingHistoryAttach): void { + this.pendingHandles.add(pending); + } + + promote(pending: AgentPendingHistoryAttach, active: AgentActiveLiveStream): void { + this.pendingHandles.delete(pending); + this.activeHandles.add(active); + } + + trackActive(active: AgentActiveLiveStream): void { + this.activeHandles.add(active); + } + + async cancelPending(pending: AgentPendingHistoryAttach): Promise { + this.pendingHandles.add(pending); + const [result] = await Promise.allSettled([invokeCancellation(() => pending.cancel())]); + if (result.status === "fulfilled") this.pendingHandles.delete(pending); + await throwCancellationFailures([result]); + } + + async cancelActive(active: AgentActiveLiveStream): Promise { + this.activeHandles.add(active); + const [result] = await Promise.allSettled([invokeCancellation(() => active.cancel())]); + if (result.status === "fulfilled") this.activeHandles.delete(active); + await throwCancellationFailures([result]); + } + + async retire(): Promise { + const pending = [...this.pendingHandles]; + const active = [...this.activeHandles]; + const results = await Promise.allSettled([ + ...pending.map((handle) => invokeCancellation(() => handle.cancel())), + ...active.map((handle) => invokeCancellation(() => handle.cancel())) + ]); + + pending.forEach((handle, index) => { + if (results[index]?.status === "fulfilled") this.pendingHandles.delete(handle); + }); + active.forEach((handle, index) => { + if (results[pending.length + index]?.status === "fulfilled") { + this.activeHandles.delete(handle); + } + }); + await throwCancellationFailures(results); + } +} + +export async function recoverAgentLiveConnectionAfterReplacementFailure({ + replacementError, + cursor, + retire, + resume +}: { + replacementError: unknown; + cursor: TCursor | null; + retire: () => Promise; + resume: (cursor: TCursor) => Promise; +}): Promise { + try { + await retire(); + } catch (retirementError) { + throw new AgentLiveConnectionError("Agent history replacement and live cleanup both failed", [ + replacementError, + retirementError + ]); + } + + if (cursor !== null) { + try { + await resume(cursor); + } catch (resumeError) { + throw new AgentLiveConnectionError( + "Agent history replacement failed and cursor recovery could not resume", + [replacementError, resumeError] + ); + } + } + throw replacementError; +} diff --git a/frontend/src/services/agentProjectFolder.test.ts b/frontend/src/services/agentProjectFolder.test.ts index 3fa368ffc..37ad5e5a2 100644 --- a/frontend/src/services/agentProjectFolder.test.ts +++ b/frontend/src/services/agentProjectFolder.test.ts @@ -1,6 +1,24 @@ import { describe, expect, test } from "bun:test"; -import { revealAgentProjectFolder } from "./agentProjectFolder"; +import { + canUseLocalAgentProjectFolderActions, + revealAgentProjectFolder +} from "./agentProjectFolder"; +import { + LOCAL_AGENT_EXECUTION_TARGET, + createRemoteAgentExecutionTarget +} from "./agentRuntimeService"; + +describe("canUseLocalAgentProjectFolderActions", () => { + test("allows host folder actions only for the local desktop target", () => { + const remoteTarget = createRemoteAgentExecutionTarget("paired-mac", "Paired Mac"); + + expect(canUseLocalAgentProjectFolderActions(LOCAL_AGENT_EXECUTION_TARGET, true)).toBe(true); + expect(canUseLocalAgentProjectFolderActions(LOCAL_AGENT_EXECUTION_TARGET, false)).toBe(false); + expect(canUseLocalAgentProjectFolderActions(remoteTarget, true)).toBe(false); + expect(canUseLocalAgentProjectFolderActions(remoteTarget, false)).toBe(false); + }); +}); describe("revealAgentProjectFolder", () => { test("reveals the exact canonical project path once", async () => { diff --git a/frontend/src/services/agentProjectFolder.ts b/frontend/src/services/agentProjectFolder.ts index 888cc841c..6856a3285 100644 --- a/frontend/src/services/agentProjectFolder.ts +++ b/frontend/src/services/agentProjectFolder.ts @@ -1,5 +1,14 @@ +import type { AgentExecutionTarget } from "./agentRuntimeService"; + export type AgentProjectFolderRevealer = (projectPath: string) => Promise; +export function canUseLocalAgentProjectFolderActions( + target: AgentExecutionTarget, + isTauriDesktop: boolean +): boolean { + return target.kind === "local" && isTauriDesktop; +} + async function revealProjectFolderWithTauri(projectPath: string): Promise { const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); await revealItemInDir(projectPath); diff --git a/frontend/src/services/agentProjectOrdering.test.ts b/frontend/src/services/agentProjectOrdering.test.ts index 10b2be34c..6d6469dcd 100644 --- a/frontend/src/services/agentProjectOrdering.test.ts +++ b/frontend/src/services/agentProjectOrdering.test.ts @@ -26,6 +26,7 @@ function session(id: string, projectRoot: string, updatedMs: number): AgentSessi projectRoot, createdMs: updatedMs, updatedMs, + pageSortMs: updatedMs, messageCount: 1, mode: "smart_approve" }; diff --git a/frontend/src/services/agentRuntimeService.test.ts b/frontend/src/services/agentRuntimeService.test.ts index 2a8c98e7a..47377f2a0 100644 --- a/frontend/src/services/agentRuntimeService.test.ts +++ b/frontend/src/services/agentRuntimeService.test.ts @@ -3,15 +3,87 @@ import { AgentRuntimeStopCoordinator, AgentRuntimePartialStopError, AgentRuntimeService, + LOCAL_AGENT_EXECUTION_TARGET, + clearAgentDataForUser, + clearAgentHistoryForUser, + createRemoteAgentExecutionTarget, + activateAgentRuntimeAccountResources, + retireAgentAuthAccount, + retireAgentRuntimeAccountResources, + stopAgentRuntimeForUser, + type AgentListSessionsPageRequest, + type AgentListSessionRecordsPageRequest, + type AgentBridgeEventHandler, + type AgentBridgeLiveChannelResult, + type AgentBeginSessionHistoryAttachResponse, + type AgentExecutionLease, + type AgentLiveChannelFrame, + type AgentLiveEventCursor, type AgentRuntimeBridge, + type AgentRuntimeInvocation, type AgentRuntimeStopBridge, type AgentRuntimeLifecycleOutcome, type AgentCreateSessionRequest, + type AgentEventEnvelope, + type AgentExecutionTarget, type AgentRenameSessionRequest, + type AgentSessionRecordsPage, type AgentSessionSummary, type AgentSendMessageRequest } from "./agentRuntimeService"; -import type { AgentOperationBlock } from "./agentOperationFence"; +import { + AgentOperationFence, + AgentOperationsBlockedError, + type AgentOperationBlock +} from "./agentOperationFence"; +import { AgentAuthLifecycleCoordinator } from "./agentAuthLifecycle"; +import { waitForPlatform } from "@/utils/platform"; + +const TEST_JOURNAL_ID = "0123456789abcdef0123456789abcdef"; + +function synchronizedAttachResult(): AgentBeginSessionHistoryAttachResponse { + return { + attachId: "attach-1", + page: { records: [], historyRevision: "history-1" }, + liveSessionsComplete: true, + liveSessionCount: 0, + liveSessions: [], + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 0 } + }; +} + +function closedLiveItem( + id = "live-1", + overrides: Record = {} +): Record { + return { + id, + itemType: "message", + role: "assistant", + text: "safe", + createdMs: 1, + merge: "replace", + ...overrides + }; +} + +function orderedLiveFrame( + lease: AgentExecutionLease, + target: AgentExecutionTarget, + eventSequence: number, + payload: Record +): Record { + return { + liveEventVersion: 1, + targetId: target.id, + hostEpoch: lease.hostEpoch, + connectionGeneration: lease.connectionGeneration, + eventEpoch: TEST_JOURNAL_ID, + eventSequence, + sessionId: "session", + ...payload + }; +} class RecordingBridge implements AgentRuntimeBridge { readonly events: string[] = []; @@ -19,7 +91,7 @@ class RecordingBridge implements AgentRuntimeBridge { response: unknown; invokeError: unknown; - async syncAuth(userId: string): Promise { + async syncLocalAuth(userId: string): Promise { this.events.push(`sync:${userId}`); } @@ -36,6 +108,415 @@ class RecordingBridge implements AgentRuntimeBridge { } } +class RecordingTargetBridge implements AgentRuntimeBridge { + readonly invocations: Array<{ + lease: AgentExecutionLease; + invocation: AgentRuntimeInvocation; + }> = []; + readonly preparedTargets: AgentExecutionTarget[] = []; + readonly fencedTargets: AgentExecutionTarget[] = []; + readonly subscriptions: Array<{ + lease: AgentExecutionLease | null; + target: AgentExecutionTarget; + }> = []; + readonly listeners = new Map>(); + readonly liveHandlers: AgentBridgeEventHandler[] = []; + readonly liveResumes: Array<{ + lease: AgentExecutionLease | null; + cursor: AgentLiveEventCursor; + }> = []; + readonly pendingAttachCancels: string[] = []; + readonly liveStreamCancels: string[] = []; + private readonly leases = new Map< + string, + { + targetId: string; + hostEpoch: string; + connectionGeneration: number; + } + >(); + private nextGeneration = 1; + runtimeStatusResult: unknown = { running: false }; + createSessionResult: unknown = null; + sessionPageResult: unknown = { items: [], nextCursor: null }; + recordPageResult: unknown = { + records: [], + nextCursor: null, + historyRevision: "history-1" + }; + attachResult: unknown = synchronizedAttachResult(); + attachError: unknown = null; + activateResult: unknown = { + throughEventCursor: { journalId: "0123456789abcdef0123456789abcdef", sequence: 0 }, + liveStreamId: "attach-1" + }; + activateError: unknown = null; + resumeResult: unknown = { + throughEventCursor: { journalId: "0123456789abcdef0123456789abcdef", sequence: 7 }, + liveStreamId: "stream-1" + }; + resumeError: unknown = null; + + async prepareTarget(userId: string, target: AgentExecutionTarget): Promise { + this.preparedTargets.push(target); + const key = `${userId}:${target.id as string}`; + let lease = this.leases.get(key); + if (!lease) { + const generation = this.nextGeneration++; + lease = { + targetId: target.id, + hostEpoch: String(generation), + connectionGeneration: generation + }; + this.leases.set(key, lease); + } + return lease; + } + + async runForUser( + _userId: string, + operation: () => Promise, + target?: AgentExecutionTarget + ): Promise { + if (target) this.fencedTargets.push(target); + return await operation(); + } + + async invokeTarget( + lease: AgentExecutionLease, + invocation: AgentRuntimeInvocation + ): Promise { + this.invocations.push({ lease, invocation }); + switch (invocation.operation) { + case "getRuntimeStatus": + return this.runtimeStatusResult; + case "createSession": + return this.createSessionResult; + case "listSessionsPage": + return this.sessionPageResult; + case "listSessionRecordsPage": + return this.recordPageResult; + case "stopRuntime": + return { + status: { running: false, activeRuns: {} }, + acpShutdownError: null + }; + default: + return null; + } + } + + async listenToEvents( + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + handler: AgentBridgeEventHandler + ): Promise<() => void> { + this.subscriptions.push({ lease, target }); + const listeners = this.listeners.get(target.id) ?? new Set(); + listeners.add(handler); + this.listeners.set(target.id, listeners); + return () => listeners.delete(handler); + } + + async beginSessionHistoryAttach( + _userId: string, + _lease: AgentExecutionLease | null, + _target: AgentExecutionTarget, + _request: AgentListSessionRecordsPageRequest, + handler: AgentBridgeEventHandler + ): Promise { + if (this.attachError) throw this.attachError; + this.liveHandlers.push(handler); + return { result: this.attachResult, keepAlive: {} }; + } + + async activateSessionHistoryAttach(): Promise { + if (this.activateError) throw this.activateError; + return this.activateResult; + } + + async cancelSessionHistoryAttach( + _userId: string, + _lease: AgentExecutionLease | null, + _target: AgentExecutionTarget, + attachId: string + ): Promise { + this.pendingAttachCancels.push(attachId); + } + + async resumeLiveEvents( + _userId: string, + lease: AgentExecutionLease | null, + _target: AgentExecutionTarget, + cursor: AgentLiveEventCursor, + handler: AgentBridgeEventHandler + ): Promise { + if (this.resumeError) throw this.resumeError; + this.liveResumes.push({ lease, cursor }); + this.liveHandlers.push(handler); + return { result: this.resumeResult, keepAlive: {} }; + } + + async cancelLiveEvents( + _userId: string, + _lease: AgentExecutionLease | null, + _target: AgentExecutionTarget, + liveStreamId: string + ): Promise { + this.liveStreamCancels.push(liveStreamId); + } + + emit(target: AgentExecutionTarget, event: unknown): void { + for (const listener of this.listeners.get(target.id) ?? []) listener(event); + } + + rotateLease(userId: string, target: AgentExecutionTarget): void { + const generation = this.nextGeneration++; + this.leases.set(`${userId}:${target.id as string}`, { + targetId: target.id, + hostEpoch: String(generation), + connectionGeneration: generation + }); + } +} + +class ControlledPrepareBridge extends RecordingTargetBridge { + readonly pendingPreparations: Array<{ + userId: string; + target: AgentExecutionTarget; + resolve: (value: unknown) => void; + reject: (error: unknown) => void; + }> = []; + + override async prepareTarget(userId: string, target: AgentExecutionTarget): Promise { + this.preparedTargets.push(target); + return await new Promise((resolve, reject) => { + this.pendingPreparations.push({ userId, target, resolve, reject }); + }); + } + + resolvePreparation(index: number, generation: number): void { + const preparation = this.pendingPreparations[index]; + preparation.resolve({ + targetId: preparation.target.id, + hostEpoch: String(generation), + connectionGeneration: generation + }); + } +} + +class ControlledEventBindBridge extends RecordingTargetBridge { + readonly pendingBinds: Array<{ + lease: AgentExecutionLease; + target: AgentExecutionTarget; + handler: AgentBridgeEventHandler; + resolve: (unlisten: () => void) => void; + reject: (error: unknown) => void; + }> = []; + + override async listenToEvents( + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + handler: AgentBridgeEventHandler + ): Promise<() => void> { + if (!lease) return await super.listenToEvents(lease, target, handler); + this.subscriptions.push({ lease, target }); + return await new Promise<() => void>((resolve, reject) => { + this.pendingBinds.push({ lease, target, handler, resolve, reject }); + }); + } + + resolveBind(index: number): void { + const bind = this.pendingBinds[index]; + const listeners = this.listeners.get(bind.target.id) ?? new Set(); + listeners.add(bind.handler); + this.listeners.set(bind.target.id, listeners); + bind.resolve(() => listeners.delete(bind.handler)); + } + + rejectBind(index: number, error: unknown): void { + this.pendingBinds[index].reject(error); + } +} + +class FailingUnlistenBridge extends RecordingTargetBridge { + unlistenCalls = 0; + private failuresRemaining = 0; + + failNextUnlisten(): void { + this.failuresRemaining += 1; + } + + override async listenToEvents( + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + handler: AgentBridgeEventHandler + ): Promise<() => void> { + this.subscriptions.push({ lease, target }); + const listeners = this.listeners.get(target.id) ?? new Set(); + listeners.add(handler); + this.listeners.set(target.id, listeners); + return () => { + this.unlistenCalls += 1; + if (this.failuresRemaining > 0) { + this.failuresRemaining -= 1; + throw new Error("subscription cleanup failed"); + } + listeners.delete(handler); + }; + } +} + +class FencedControlledEventBridge extends ControlledEventBindBridge { + constructor(private readonly fence: AgentOperationFence) { + super(); + } + + override async runForUser( + userId: string, + operation: () => Promise, + target?: AgentExecutionTarget + ): Promise { + if (target) this.fencedTargets.push(target); + return await this.fence.run(userId, operation); + } +} + +class ControlledAttachBridge extends RecordingTargetBridge { + readonly pendingActivations: Array<{ + resolve: (value: unknown) => void; + reject: (error: unknown) => void; + }> = []; + + override async activateSessionHistoryAttach(): Promise { + return await new Promise((resolve, reject) => { + this.pendingActivations.push({ resolve, reject }); + }); + } +} + +class ControlledBeginBridge extends RecordingTargetBridge { + readonly pendingBegins: Array<{ + handler: AgentBridgeEventHandler; + resolve: (value: AgentBridgeLiveChannelResult) => void; + reject: (error: unknown) => void; + }> = []; + + override async beginSessionHistoryAttach( + _userId: string, + _lease: AgentExecutionLease | null, + _target: AgentExecutionTarget, + _request: AgentListSessionRecordsPageRequest, + handler: AgentBridgeEventHandler + ): Promise { + return await new Promise((resolve, reject) => { + this.pendingBegins.push({ handler, resolve, reject }); + }); + } + + resolveBegin(index: number): void { + const pending = this.pendingBegins[index]; + this.liveHandlers.push(pending.handler); + pending.resolve({ result: this.attachResult, keepAlive: {} }); + } +} + +class SelectiveFailingLiveCancelBridge extends RecordingTargetBridge { + readonly cancellationFailures = new Map(); + + failNextCancellation(liveStreamId: string, error: unknown): void { + const failures = this.cancellationFailures.get(liveStreamId) ?? []; + failures.push(error); + this.cancellationFailures.set(liveStreamId, failures); + } + + override async cancelLiveEvents( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + liveStreamId: string + ): Promise { + await super.cancelLiveEvents(userId, lease, target, liveStreamId); + const failures = this.cancellationFailures.get(liveStreamId); + const failure = failures?.shift(); + if (failures?.length === 0) this.cancellationFailures.delete(liveStreamId); + if (failure) throw failure; + } +} + +class ControlledLiveCancelBridge extends RecordingTargetBridge { + readonly pendingLiveCancels: Array<{ + liveStreamId: string; + resolve: () => void; + reject: (error: unknown) => void; + }> = []; + + override async cancelLiveEvents( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + liveStreamId: string + ): Promise { + await super.cancelLiveEvents(userId, lease, target, liveStreamId); + await new Promise((resolve, reject) => { + this.pendingLiveCancels.push({ liveStreamId, resolve, reject }); + }); + } +} + +class ControlledResumeBridge extends SelectiveFailingLiveCancelBridge { + readonly pendingResumes: Array<{ + lease: AgentExecutionLease | null; + cursor: AgentLiveEventCursor; + handler: AgentBridgeEventHandler; + resolve: (value: AgentBridgeLiveChannelResult) => void; + reject: (error: unknown) => void; + }> = []; + + override async resumeLiveEvents( + _userId: string, + lease: AgentExecutionLease | null, + _target: AgentExecutionTarget, + cursor: AgentLiveEventCursor, + handler: AgentBridgeEventHandler + ): Promise { + this.liveResumes.push({ lease, cursor }); + return await new Promise((resolve, reject) => { + this.pendingResumes.push({ lease, cursor, handler, resolve, reject }); + }); + } + + resolveResume(index: number, result: unknown): void { + const pending = this.pendingResumes[index]; + this.liveHandlers.push(pending.handler); + pending.resolve({ result, keepAlive: {} }); + } +} + +class FailingLateStreamCancelBridge extends ControlledAttachBridge { + cancelError: unknown = null; + + override async cancelLiveEvents( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + liveStreamId: string + ): Promise { + await super.cancelLiveEvents(userId, lease, target, liveStreamId); + const error = this.cancelError; + this.cancelError = null; + if (error) throw error; + } +} + +async function waitFor(predicate: () => boolean): Promise { + for (let turn = 0; turn < 20; turn += 1) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error("timed out waiting for test condition"); +} + describe("AgentRuntimeService", () => { test("cancellation stays account-fenced without waiting for remote auth sync", async () => { const bridge = new RecordingBridge(); @@ -47,6 +528,67 @@ describe("AgentRuntimeService", () => { expect(bridge.lastArgs).toEqual({ userId: "user-a", runId: "run-1" }); }); + test("preserves embedded lifecycle command behavior through the semantic service", async () => { + const stopBridge = new RecordingBridge(); + stopBridge.response = { + status: { running: false, activeRuns: {} }, + acpShutdownError: null + }; + const stopService = new AgentRuntimeService(stopBridge); + + await stopService.stopRuntime("user-a"); + expect(stopBridge.events).toEqual(["fence:user-a", "invoke:agent_stop_runtime"]); + expect(stopBridge.lastArgs).toEqual({ userId: "user-a" }); + + const clearBridge = new RecordingBridge(); + const clearService = new AgentRuntimeService(clearBridge); + await clearService.clearUserData("user-a"); + await clearService.clearUserHistory("user-a"); + expect(clearBridge.events).toEqual([ + "fence:user-a", + "sync:user-a", + "invoke:agent_clear_user_data", + "fence:user-a", + "sync:user-a", + "invoke:agent_clear_user_history" + ]); + }); + + test("keeps implicit local cleanup helpers as no-ops outside Tauri Desktop", async () => { + await waitForPlatform(); + const stopBlock = await stopAgentRuntimeForUser("user-a"); + const dataBlock = await clearAgentDataForUser("user-a"); + const historyBlock = await clearAgentHistoryForUser("user-a"); + + expect(() => stopBlock.release()).not.toThrow(); + expect(() => dataBlock.retainUntilNextSession()).not.toThrow(); + expect(() => historyBlock.release()).not.toThrow(); + + const anonymousStopBlock = await stopAgentRuntimeForUser(undefined); + const anonymousDataBlock = await clearAgentDataForUser(undefined); + const anonymousHistoryBlock = await clearAgentHistoryForUser(undefined); + expect(() => anonymousStopBlock.release()).not.toThrow(); + expect(() => anonymousDataBlock.release()).not.toThrow(); + expect(() => anonymousHistoryBlock.retainUntilNextSession()).not.toThrow(); + }); + + test("an explicit remote cleanup service still requires an authenticated user", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + await expect(stopAgentRuntimeForUser(undefined, service)).rejects.toThrow( + "without an authenticated user" + ); + await expect(clearAgentDataForUser(undefined, service)).rejects.toThrow( + "without an authenticated user" + ); + await expect(clearAgentHistoryForUser(undefined, service)).rejects.toThrow( + "without an authenticated user" + ); + expect(bridge.invocations).toEqual([]); + }); + test("backend-dependent operations still synchronize credentials inside the fence", async () => { const bridge = new RecordingBridge(); const service = new AgentRuntimeService(bridge); @@ -91,6 +633,7 @@ describe("AgentRuntimeService", () => { projectRoot: "/tmp/project", createdMs: 100, updatedMs: 200, + pageSortMs: 200, messageCount: 3, model: "kimi-k2-6", mode: "auto" @@ -114,6 +657,2046 @@ describe("AgentRuntimeService", () => { service.renameSession("user-a", { sessionId: "session-1", title: "Renamed task" }) ).rejects.toBe(persistenceError); }); + + test("keeps legacy command strings on the local bridge only", async () => { + const bridge = new RecordingBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + await expect(service.cancelRun("user-a", "run-1")).rejects.toThrow( + 'Agent runtime bridge cannot prepare remote target "dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"' + ); + expect(bridge.events).toEqual(["fence:user-a"]); + }); + + test("routes authenticated operations through a target-aware semantic vocabulary", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget( + "dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA", + "MacBook Pro" + ); + const service = new AgentRuntimeService(bridge, remote); + + await service.getRuntimeStatus("user-a"); + + expect(bridge.fencedTargets).toEqual([remote]); + expect(bridge.preparedTargets).toEqual([remote]); + expect(remote.displayName).toBe("MacBook Pro"); + expect(bridge.invocations).toHaveLength(1); + expect(bridge.invocations[0].lease).toMatchObject({ + accountId: "user-a", + targetId: remote.id, + hostEpoch: "1", + connectionGeneration: 1 + }); + expect(Object.isFrozen(bridge.invocations[0].lease)).toBe(true); + expect(bridge.invocations[0].invocation).toEqual({ operation: "getRuntimeStatus" }); + expect(JSON.stringify(bridge.invocations[0].invocation)).not.toContain("user-a"); + }); + + test("rejects malformed results at the remote bridge boundary", async () => { + const bridge = new RecordingTargetBridge(); + bridge.runtimeStatusResult = { running: "sometimes" }; + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + await expect(service.getRuntimeStatus("user-a")).rejects.toThrow( + 'invalid result for "getRuntimeStatus"' + ); + }); + + test("rejects a zero-generation verified host lease", async () => { + const bridge = new ControlledPrepareBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const status = service.getRuntimeStatus("user-a"); + await waitFor(() => bridge.pendingPreparations.length === 1); + bridge.resolvePreparation(0, 0); + await expect(status).rejects.toThrow("invalid or mismatched execution lease"); + }); + + test("accepts only the exact native execution lease shape", async () => { + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const invalidLeases: unknown[] = [ + { targetId: remote.id, connectionGeneration: 1 }, + { targetId: remote.id, hostEpoch: "1", connectionGeneration: 1, accountId: "user-a" }, + { + targetId: remote.id, + hostEpoch: "1", + connectionGeneration: 1, + leaseId: "legacy-lease" + }, + { targetId: "another-target", hostEpoch: "1", connectionGeneration: 1 }, + { targetId: remote.id, hostEpoch: "", connectionGeneration: 1 }, + { targetId: remote.id, hostEpoch: "host:1", connectionGeneration: 1 }, + { targetId: remote.id, hostEpoch: "01", connectionGeneration: 1 }, + { targetId: remote.id, hostEpoch: "18446744073709551616", connectionGeneration: 1 }, + { targetId: remote.id, hostEpoch: "1", connectionGeneration: 0 } + ]; + + for (const invalidLease of invalidLeases) { + const bridge = new ControlledPrepareBridge(); + const service = new AgentRuntimeService(bridge, remote); + const status = service.getRuntimeStatus("user-a"); + await waitFor(() => bridge.pendingPreparations.length === 1); + bridge.pendingPreparations[0].resolve(invalidLease); + await expect(status).rejects.toThrow("invalid or mismatched execution lease"); + } + }); + + test("rejects an in-flight result after reconnect replaces its immutable lease", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + let resolveFirst: ((value: unknown) => void) | undefined; + bridge.runtimeStatusResult = new Promise((resolve) => { + resolveFirst = resolve; + }); + + const firstInvocation = service.getRuntimeStatus("user-a"); + await waitFor(() => bridge.invocations.length === 1); + expect(bridge.invocations).toHaveLength(1); + const firstLease = bridge.invocations[0].lease; + + bridge.rotateLease("user-a", remote); + bridge.runtimeStatusResult = { running: true }; + await expect(service.getRuntimeStatus("user-a")).resolves.toEqual({ running: true }); + const replacementLease = bridge.invocations[1].lease; + expect(replacementLease.hostEpoch).not.toBe(firstLease.hostEpoch); + + resolveFirst?.({ running: false }); + await expect(firstInvocation).rejects.toThrow( + 'execution lease changed while "getRuntimeStatus" was in flight' + ); + }); + + test("a late account A preparation cannot overwrite account B invocation authority", async () => { + const bridge = new ControlledPrepareBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + const accountA = service.getRuntimeStatus("account-a"); + await waitFor(() => bridge.pendingPreparations.length === 1); + const accountAOutcome = accountA.then( + () => null, + (error: unknown) => error + ); + + const accountB = service.getRuntimeStatus("account-b"); + await waitFor(() => bridge.pendingPreparations.length === 2); + bridge.resolvePreparation(1, 22); + await expect(accountB).resolves.toEqual({ running: false }); + + bridge.resolvePreparation(0, 11); + expect(await accountAOutcome).toHaveProperty( + "message", + "Remote Agent target preparation was superseded" + ); + expect(bridge.invocations).toHaveLength(1); + expect(bridge.invocations[0].lease).toMatchObject({ + accountId: "account-b", + hostEpoch: "22", + connectionGeneration: 22 + }); + }); + + test("A to B to A handoff cannot insert or later revive A's retired subscription", async () => { + const bridge = new ControlledPrepareBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const retiredEvents: AgentEventEnvelope[] = []; + + const subscribeA = service.listenToEvents("account-a", (event) => retiredEvents.push(event)); + const subscribeAOutcome = subscribeA.then( + () => null, + (error: unknown) => error + ); + await waitFor(() => bridge.pendingPreparations.length === 1); + bridge.resolvePreparation(0, 51); + + // completeRemotePreparation installs A and queues the listenToEvents + // continuation. Resume the test between those two handoff microtasks, then + // synchronously let B retire A before A can enter remoteSubscriptions. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const commandB = service.getRuntimeStatus("account-b"); + await waitFor(() => bridge.pendingPreparations.length === 2); + bridge.resolvePreparation(1, 52); + await expect(commandB).resolves.toEqual({ running: false }); + + expect(await subscribeAOutcome).toHaveProperty( + "message", + "Remote Agent execution lease changed before event subscription handoff" + ); + expect(bridge.subscriptions).toEqual([]); + + const commandA = service.getRuntimeStatus("account-a"); + await waitFor(() => bridge.pendingPreparations.length === 3); + bridge.resolvePreparation(2, 53); + await expect(commandA).resolves.toEqual({ running: false }); + + // Returning to account A refreshes command authority only. The retired + // late subscription was never inserted, so it cannot silently rebind. + expect(bridge.subscriptions).toEqual([]); + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + connectionGeneration: 53, + sessionId: "session-1", + runId: "must-not-deliver" + }); + expect(retiredEvents).toEqual([]); + }); + + test("coalesces concurrent same-account status and paged-load preparation", async () => { + const bridge = new ControlledPrepareBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + const status = service.getRuntimeStatus("user-a"); + const sessions = service.listSessionsPage("user-a", { limit: 25 }); + await waitFor(() => bridge.pendingPreparations.length === 1); + bridge.resolvePreparation(0, 41); + + await expect(status).resolves.toEqual({ running: false }); + await expect(sessions).resolves.toEqual({ items: [], nextCursor: null }); + expect(bridge.preparedTargets).toEqual([remote]); + expect(bridge.invocations.map(({ invocation }) => invocation.operation).sort()).toEqual([ + "getRuntimeStatus", + "listSessionsPage" + ]); + expect(bridge.invocations[0].lease).toMatchObject({ + accountId: "user-a", + hostEpoch: "41", + connectionGeneration: 41 + }); + expect(bridge.invocations[1].lease).toMatchObject({ + accountId: "user-a", + hostEpoch: "41", + connectionGeneration: 41 + }); + }); + + test("a generation refresh supersedes an initial async bind without closing the logical subscription", async () => { + const bridge = new ControlledEventBindBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const events: AgentEventEnvelope[] = []; + + const subscription = service.listenToEvents("user-a", (event) => events.push(event)); + await waitFor(() => bridge.pendingBinds.length === 1); + const initialLease = bridge.pendingBinds[0].lease; + + bridge.rotateLease("user-a", remote); + const refreshCommand = service.getRuntimeStatus("user-a"); + await waitFor(() => bridge.pendingBinds.length === 2); + const refreshedLease = bridge.pendingBinds[1].lease; + expect(refreshedLease.connectionGeneration).not.toBe(initialLease.connectionGeneration); + + bridge.resolveBind(1); + await expect(refreshCommand).resolves.toEqual({ running: false }); + bridge.resolveBind(0); + const unlisten = await subscription; + + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + connectionGeneration: initialLease.connectionGeneration, + sessionId: "session-1", + runId: "old-run" + }); + expect(events).toEqual([]); + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + hostEpoch: refreshedLease.hostEpoch, + connectionGeneration: refreshedLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 1, + sessionId: "session-1", + runId: "new-run" + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ runId: "new-run" }); + + unlisten(); + }); + + test("a stale initial native bind rejection cannot close its successful generation replacement", async () => { + const bridge = new ControlledEventBindBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const events: AgentEventEnvelope[] = []; + + const subscription = service.listenToEvents("user-a", (event) => events.push(event)); + await waitFor(() => bridge.pendingBinds.length === 1); + const initialLease = bridge.pendingBinds[0].lease; + + bridge.rotateLease("user-a", remote); + const refreshCommand = service.getRuntimeStatus("user-a"); + await waitFor(() => bridge.pendingBinds.length === 2); + const replacementLease = bridge.pendingBinds[1].lease; + + bridge.resolveBind(1); + await expect(refreshCommand).resolves.toEqual({ running: false }); + bridge.rejectBind(0, new Error("generation 1 socket closed")); + const unlisten = await subscription; + + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + connectionGeneration: initialLease.connectionGeneration, + sessionId: "session-1", + runId: "old-run" + }); + expect(events).toEqual([]); + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + hostEpoch: replacementLease.hostEpoch, + connectionGeneration: replacementLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 1, + sessionId: "session-1", + runId: "new-run" + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ runId: "new-run" }); + + unlisten(); + }); + + test("account B retirement rejects account A when its pending initial bind later resolves", async () => { + const bridge = new ControlledEventBindBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + const subscriptionA = service.listenToEvents("account-a", () => {}); + const subscriptionAOutcome = subscriptionA.then( + () => null, + (error: unknown) => error + ); + await waitFor(() => bridge.pendingBinds.length === 1); + + let accountBSettled = false; + const accountB = service.getRuntimeStatus("account-b").finally(() => { + accountBSettled = true; + }); + await Promise.resolve(); + expect(accountBSettled).toBe(false); + bridge.resolveBind(0); + + expect(await subscriptionAOutcome).toHaveProperty( + "message", + "Remote Agent event subscription was retired before its initial bind completed" + ); + await expect(accountB).resolves.toEqual({ running: false }); + expect(bridge.listeners.get(remote.id)?.size ?? 0).toBe(0); + }); + + test("account B retirement rejects account A when its pending initial bind later rejects", async () => { + const bridge = new ControlledEventBindBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + const subscriptionA = service.listenToEvents("account-a", () => {}); + const subscriptionAOutcome = subscriptionA.then( + () => null, + (error: unknown) => error + ); + await waitFor(() => bridge.pendingBinds.length === 1); + + let accountBSettled = false; + const accountB = service.getRuntimeStatus("account-b").finally(() => { + accountBSettled = true; + }); + await Promise.resolve(); + expect(accountBSettled).toBe(false); + bridge.rejectBind(0, new Error("retired account socket closed")); + + expect(await subscriptionAOutcome).toHaveProperty( + "message", + "Remote Agent event subscription was retired before its initial bind completed" + ); + await expect(accountB).resolves.toEqual({ running: false }); + }); + + test("account B preparation synchronously retires account A's subscription", async () => { + const bridge = new ControlledPrepareBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const accountAEvents: AgentEventEnvelope[] = []; + const accountBEvents: AgentEventEnvelope[] = []; + + const subscribeA = service.listenToEvents("account-a", (event) => accountAEvents.push(event)); + await waitFor(() => bridge.pendingPreparations.length === 1); + bridge.resolvePreparation(0, 31); + await subscribeA; + const accountALease = bridge.subscriptions[0].lease!; + + const subscribeB = service.listenToEvents("account-b", (event) => accountBEvents.push(event)); + await waitFor(() => bridge.pendingPreparations.length === 2); + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + connectionGeneration: accountALease.connectionGeneration, + sessionId: "session-1", + runId: "run-a" + }); + expect(accountAEvents).toEqual([]); + + bridge.resolvePreparation(1, 32); + await subscribeB; + const accountBLease = bridge.subscriptions[1].lease!; + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + hostEpoch: accountBLease.hostEpoch, + connectionGeneration: accountBLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 1, + sessionId: "session-1", + runId: "run-b" + }); + expect(accountAEvents).toEqual([]); + expect(accountBEvents).toHaveLength(1); + }); + + test("a non-desktop A to B transition drains delayed operations and subscription binds", async () => { + const fence = new AgentOperationFence(); + const bridge = new FencedControlledEventBridge(fence); + const baseService = new AgentRuntimeService(bridge); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = baseService.forTarget(remote); + const activations: string[] = []; + const desktopCalls: string[] = []; + const coordinator = new AgentAuthLifecycleCoordinator( + async (userId) => + await retireAgentAuthAccount(userId, { + blockAndDrain: async (accountId) => await fence.blockAndDrain(accountId), + retireRemoteAccount: async (accountId) => await baseService.retireAccount(accountId), + isDesktop: () => false, + stopLocalHost: async (accountId) => { + desktopCalls.push(`stop:${accountId}`); + return { status: { running: false }, acpShutdownError: null }; + }, + clearLocalAuth: async (accountId) => { + desktopCalls.push(`clear:${accountId}`); + }, + stopLocalProxy: async () => { + desktopCalls.push("proxy"); + } + }), + async (userId) => { + activations.push(userId); + fence.activateUserSession(userId); + } + ); + await coordinator.transitionTo("account-a"); + + let finishStatus: ((value: unknown) => void) | undefined; + bridge.runtimeStatusResult = new Promise((resolve) => { + finishStatus = resolve; + }); + const delayedStatus = service.getRuntimeStatus("account-a"); + const delayedSubscription = service.listenToEvents("account-a", () => {}); + await waitFor(() => bridge.invocations.length === 1 && bridge.pendingBinds.length === 1); + + let transitionSettled = false; + const transitionB = coordinator.transitionTo("account-b").finally(() => { + transitionSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(transitionSettled).toBe(false); + expect(activations).toEqual(["account-a"]); + + finishStatus?.({ running: false }); + bridge.resolveBind(0); + await expect(delayedStatus).resolves.toEqual({ running: false }); + const lateUnlisten = await delayedSubscription; + await transitionB; + + expect(activations).toEqual(["account-a", "account-b"]); + expect(desktopCalls).toEqual([]); + expect(bridge.listeners.get(remote.id)?.size ?? 0).toBe(0); + lateUnlisten(); + await expect(service.getRuntimeStatus("account-a")).rejects.toBeInstanceOf( + AgentOperationsBlockedError + ); + }); + + test("auth retirement drains a custom-bridge scope with a delayed initial bind", async () => { + const accountId = "patch3-custom-bind-account"; + activateAgentRuntimeAccountResources(accountId); + const bridge = new ControlledEventBindBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_custom_bind"); + const service = new AgentRuntimeService(bridge, remote); + try { + const subscription = service.listenToEvents(accountId, () => {}); + const subscriptionOutcome = subscription.then( + () => null, + (error: unknown) => error + ); + await waitFor(() => bridge.pendingBinds.length === 1); + + let retired = false; + const retirement = retireAgentRuntimeAccountResources(accountId).finally(() => { + retired = true; + }); + await Promise.resolve(); + expect(retired).toBe(false); + + bridge.resolveBind(0); + expect(await subscriptionOutcome).toHaveProperty( + "message", + "Remote Agent event subscription was retired before its initial bind completed" + ); + await retirement; + expect(bridge.listeners.get(remote.id)?.size ?? 0).toBe(0); + } finally { + activateAgentRuntimeAccountResources(accountId); + } + }); + + test("auth retirement owns a custom-bridge attach before its native ID arrives", async () => { + const accountId = "patch3-pending-attach-account"; + activateAgentRuntimeAccountResources(accountId); + const bridge = new ControlledBeginBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_pending_attach"); + const service = new AgentRuntimeService(bridge, remote); + try { + const attach = service.beginSessionHistoryAttach( + accountId, + { sessionId: "session" }, + () => {} + ); + const attachOutcome = attach.then( + () => null, + (error: unknown) => error + ); + await waitFor(() => bridge.pendingBegins.length === 1); + + let retired = false; + const retirement = retireAgentRuntimeAccountResources(accountId).finally(() => { + retired = true; + }); + await Promise.resolve(); + expect(retired).toBe(false); + + bridge.resolveBegin(0); + expect(await attachOutcome).toHaveProperty( + "message", + "Agent history attachment owner retired while opening" + ); + await retirement; + expect(bridge.pendingAttachCancels).toEqual(["attach-1"]); + } finally { + activateAgentRuntimeAccountResources(accountId); + } + }); + + test("auth retirement waits for a replacement opened by an ordinary invocation", async () => { + const accountId = "patch3-ordinary-rebind-account"; + activateAgentRuntimeAccountResources(accountId); + const bridge = new ControlledResumeBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_ordinary_rebind"); + const service = new AgentRuntimeService(bridge, remote); + try { + const pending = await service.beginSessionHistoryAttach( + accountId, + { sessionId: "session" }, + () => {} + ); + await pending.activate(); + bridge.rotateLease(accountId, remote); + const refresh = service.getRuntimeStatus(accountId); + await waitFor(() => bridge.pendingResumes.length === 1); + + let retired = false; + const retirement = retireAgentRuntimeAccountResources(accountId).finally(() => { + retired = true; + }); + await Promise.resolve(); + expect(retired).toBe(false); + + bridge.resolveResume(0, { + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 0 }, + liveStreamId: "retired-replacement" + }); + await expect(refresh).rejects.toThrow("superseded"); + await retirement; + expect(bridge.liveStreamCancels).toEqual(["attach-1", "retired-replacement"]); + } finally { + activateAgentRuntimeAccountResources(accountId); + } + }); + + test("a fresh service retries a failed subscription cleanup before rebinding", async () => { + const bridge = new FailingUnlistenBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_subscription_remount"); + const firstService = new AgentRuntimeService(bridge, remote); + const unlisten = await firstService.listenToEvents( + "patch3-subscription-remount-account", + () => {} + ); + bridge.failNextUnlisten(); + + unlisten(); + await Promise.resolve(); + await Promise.resolve(); + expect(bridge.unlistenCalls).toBe(1); + expect(bridge.listeners.get(remote.id)?.size ?? 0).toBe(1); + + const replacementService = new AgentRuntimeService(bridge, remote); + const replacementUnlisten = await replacementService.listenToEvents( + "patch3-subscription-remount-account", + () => {} + ); + expect(bridge.unlistenCalls).toBe(2); + expect(bridge.listeners.get(remote.id)?.size ?? 0).toBe(1); + + replacementUnlisten(); + await Promise.resolve(); + expect(bridge.listeners.get(remote.id)?.size ?? 0).toBe(0); + }); + + test("lifecycle and destructive operations use the target lease and semantic host adapter", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + await expect(service.stopRuntime("user-a")).resolves.toMatchObject({ + status: { running: false }, + acpShutdownError: null + }); + await service.clearUserData("user-a"); + await service.clearUserHistory("user-a"); + + expect(bridge.invocations.map(({ invocation }) => invocation)).toEqual([ + { operation: "stopRuntime" }, + { operation: "clearUserData" }, + { operation: "clearUserHistory" } + ]); + expect(bridge.invocations.every(({ lease }) => lease.accountId === "user-a")).toBe(true); + expect(JSON.stringify(bridge.invocations)).not.toContain('"userId"'); + }); + + test("remote clear helper stops the host before clearing through target authority", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + const block = await clearAgentDataForUser("user-a", service); + + expect(bridge.invocations.map(({ invocation }) => invocation.operation)).toEqual([ + "stopRuntime", + "clearUserData" + ]); + expect(() => block.release()).not.toThrow(); + }); + + test("validates opaque remote IDs independently from display names", () => { + const target = createRemoteAgentExecutionTarget( + "dev:01J4Z3N9Y5K7QX2P8B6C0R1TWA", + "Ada's MacBook Pro" + ); + + expect(String(target.id)).toBe("dev:01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + expect(target.displayName).toBe("Ada's MacBook Pro"); + for (const invalidId of [ + 123, + "", + "local", + "human readable name", + "unicode-🪿", + "a".repeat(129) + ]) { + expect(() => createRemoteAgentExecutionTarget(invalidId)).toThrow(); + } + expect(() => createRemoteAgentExecutionTarget("device-1", 123)).toThrow( + "display name must be a string" + ); + expect(String(createRemoteAgentExecutionTarget("a".repeat(128)).id)).toBe("a".repeat(128)); + }); + + test("constructs a closed remote create response and rejects hostile timeline authority", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const rawSummary = { + id: "session-1", + title: "New task", + projectRoot: "/workspace", + createdMs: 1, + updatedMs: 1, + pageSortMs: 1, + messageCount: 0, + model: null, + mode: "smart_approve" + }; + const rawDetail = { session: rawSummary, timeline: [], mcpErrors: [] }; + bridge.createSessionResult = rawDetail; + + const detail = await service.createSession("user-a"); + expect(detail).toEqual(rawDetail); + expect(detail).not.toBe(rawDetail); + expect(detail.session).not.toBe(rawSummary); + + for (const hostile of [ + { ...rawDetail, providerExtension: "secret" }, + { ...rawDetail, timeline: [closedLiveItem("input", { input: { secret: true } })] }, + { ...rawDetail, timeline: [closedLiveItem("output", { output: "secret" })] }, + { ...rawDetail, timeline: [closedLiveItem("unknown", { providerExtension: true })] } + ]) { + bridge.createSessionResult = hostile; + await expect(service.createSession("user-a")).rejects.toThrow( + 'invalid result for "createSession"' + ); + } + }); + + test("exposes paged remote history operations and rejects unpaged compatibility calls", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + await service.listSessionsPage("user-a", { + projectRoot: "/workspace", + cursor: "sessions:50", + limit: 50 + }); + await service.listSessionRecordsPage("user-a", { + sessionId: "session-1", + cursor: "timeline:100", + limit: 50 + }); + + expect(bridge.invocations.map(({ invocation }) => invocation)).toEqual([ + { + operation: "listSessionsPage", + request: { + request: { projectRoot: "/workspace", cursor: "sessions:50", limit: 50 } + } + }, + { + operation: "listSessionRecordsPage", + request: { + request: { sessionId: "session-1", cursor: "timeline:100", limit: 50 } + } + } + ]); + await expect(service.listSessions("user-a")).rejects.toThrow( + '"listSessions" is embedded-only compatibility' + ); + await expect(service.loadSession("user-a", "session-1")).rejects.toThrow( + '"loadSession" is embedded-only compatibility' + ); + await expect(service.listSessionsPage("user-a", { limit: 51 })).rejects.toThrow( + "page limit must be between 1 and 50" + ); + await expect( + service.listSessionsPage("user-a", { + cursor: 7 + } as unknown as AgentListSessionsPageRequest) + ).rejects.toThrow("cursor must be non-empty bounded ASCII"); + await expect( + service.listSessionsPage("user-a", { + projectRoot: 7 + } as unknown as AgentListSessionsPageRequest) + ).rejects.toThrow("project root must be a string or null"); + await expect( + service.listSessionRecordsPage("user-a", { + sessionId: 7 + } as unknown as AgentListSessionRecordsPageRequest) + ).rejects.toThrow("session ID must be a non-empty string"); + + bridge.sessionPageResult = { items: [], nextCursor: 7 }; + await expect(service.listSessionsPage("user-a")).rejects.toThrow( + 'invalid result for "listSessionsPage"' + ); + bridge.sessionPageResult = { + items: [ + { + id: "session-1", + title: "Session", + projectRoot: 7, + createdMs: 1, + updatedMs: 2, + pageSortMs: 2, + messageCount: 0, + mode: "auto" + } + ], + nextCursor: null + }; + await expect(service.listSessionsPage("user-a")).rejects.toThrow( + 'invalid result for "listSessionsPage"' + ); + }); + + test("constructs bounded closed session pages and rejects unsafe summaries", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const rawSummary = { + id: "session-1", + title: "Task", + projectRoot: "/workspace", + createdMs: 1, + updatedMs: 2, + pageSortMs: 2, + messageCount: 3, + model: "model-1", + mode: "smart_approve" + }; + const rawPage = { items: [rawSummary], nextCursor: "next" }; + bridge.sessionPageResult = rawPage; + + const decoded = await service.listSessionsPage("user-a"); + expect(decoded).toEqual(rawPage); + expect(decoded).not.toBe(rawPage); + expect(decoded.items[0]).not.toBe(rawSummary); + + const hostileSummaries = [ + { ...rawSummary, providerExtension: true }, + { ...rawSummary, messageCount: Number.MAX_SAFE_INTEGER + 1 }, + { ...rawSummary, createdMs: -1 }, + { ...rawSummary, title: "unsafe\ncontrol" }, + { ...rawSummary, title: "spoof\u202Etxt" }, + { ...rawSummary, mode: "\u061Cauto" }, + { ...rawSummary, title: "x".repeat(1_025) } + ]; + for (const summary of hostileSummaries) { + bridge.sessionPageResult = { items: [summary], nextCursor: null }; + await expect(service.listSessionsPage("user-a")).rejects.toThrow( + 'invalid result for "listSessionsPage"' + ); + } + bridge.sessionPageResult = { ...rawPage, unknown: true }; + await expect(service.listSessionsPage("user-a")).rejects.toThrow( + 'invalid result for "listSessionsPage"' + ); + }); + + test("enforces the requested page limit and cursor progress on returned pages", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const summary = (id: string): AgentSessionSummary => ({ + id, + title: id, + projectRoot: "/workspace", + createdMs: 1, + updatedMs: 1, + pageSortMs: 1, + messageCount: 0, + mode: "smart_approve" + }); + + bridge.sessionPageResult = { items: [summary("one"), summary("two")], nextCursor: null }; + await expect(service.listSessionsPage("user-a", { limit: 1 })).rejects.toThrow( + "exceeded the requested record limit" + ); + + bridge.sessionPageResult = { items: [summary("one")], nextCursor: "same" }; + await expect(service.listSessionsPage("user-a", { cursor: "same", limit: 1 })).rejects.toThrow( + "cursor did not progress" + ); + + bridge.recordPageResult = { + records: [ + { recordId: "r1", role: "user", createdMs: 1, items: [] }, + { recordId: "r2", role: "assistant", createdMs: 2, items: [] } + ], + nextCursor: null, + historyRevision: "history-1" + }; + await expect( + service.listSessionRecordsPage("user-a", { sessionId: "session", limit: 1 }) + ).rejects.toThrow("exceeded the requested record limit"); + + bridge.recordPageResult = { + records: [], + nextCursor: "later", + historyRevision: "history-1" + }; + await expect( + service.listSessionRecordsPage("user-a", { sessionId: "session", limit: 1 }) + ).rejects.toThrow("cursor without records"); + }); + + test("keeps ordinary history pages strictly unsynchronized", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const liveItem = { + id: "live-1", + itemType: "message", + role: "assistant", + text: "live", + createdMs: 1, + merge: "replace" + } as const; + + bridge.recordPageResult = { + records: [], + nextCursor: null, + historyRevision: "history-1", + liveItems: [liveItem] + }; + await expect( + service.listSessionRecordsPage("user-a", { sessionId: "session" }) + ).rejects.toThrow('invalid result for "listSessionRecordsPage"'); + + bridge.recordPageResult = { + records: [], + nextCursor: null, + historyRevision: "history-1", + throughEventCursor: { journalId: "journal", sequence: 0 } + }; + await expect( + service.listSessionRecordsPage("user-a", { sessionId: "session" }) + ).rejects.toThrow('invalid result for "listSessionRecordsPage"'); + + bridge.recordPageResult = { + records: [], + nextCursor: null, + historyRevision: "history-1", + liveItems: [liveItem], + throughEventCursor: { journalId: "journal", sequence: 0 } + }; + await expect( + service.listSessionRecordsPage("user-a", { sessionId: "session" }) + ).rejects.toThrow('invalid result for "listSessionRecordsPage"'); + + bridge.recordPageResult = { + records: [], + nextCursor: null, + historyRevision: "history-1", + liveItems: [{ ...liveItem, merge: "append" }], + throughEventCursor: { journalId: "journal", sequence: 0 } + }; + await expect( + service.listSessionRecordsPage("user-a", { sessionId: "session" }) + ).rejects.toThrow('invalid result for "listSessionRecordsPage"'); + }); + + test("preserves hidden native message rows with non-chat roles", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const expectedPage: AgentSessionRecordsPage = { + records: [{ recordId: "provider-row", role: "developer", createdMs: 1, items: [] }], + nextCursor: "older", + historyRevision: "history-1" + }; + bridge.recordPageResult = expectedPage; + + await expect( + service.listSessionRecordsPage("user-a", { sessionId: "session" }) + ).resolves.toEqual(expectedPage); + + for (const role of ["developer\nspoof", "developer\u202espoof"]) { + bridge.recordPageResult = { + ...expectedPage, + records: [{ ...expectedPage.records[0], role }] + }; + await expect( + service.listSessionRecordsPage("user-a", { sessionId: "session" }) + ).rejects.toThrow('invalid result for "listSessionRecordsPage"'); + } + }); + + test("begins, activates, and cancels a fully validated synchronized history stream", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const frames: AgentLiveChannelFrame[] = []; + + const pending = await service.beginSessionHistoryAttach( + "user-a", + { sessionId: "session", limit: 25 }, + (frame) => frames.push(frame) + ); + expect(pending.response).toEqual(synchronizedAttachResult()); + // begin does not use the ordinary invocation list; inspect the bridge's + // current verified lease through the next fenced operation. + await service.getRuntimeStatus("user-a"); + const currentLease = bridge.invocations.at(-1)!.lease; + + bridge.liveHandlers[0]({ + liveEventVersion: 1, + eventType: "runStarted", + targetId: remote.id, + hostEpoch: `${currentLease.hostEpoch}:stale`, + connectionGeneration: currentLease.connectionGeneration, + eventEpoch: TEST_JOURNAL_ID, + eventSequence: 1, + sessionId: "session", + runId: "stale" + }); + expect(frames).toEqual([]); + bridge.liveHandlers[0]({ + liveEventVersion: 1, + eventType: "runStarted", + targetId: createRemoteAgentExecutionTarget("other-target").id, + hostEpoch: currentLease.hostEpoch, + connectionGeneration: currentLease.connectionGeneration, + eventEpoch: TEST_JOURNAL_ID, + eventSequence: 1, + sessionId: "session", + runId: "wrong-target" + }); + bridge.liveHandlers[0]({ + liveEventVersion: 1, + eventType: "runStarted", + targetId: remote.id, + hostEpoch: currentLease.hostEpoch, + connectionGeneration: currentLease.connectionGeneration + 1, + eventEpoch: TEST_JOURNAL_ID, + eventSequence: 1, + sessionId: "session", + runId: "wrong-generation" + }); + bridge.liveHandlers[0]({ + eventType: "notAnAgentEvent", + targetId: remote.id, + hostEpoch: currentLease.hostEpoch, + connectionGeneration: currentLease.connectionGeneration + }); + expect(frames).toEqual([]); + bridge.liveHandlers[0]({ + liveEventVersion: 1, + eventType: "runStarted", + targetId: remote.id, + hostEpoch: currentLease.hostEpoch, + connectionGeneration: currentLease.connectionGeneration, + eventEpoch: TEST_JOURNAL_ID, + eventSequence: 1, + sessionId: "session", + runId: "run" + }); + expect(frames).toMatchObject([{ eventType: "runStarted", runId: "run" }]); + + const active = await pending.activate(); + expect(active.liveStreamId).toBe("attach-1"); + await active.cancel(); + await active.cancel(); + expect(bridge.liveStreamCancels).toEqual(["attach-1"]); + }); + + test("rejects malformed complete snapshots and cancels their paused lease", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + bridge.attachResult = { + ...(synchronizedAttachResult() as unknown as Record), + liveSessionCount: 1, + liveSessions: [] + }; + + await expect( + service.beginSessionHistoryAttach("user-a", { sessionId: "session" }, () => {}) + ).rejects.toThrow("invalid synchronized history attachment"); + expect(bridge.pendingAttachCancels).toEqual(["attach-1"]); + }); + + test("validates synchronized session ordering by native UTF-8 byte order", async () => { + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const correctlyOrdered = [ + { sessionId: "session-\ue000", liveItems: [closedLiveItem("bmp")] }, + { sessionId: "session-🪿", liveItems: [closedLiveItem("supplementary")] } + ]; + const bridge = new RecordingTargetBridge(); + bridge.attachResult = { + ...synchronizedAttachResult(), + liveSessionCount: 2, + liveSessions: correctlyOrdered + }; + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "user-a", + { sessionId: "session" }, + () => {} + ); + expect(pending.response.liveSessions.map(({ sessionId }) => sessionId)).toEqual( + correctlyOrdered.map(({ sessionId }) => sessionId) + ); + await pending.cancel(); + + const reversedBridge = new RecordingTargetBridge(); + reversedBridge.attachResult = { + ...synchronizedAttachResult(), + liveSessionCount: 2, + liveSessions: [...correctlyOrdered].reverse() + }; + const reversedService = new AgentRuntimeService(reversedBridge, remote); + await expect( + reversedService.beginSessionHistoryAttach("user-a", { sessionId: "session" }, () => {}) + ).rejects.toThrow("invalid synchronized live sessions"); + expect(reversedBridge.pendingAttachCancels).toEqual(["attach-1"]); + }); + + test("rejects unsafe synchronized history and live items before installing the snapshot", async () => { + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const unsafeItems = [ + closedLiveItem("provider-json", { input: { token: "secret" } }), + closedLiveItem("extra-field", { providerExtension: "secret" }), + closedLiveItem("raw-tool", { + itemType: "tool", + role: "assistant", + title: "curl --header Authorization: secret", + text: "/Users/alice/.env", + status: "failed" + }), + closedLiveItem("actionable-permission", { + itemType: "permission", + role: "system", + title: "Tool permission", + text: undefined, + status: "pending" + }), + closedLiveItem("oversized-title", { title: "🪿".repeat(300) }), + closedLiveItem("bidi-\u202espoof") + ]; + + for (const [index, item] of unsafeItems.entries()) { + const bridge = new RecordingTargetBridge(); + const service = new AgentRuntimeService(bridge, remote); + bridge.attachResult = { + ...synchronizedAttachResult(), + attachId: `attach-${index}`, + page: { + records: [{ recordId: `row-${index}`, role: "assistant", createdMs: 1, items: [item] }], + historyRevision: "history-1" + } + }; + await expect( + service.beginSessionHistoryAttach("user-a", { sessionId: "session" }, () => {}) + ).rejects.toThrow("invalid synchronized history attachment"); + expect(bridge.pendingAttachCancels).toEqual([`attach-${index}`]); + } + + const bridge = new RecordingTargetBridge(); + const service = new AgentRuntimeService(bridge, remote); + bridge.attachResult = { + ...synchronizedAttachResult(), + liveSessionCount: 1, + liveSessions: [ + { sessionId: "session", liveItems: [closedLiveItem("unsafe-live", { output: "secret" })] } + ] + }; + await expect( + service.beginSessionHistoryAttach("user-a", { sessionId: "session" }, () => {}) + ).rejects.toThrow("invalid synchronized live suffix"); + expect(bridge.pendingAttachCancels).toEqual(["attach-1"]); + + const oversizedBridge = new RecordingTargetBridge(); + const oversizedService = new AgentRuntimeService(oversizedBridge, remote); + oversizedBridge.attachResult = { + ...synchronizedAttachResult(), + page: { + records: [ + { + recordId: "oversized-row", + role: "assistant", + createdMs: 1, + items: Array.from({ length: 6 }, (_, index) => + closedLiveItem(`large-${index}`, { text: "x".repeat(192 * 1024) }) + ) + } + ], + historyRevision: "history-1" + } + }; + await expect( + oversizedService.beginSessionHistoryAttach("user-a", { sessionId: "session" }, () => {}) + ).rejects.toThrow("invalid synchronized history attachment"); + expect(oversizedBridge.pendingAttachCancels).toEqual(["attach-1"]); + }); + + test("decodes every exact closed v1 live variant and rejects compatibility or unsafe frames", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const frames: AgentLiveChannelFrame[] = []; + await service.beginSessionHistoryAttach("user-a", { sessionId: "session" }, (frame) => + frames.push(frame) + ); + await service.getRuntimeStatus("user-a"); + const lease = bridge.invocations.at(-1)!.lease; + const summary = { + id: "session", + title: "Task", + projectRoot: "/workspace", + createdMs: 1, + updatedMs: 2, + pageSortMs: 3, + messageCount: 4, + mode: "smart_approve" + }; + const variants = [ + { eventType: "runStarted", runId: "run" }, + { eventType: "timelineUpsert", runId: "run", item: closedLiveItem("message") }, + { eventType: "timelineCleared", runId: "run", reason: "run_started" }, + { eventType: "historyReplaced", runId: "run" }, + { eventType: "cursorAdvanced" }, + { eventType: "sessionUpdated", session: summary }, + { eventType: "runFinished", runId: "run", terminal: "completed" }, + { eventType: "sessionDeleted" }, + { + eventType: "userFacingError", + runId: "run", + item: closedLiveItem("safe-error", { + itemType: "error", + role: "system", + title: "Agent error", + text: "The Agent task failed. Open the host for additional diagnostic details.", + status: "failed" + }) + }, + { eventType: "timelineCleared", reason: "explicit_reload" } + ]; + variants.forEach((variant, index) => + bridge.liveHandlers[0](orderedLiveFrame(lease, remote, index + 1, variant)) + ); + expect(frames.map((frame) => frame.eventType)).toEqual([ + "runStarted", + "timelineUpsert", + "timelineCleared", + "historyReplaced", + "cursorAdvanced", + "sessionUpdated", + "runFinished", + "sessionDeleted", + "userFacingError", + "timelineCleared" + ]); + + const invalidFrames = [ + orderedLiveFrame(lease, remote, 11, { eventType: "timelineItem", item: closedLiveItem() }), + orderedLiveFrame(lease, remote, 11, { + eventType: "runFinished", + runId: "run", + message: "completed" + }), + orderedLiveFrame(lease, remote, 11, { eventType: "cursorAdvanced", runId: "run" }), + orderedLiveFrame(lease, remote, 11, { + eventType: "timelineCleared", + runId: "run", + reason: "explicit_reload" + }), + orderedLiveFrame(lease, remote, 11, { + eventType: "sessionUpdated", + session: { ...summary, id: "other" } + }), + orderedLiveFrame(lease, remote, 11, { + eventType: "timelineUpsert", + item: closedLiveItem("unsafe", { input: { token: "secret" } }) + }), + { ...orderedLiveFrame(lease, remote, 11, { eventType: "cursorAdvanced" }), extra: true }, + { + ...orderedLiveFrame(lease, remote, 11, { eventType: "cursorAdvanced" }), + liveEventVersion: 2 + } + ]; + invalidFrames.forEach((frame) => bridge.liveHandlers[0](frame)); + expect(frames).toHaveLength(10); + }); + + test("cancels and resumes an active synchronized stream from its retained cursor on lease rotation", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const frames: AgentLiveChannelFrame[] = []; + const pending = await service.beginSessionHistoryAttach( + "user-a", + { sessionId: "session" }, + (frame) => frames.push(frame) + ); + const active = await pending.activate(); + await service.getRuntimeStatus("user-a"); + const oldLease = bridge.invocations.at(-1)!.lease; + bridge.liveHandlers[0](orderedLiveFrame(oldLease, remote, 1, { eventType: "cursorAdvanced" })); + expect(frames).toHaveLength(1); + + bridge.rotateLease("user-a", remote); + bridge.resumeResult = { + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 1 }, + liveStreamId: "replacement-stream" + }; + await service.getRuntimeStatus("user-a"); + const replacementLease = bridge.liveResumes[0].lease!; + + expect(bridge.liveStreamCancels).toEqual(["attach-1"]); + expect(bridge.liveResumes).toMatchObject([ + { + cursor: { journalId: TEST_JOURNAL_ID, sequence: 1 }, + lease: { + hostEpoch: replacementLease.hostEpoch, + connectionGeneration: replacementLease.connectionGeneration + } + } + ]); + + bridge.liveHandlers[0](orderedLiveFrame(oldLease, remote, 2, { eventType: "cursorAdvanced" })); + expect(frames).toHaveLength(1); + bridge.liveHandlers[1]( + orderedLiveFrame(replacementLease, remote, 2, { eventType: "cursorAdvanced" }) + ); + expect(frames).toHaveLength(2); + expect(active.throughEventCursor).toEqual({ journalId: TEST_JOURNAL_ID, sequence: 2 }); + + await active.cancel(); + expect(bridge.liveStreamCancels).toEqual(["attach-1", "replacement-stream"]); + }); + + test("notifies an active stream when lease replacement cannot resume", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const frames: AgentLiveChannelFrame[] = []; + const pending = await service.beginSessionHistoryAttach( + "user-a", + { sessionId: "session" }, + (frame) => frames.push(frame) + ); + const active = await pending.activate(); + bridge.rotateLease("user-a", remote); + bridge.resumeError = new Error("replacement socket unavailable"); + + await expect(service.getRuntimeStatus("user-a")).rejects.toThrow( + "replacement socket unavailable" + ); + expect(bridge.liveStreamCancels).toEqual(["attach-1"]); + expect(frames).toEqual([ + expect.objectContaining({ + eventType: "snapshotRequired", + reason: "ordering_lost", + lastEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 0 } + }) + ]); + await expect(active.cancel()).resolves.toBeUndefined(); + }); + + test("retains a malformed replacement ID when its cleanup fails and retries it exactly", async () => { + const bridge = new SelectiveFailingLiveCancelBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_malformed_replacement"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "patch3-malformed-account", + { sessionId: "session" }, + () => {} + ); + const active = await pending.activate(); + bridge.rotateLease("patch3-malformed-account", remote); + bridge.resumeResult = { + throughEventCursor: { journalId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", sequence: 0 }, + liveStreamId: "malformed-replacement" + }; + const cleanupError = new Error("replacement cleanup failed"); + bridge.failNextCancellation("malformed-replacement", cleanupError); + + await expect(service.getRuntimeStatus("patch3-malformed-account")).rejects.toBe(cleanupError); + expect(bridge.liveStreamCancels).toEqual(["attach-1", "malformed-replacement"]); + + bridge.resumeResult = { + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 0 }, + liveStreamId: "recovered-replacement" + }; + await expect(service.getRuntimeStatus("patch3-malformed-account")).resolves.toEqual({ + running: false + }); + expect(bridge.liveStreamCancels).toEqual([ + "attach-1", + "malformed-replacement", + "malformed-replacement" + ]); + await active.cancel(); + expect(bridge.liveStreamCancels.at(-1)).toBe("recovered-replacement"); + }); + + test("retains a stale late-opened replacement ID when exact cleanup fails", async () => { + const bridge = new ControlledResumeBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_stale_replacement"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "patch3-stale-account-a", + { sessionId: "session" }, + () => {} + ); + await pending.activate(); + bridge.rotateLease("patch3-stale-account-a", remote); + + const refreshA = service.getRuntimeStatus("patch3-stale-account-a"); + await waitFor(() => bridge.pendingResumes.length === 1); + let accountBSettled = false; + const accountB = service.getRuntimeStatus("patch3-stale-account-b").finally(() => { + accountBSettled = true; + }); + await Promise.resolve(); + expect(accountBSettled).toBe(false); + + const cleanupError = new Error("stale replacement cleanup failed"); + bridge.failNextCancellation("stale-replacement", cleanupError); + bridge.resolveResume(0, { + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 0 }, + liveStreamId: "stale-replacement" + }); + await expect(refreshA).rejects.toBe(cleanupError); + await expect(accountB).resolves.toEqual({ running: false }); + expect(bridge.liveStreamCancels).toEqual([ + "attach-1", + "stale-replacement", + "stale-replacement" + ]); + + const replacement = await service.beginSessionHistoryAttach( + "patch3-stale-account-a", + { sessionId: "session" }, + () => {} + ); + expect(bridge.liveStreamCancels).toEqual([ + "attach-1", + "stale-replacement", + "stale-replacement" + ]); + await replacement.cancel(); + }); + + test("retries an old binding cancellation failure before a later lease replacement", async () => { + const bridge = new SelectiveFailingLiveCancelBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_old_binding"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "patch3-old-binding-account", + { sessionId: "session" }, + () => {} + ); + const active = await pending.activate(); + const cleanupError = new Error("old binding cleanup failed"); + bridge.failNextCancellation("attach-1", cleanupError); + bridge.rotateLease("patch3-old-binding-account", remote); + bridge.resumeResult = { + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 0 }, + liveStreamId: "replacement-after-retry" + }; + + await expect(service.getRuntimeStatus("patch3-old-binding-account")).rejects.toBe(cleanupError); + expect(bridge.liveResumes).toHaveLength(0); + await expect(service.getRuntimeStatus("patch3-old-binding-account")).resolves.toEqual({ + running: false + }); + expect(bridge.liveStreamCancels.slice(0, 2)).toEqual(["attach-1", "attach-1"]); + expect(bridge.liveResumes).toHaveLength(1); + await active.cancel(); + }); + + test("shares one exact native result across double cancel and account retirement", async () => { + const bridge = new ControlledLiveCancelBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_concurrent_cancel"); + const service = new AgentRuntimeService(bridge, remote); + const active = await service.resumeLiveEvents( + "patch3-concurrent-cancel-account", + { journalId: TEST_JOURNAL_ID, sequence: 7 }, + () => {} + ); + + const firstCancel = active.cancel(); + await waitFor(() => bridge.pendingLiveCancels.length === 1); + const secondCancel = active.cancel(); + const retirement = service.retireAccount("patch3-concurrent-cancel-account"); + await Promise.resolve(); + expect(bridge.pendingLiveCancels).toHaveLength(1); + expect(bridge.liveStreamCancels).toEqual(["stream-1"]); + + bridge.pendingLiveCancels[0].resolve(); + await expect(Promise.all([firstCancel, secondCancel, retirement])).resolves.toEqual([ + undefined, + undefined, + undefined + ]); + await expect(active.cancel()).resolves.toBeUndefined(); + expect(bridge.liveStreamCancels).toEqual(["stream-1"]); + }); + + test("active cancel waits for an in-flight replacement and retries its exact ID", async () => { + const bridge = new ControlledResumeBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_cancel_during_replacement"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "patch3-cancel-during-replacement-account", + { sessionId: "session" }, + () => {} + ); + const active = await pending.activate(); + bridge.rotateLease("patch3-cancel-during-replacement-account", remote); + + const refresh = service.getRuntimeStatus("patch3-cancel-during-replacement-account"); + const refreshOutcome = refresh.then( + () => null, + (error: unknown) => error + ); + await waitFor(() => bridge.pendingResumes.length === 1); + let cancelSettled = false; + const cancelOutcome = active + .cancel() + .then( + () => null, + (error: unknown) => error + ) + .finally(() => { + cancelSettled = true; + }); + await Promise.resolve(); + expect(cancelSettled).toBe(false); + + const cleanupError = new Error("late replacement cleanup failed"); + bridge.failNextCancellation("late-replacement", cleanupError); + bridge.failNextCancellation("late-replacement", cleanupError); + bridge.resolveResume(0, { + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 0 }, + liveStreamId: "late-replacement" + }); + expect(await refreshOutcome).toBe(cleanupError); + expect(await cancelOutcome).toBe(cleanupError); + expect(bridge.liveStreamCancels).toEqual(["attach-1", "late-replacement", "late-replacement"]); + + await expect(active.cancel()).resolves.toBeUndefined(); + expect(bridge.liveStreamCancels.at(-1)).toBe("late-replacement"); + }); + + test("same-lease remount retries a failed final unmount cleanup before opening", async () => { + const bridge = new SelectiveFailingLiveCancelBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_same_lease_remount"); + const service = new AgentRuntimeService(bridge, remote); + const active = await service.resumeLiveEvents( + "patch3-remount-account", + { journalId: TEST_JOURNAL_ID, sequence: 7 }, + () => {} + ); + const cleanupError = new Error("unmount cleanup failed"); + bridge.failNextCancellation("stream-1", cleanupError); + + await expect(active.cancel()).rejects.toBe(cleanupError); + const replacementService = new AgentRuntimeService(bridge, remote); + const replacement = await replacementService.beginSessionHistoryAttach( + "patch3-remount-account", + { sessionId: "session" }, + () => {} + ); + expect(bridge.liveStreamCancels).toEqual(["stream-1", "stream-1"]); + await replacement.cancel(); + }); + + test("retains activation rejection cleanup after an earlier pending cancel", async () => { + const bridge = new FailingLateStreamCancelBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_rejected_activation_cleanup"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "patch3-rejected-activation-account", + { sessionId: "session" }, + () => {} + ); + const activation = pending.activate(); + await waitFor(() => bridge.pendingActivations.length === 1); + await pending.cancel(); + + const cleanupError = new Error("rejected activation cleanup failed"); + bridge.cancelError = cleanupError; + bridge.pendingActivations[0].reject(new Error("activation transport failed")); + await expect(activation).rejects.toBe(cleanupError); + expect(bridge.liveStreamCancels).toEqual(["attach-1"]); + + await expect(pending.cancel()).resolves.toBeUndefined(); + expect(bridge.liveStreamCancels).toEqual(["attach-1", "attach-1"]); + }); + + test("an ambiguous activation rejection drains both pending and live exact IDs", async () => { + const bridge = new ControlledAttachBridge(); + const remote = createRemoteAgentExecutionTarget("dev_patch3_ambiguous_activation"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "patch3-ambiguous-activation-account", + { sessionId: "session" }, + () => {} + ); + const activation = pending.activate(); + await waitFor(() => bridge.pendingActivations.length === 1); + + bridge.pendingActivations[0].reject(new Error("activation transport failed")); + await expect(activation).rejects.toThrow("activation transport failed"); + expect(bridge.pendingAttachCancels).toEqual(["attach-1"]); + expect(bridge.liveStreamCancels).toEqual(["attach-1"]); + }); + + test("cancels a stream whose activation resolves after owner teardown", async () => { + const bridge = new ControlledAttachBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "user-a", + { sessionId: "session" }, + () => {} + ); + const activation = pending.activate(); + await waitFor(() => bridge.pendingActivations.length === 1); + + await pending.cancel(); + bridge.pendingActivations[0].resolve(bridge.activateResult); + await expect(activation).rejects.toThrow("cancelled during activation"); + expect(bridge.pendingAttachCancels).toEqual(["attach-1"]); + expect(bridge.liveStreamCancels).toEqual(["attach-1"]); + }); + + test("retains and propagates a late activated stream whose cancellation fails", async () => { + const bridge = new FailingLateStreamCancelBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "user-a", + { sessionId: "session" }, + () => {} + ); + const activation = pending.activate(); + await waitFor(() => bridge.pendingActivations.length === 1); + + await pending.cancel(); + const cancellationError = new Error("live cleanup failed"); + bridge.cancelError = cancellationError; + bridge.pendingActivations[0].resolve(bridge.activateResult); + await expect(activation).rejects.toBe(cancellationError); + + await pending.cancel(); + expect(bridge.pendingAttachCancels).toEqual(["attach-1"]); + expect(bridge.liveStreamCancels).toEqual(["attach-1", "attach-1"]); + }); + + test("retains malformed activation cleanup until its non-benign failure is retried", async () => { + const bridge = new FailingLateStreamCancelBridge(); + bridge.activateResult = { + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 0 }, + liveStreamId: "wrong-stream" + }; + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "user-a", + { sessionId: "session" }, + () => {} + ); + const activation = pending.activate(); + await waitFor(() => bridge.pendingActivations.length === 1); + + const cancellationError = new Error("malformed activation cleanup failed"); + bridge.cancelError = cancellationError; + bridge.pendingActivations[0].resolve(bridge.activateResult); + await expect(activation).rejects.toBe(cancellationError); + + await pending.cancel(); + expect(bridge.liveStreamCancels).toEqual(["attach-1", "attach-1"]); + }); + + test("decodes only exact stamped snapshot-required control frames", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const frames: AgentLiveChannelFrame[] = []; + await service.beginSessionHistoryAttach("user-a", { sessionId: "session" }, (frame) => + frames.push(frame) + ); + await service.getRuntimeStatus("user-a"); + const lease = bridge.invocations.at(-1)!.lease; + const base = { + liveEventVersion: 1, + eventType: "snapshotRequired", + targetId: remote.id, + hostEpoch: lease.hostEpoch, + connectionGeneration: lease.connectionGeneration, + lastEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 7 } + } as const; + + bridge.liveHandlers[0]({ ...base, reason: "pausedSubscriberOverflow" }); + bridge.liveHandlers[0]({ ...base, reason: "paused_overflow", hostEpoch: "wrong" }); + bridge.liveHandlers[0]({ ...base, reason: "paused_overflow", liveEventVersion: 2 }); + const missingVersion = { ...base } as Record; + delete missingVersion.liveEventVersion; + bridge.liveHandlers[0]({ ...missingVersion, reason: "paused_overflow" }); + bridge.liveHandlers[0]({ ...base, reason: "paused_overflow", extra: true }); + expect(frames).toEqual([]); + bridge.liveHandlers[0]({ ...base, reason: "paused_overflow" }); + expect(frames).toEqual([{ ...base, reason: "paused_overflow" }]); + }); + + test("fails a pending attach closed by the native TTL without inventing a stream", async () => { + const bridge = new RecordingTargetBridge(); + bridge.activateError = { code: "attach_not_found" }; + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const pending = await service.beginSessionHistoryAttach( + "user-a", + { sessionId: "session" }, + () => {} + ); + + await expect(pending.activate()).rejects.toEqual({ code: "attach_not_found" }); + // Activation may consume or expire the pending token; exact active cleanup + // remains safe because activated attaches use attachId as liveStreamId. + expect(bridge.liveStreamCancels).toEqual(["attach-1"]); + }); + + test("preserves the typed oversized-history-record failure", async () => { + const bridge = new RecordingTargetBridge(); + bridge.attachError = { code: "history_record_too_large" }; + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + + await expect( + service.beginSessionHistoryAttach("user-a", { sessionId: "session" }, () => {}) + ).rejects.toMatchObject({ + name: "AgentHistoryRecordTooLargeError", + message: "An Agent history record is too large to present safely" + }); + }); + + test("resumes from an exact cursor and normalizes snapshot-required errors", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const frames: AgentLiveChannelFrame[] = []; + const active = await service.resumeLiveEvents( + "user-a", + { journalId: TEST_JOURNAL_ID, sequence: 7 }, + (frame) => frames.push(frame) + ); + expect(active).toMatchObject({ + liveStreamId: "stream-1", + throughEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 7 } + }); + await service.getRuntimeStatus("user-a"); + const lease = bridge.invocations.at(-1)!.lease; + bridge.liveHandlers[0]({ + liveEventVersion: 1, + eventType: "runStarted", + targetId: remote.id, + hostEpoch: lease.hostEpoch, + connectionGeneration: lease.connectionGeneration, + eventEpoch: TEST_JOURNAL_ID, + eventSequence: 8, + sessionId: "session", + runId: "resumed" + }); + expect(frames).toMatchObject([{ eventType: "runStarted", runId: "resumed" }]); + await active.cancel(); + expect(bridge.liveStreamCancels).toContain("stream-1"); + + bridge.resumeError = { + code: "snapshot_required", + reason: "retention_gap", + lastEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 7 } + }; + await expect( + service.resumeLiveEvents("user-a", { journalId: TEST_JOURNAL_ID, sequence: 7 }, () => {}) + ).rejects.toMatchObject({ + name: "AgentLiveSnapshotRequiredError", + reason: "retention_gap", + lastEventCursor: { journalId: TEST_JOURNAL_ID, sequence: 7 } + }); + await expect( + service.resumeLiveEvents("user-a", { journalId: "unsafe", sequence: 7 }, () => {}) + ).rejects.toThrow("cursor is invalid"); + }); + + test("keeps synchronized attach unavailable on the unverified embedded compatibility path", async () => { + const service = new AgentRuntimeService(new RecordingBridge()); + await expect( + service.beginSessionHistoryAttach("user-a", { sessionId: "session" }, () => {}) + ).rejects.toThrow("requires a verified remote host connection stamp"); + }); + + test("normalizes targetless legacy events only for the embedded target", async () => { + const bridge = new RecordingTargetBridge(); + const service = new AgentRuntimeService(bridge); + const events: AgentEventEnvelope[] = []; + + await service.listenToEvents((event) => events.push(event)); + bridge.emit(LOCAL_AGENT_EXECUTION_TARGET, { + eventType: "runStarted", + sessionId: "session-1", + runId: "run-1" + }); + + expect(events).toEqual([ + { + eventType: "runStarted", + targetId: LOCAL_AGENT_EXECUTION_TARGET.id, + connectionGeneration: 0, + sessionId: "session-1", + runId: "run-1" + } + ]); + expect(bridge.subscriptions).toEqual([{ lease: null, target: LOCAL_AGENT_EXECUTION_TARGET }]); + }); + + test("requires an account and event-capable bridge for remote subscriptions", async () => { + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const eventlessService = new AgentRuntimeService(new RecordingBridge(), remote); + + await expect(eventlessService.listenToEvents(() => {})).rejects.toThrow( + "Remote Agent event subscription requires an authenticated user" + ); + await expect(eventlessService.listenToEvents("user-a", () => {})).rejects.toThrow( + "does not support events for remote target" + ); + }); + + test("keeps one logical subscription alive when its connection generation changes", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const events: AgentEventEnvelope[] = []; + + const unlisten = await service.listenToEvents("user-a", (event) => events.push(event)); + const oldLease = bridge.subscriptions[0].lease!; + expect(Object.isFrozen(oldLease)).toBe(true); + + bridge.rotateLease("user-a", remote); + await service.getRuntimeStatus("user-a"); + const resumedLease = bridge.invocations[bridge.invocations.length - 1].lease; + expect(resumedLease.connectionGeneration).not.toBe(oldLease.connectionGeneration); + + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + connectionGeneration: oldLease.connectionGeneration, + sessionId: "session-1", + runId: "run-1" + }); + expect(events).toEqual([]); + + const subscribedResumedLease = bridge.subscriptions[bridge.subscriptions.length - 1].lease!; + expect(subscribedResumedLease).toMatchObject({ + accountId: "user-a", + targetId: remote.id, + hostEpoch: resumedLease.hostEpoch, + connectionGeneration: resumedLease.connectionGeneration + }); + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + hostEpoch: subscribedResumedLease.hostEpoch, + connectionGeneration: subscribedResumedLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 1, + sessionId: "session-1", + runId: "run-2" + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + connectionGeneration: subscribedResumedLease.connectionGeneration, + runId: "run-2" + }); + + unlisten(); + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + connectionGeneration: subscribedResumedLease.connectionGeneration, + sessionId: "session-1", + runId: "run-3" + }); + expect(events).toHaveLength(1); + }); + + test("retires account A's lease when the same target service switches to account B", async () => { + const bridge = new RecordingTargetBridge(); + const remote = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const service = new AgentRuntimeService(bridge, remote); + const accountAEvents: AgentEventEnvelope[] = []; + const accountBEvents: AgentEventEnvelope[] = []; + + await service.listenToEvents("account-a", (event) => accountAEvents.push(event)); + const accountALease = bridge.subscriptions[bridge.subscriptions.length - 1].lease!; + await service.listenToEvents("account-b", (event) => accountBEvents.push(event)); + const accountBLease = bridge.subscriptions[bridge.subscriptions.length - 1].lease!; + + expect(accountALease.accountId).toBe("account-a"); + expect(accountBLease.accountId).toBe("account-b"); + expect(accountBLease.hostEpoch).not.toBe(accountALease.hostEpoch); + + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + connectionGeneration: accountALease.connectionGeneration, + sessionId: "same-session", + runId: "same-run" + }); + expect(accountAEvents).toEqual([]); + expect(accountBEvents).toEqual([]); + + bridge.emit(remote, { + eventType: "runStarted", + targetId: remote.id, + hostEpoch: accountBLease.hostEpoch, + connectionGeneration: accountBLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 1, + sessionId: "same-session", + runId: "same-run" + }); + expect(accountAEvents).toEqual([]); + expect(accountBEvents).toHaveLength(1); + }); + + test("namespaces identical session and run IDs by execution target", async () => { + const bridge = new RecordingTargetBridge(); + const macbook = createRemoteAgentExecutionTarget("dev_01J4Z3N9Y5K7QX2P8B6C0R1TWA"); + const studio = createRemoteAgentExecutionTarget("dev_01J4Z3PBVAD2S6M8H0FQ9C7XKE"); + const baseService = new AgentRuntimeService(bridge); + const macbookService = baseService.forTarget(macbook); + const studioService = baseService.forTarget(studio); + const macbookEvents: AgentEventEnvelope[] = []; + const studioEvents: AgentEventEnvelope[] = []; + + await macbookService.listenToEvents("user-a", (event) => macbookEvents.push(event)); + await studioService.listenToEvents("user-a", (event) => studioEvents.push(event)); + const macbookLease = bridge.subscriptions[0].lease!; + const studioLease = bridge.subscriptions[1].lease!; + await macbookService.cancelRun("user-a", "shared-run"); + await studioService.cancelRun("user-a", "shared-run"); + + // Targetless legacy events must never be inferred onto a remote target. + bridge.emit(macbook, { + eventType: "runStarted", + sessionId: "shared-session", + runId: "shared-run" + }); + expect(macbookEvents).toEqual([]); + + bridge.emit(macbook, { + eventType: "runStarted", + targetId: macbook.id, + hostEpoch: macbookLease.hostEpoch, + connectionGeneration: macbookLease.connectionGeneration, + sessionId: "shared-session" + }); + expect(macbookEvents).toEqual([]); + + bridge.emit(macbook, { + eventType: "runStarted", + targetId: macbook.id, + connectionGeneration: macbookLease.connectionGeneration, + sessionId: 7, + runId: "shared-run" + }); + expect(macbookEvents).toEqual([]); + + bridge.emit(macbook, { + eventType: "runStarted", + targetId: macbook.id, + hostEpoch: macbookLease.hostEpoch, + connectionGeneration: macbookLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 1, + sessionId: "shared-session", + runId: "shared-run" + }); + expect(macbookEvents).toEqual([ + { + eventType: "runStarted", + targetId: macbook.id, + hostEpoch: macbookLease.hostEpoch, + connectionGeneration: macbookLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 1, + sessionId: "shared-session", + runId: "shared-run" + } + ]); + expect(studioEvents).toEqual([]); + + bridge.emit(studio, { + eventType: "runStarted", + targetId: studio.id, + hostEpoch: studioLease.hostEpoch, + connectionGeneration: studioLease.connectionGeneration, + eventEpoch: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + eventSequence: 1, + sessionId: "shared-session", + runId: "shared-run" + }); + expect(studioEvents).toEqual([ + { + eventType: "runStarted", + targetId: studio.id, + hostEpoch: studioLease.hostEpoch, + connectionGeneration: studioLease.connectionGeneration, + eventEpoch: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + eventSequence: 1, + sessionId: "shared-session", + runId: "shared-run" + } + ]); + + // Even if a bridge delivers an explicitly mis-tagged event on the wrong + // subscription, the target-bound service rejects it. + bridge.emit(macbook, { + eventType: "runFinished", + targetId: studio.id, + connectionGeneration: studioLease.connectionGeneration, + sessionId: "shared-session", + runId: "shared-run" + }); + expect(macbookEvents).toHaveLength(1); + + bridge.emit(macbook, { + eventType: "runFinished", + targetId: macbook.id, + hostEpoch: macbookLease.hostEpoch, + connectionGeneration: macbookLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 2, + sessionId: "shared-session", + runId: "shared-run", + message: "completed" + }); + expect(macbookEvents[1]).toEqual({ + eventType: "runFinished", + targetId: macbook.id, + hostEpoch: macbookLease.hostEpoch, + connectionGeneration: macbookLease.connectionGeneration, + eventEpoch: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + eventSequence: 2, + sessionId: "shared-session", + runId: "shared-run", + message: "completed" + }); + + expect(bridge.invocations).toEqual([ + { + lease: macbookLease, + invocation: { + operation: "cancelRun", + request: { runId: "shared-run" } + } + }, + { + lease: studioLease, + invocation: { + operation: "cancelRun", + request: { runId: "shared-run" } + } + } + ]); + expect(bridge.fencedTargets).toEqual([macbook, studio, macbook, studio]); + expect(bridge.subscriptions).toEqual([ + { lease: macbookLease, target: macbook }, + { lease: studioLease, target: studio } + ]); + }); }); class RecordingStopBridge implements AgentRuntimeStopBridge { diff --git a/frontend/src/services/agentRuntimeService.ts b/frontend/src/services/agentRuntimeService.ts index 2c105a25c..daa2bb1ce 100644 --- a/frontend/src/services/agentRuntimeService.ts +++ b/frontend/src/services/agentRuntimeService.ts @@ -111,6 +111,8 @@ export interface AgentSessionSummary { projectRoot: string; createdMs: number; updatedMs: number; + /** Exact native keyset ordering timestamp; display code should use updatedMs. */ + pageSortMs: number; messageCount: number; model?: string | null; mode: string; @@ -129,12 +131,186 @@ export interface AgentTimelineItem { merge: "append" | "replace" | string; } +/** + * Closed presentation item admitted by the synchronized remote history/live + * boundary. Unlike the embedded compatibility item, this type has no place + * for provider JSON, tool input/output, credentials, or extension fields. + */ +export interface AgentPresentedTimelineItem { + id: string; + itemType: "message" | "thinking" | "tool" | "permission" | "system" | "error"; + role?: "user" | "assistant" | "thought" | "system"; + title?: string; + text?: string; + status?: string; + createdMs: number; + merge: "append" | "replace"; +} + export interface AgentSessionDetail { session: AgentSessionSummary; timeline: AgentTimelineItem[]; mcpErrors: AgentMcpConnectionError[]; } +export interface AgentPageRequest { + cursor?: string | null; + limit?: number; +} + +export interface AgentPage { + items: T[]; + nextCursor?: string | null; +} + +export interface AgentListSessionsPageRequest extends AgentPageRequest { + projectRoot?: string | null; +} + +export interface AgentListSessionRecordsPageRequest extends AgentPageRequest { + sessionId: string; +} + +/** + * One native Goose message row and its safe Maple timeline projection. A + * history page is counted in records, not projected items: one record may + * legitimately contain text, thinking, and tool activity together. + */ +export interface AgentHistoryRecord { + recordId: string; + role: string; + createdMs: number; + items: AgentTimelineItem[]; +} + +export interface AgentSessionRecordsPage { + /** Newest record first, matching the native keyset query. */ + records: AgentHistoryRecord[]; + nextCursor?: string | null; + /** Opaque generation shared by every page in one history incarnation. */ + historyRevision: string; +} + +export interface AgentLiveEventCursor { + journalId: string; + sequence: number; +} + +export interface AgentLiveSessionSnapshot { + sessionId: string; + liveItems: AgentPresentedTimelineItem[]; +} + +export interface AgentPresentedHistoryRecord { + recordId: string; + role: string; + createdMs: number; + items: AgentPresentedTimelineItem[]; +} + +export interface AgentPresentedSessionRecordsPage { + records: AgentPresentedHistoryRecord[]; + nextCursor?: string | null; + historyRevision: string; +} + +export interface AgentBeginSessionHistoryAttachResponse { + attachId: string; + page: AgentPresentedSessionRecordsPage; + liveSessionsComplete: true; + liveSessionCount: number; + liveSessions: AgentLiveSessionSnapshot[]; + throughEventCursor: AgentLiveEventCursor; +} + +export interface AgentLiveBarrierResponse { + throughEventCursor: AgentLiveEventCursor; + liveStreamId: string; +} + +export type AgentLiveSnapshotReason = + | "paused_overflow" + | "slow_subscriber" + | "journal_replaced" + | "retention_gap" + | "cursor_ahead" + | "owner_changed" + | "ordering_lost" + | "journal_unavailable"; + +export interface AgentLiveSnapshotRequiredFrame { + liveEventVersion: 1; + eventType: "snapshotRequired"; + targetId: AgentExecutionTargetId; + hostEpoch: string; + connectionGeneration: number; + reason: AgentLiveSnapshotReason; + lastEventCursor: AgentLiveEventCursor; +} + +interface AgentOrderedLiveEventCommon { + liveEventVersion: 1; + targetId: AgentExecutionTargetId; + hostEpoch: string; + connectionGeneration: number; + eventEpoch: string; + eventSequence: number; + sessionId: string; +} + +export type AgentOrderedLiveEvent = AgentOrderedLiveEventCommon & + ( + | { eventType: "runStarted"; runId: string } + | { + eventType: "timelineUpsert"; + runId?: string; + item: AgentPresentedTimelineItem; + } + | { + eventType: "timelineCleared"; + runId: string; + reason: "run_started" | "history_replaced"; + } + | { + eventType: "timelineCleared"; + runId?: never; + reason: "explicit_reload"; + } + | { eventType: "historyReplaced"; runId: string } + | { eventType: "cursorAdvanced"; runId?: never } + | { + eventType: "sessionUpdated"; + runId?: string; + session: AgentSessionSummary; + } + | { + eventType: "runFinished"; + runId: string; + terminal: "completed" | "cancelled" | "failed"; + } + | { eventType: "sessionDeleted"; runId?: never } + | { + eventType: "userFacingError"; + runId: string; + item: AgentPresentedTimelineItem; + } + ); + +export type AgentLiveChannelFrame = AgentOrderedLiveEvent | AgentLiveSnapshotRequiredFrame; +export type AgentLiveChannelHandler = (frame: AgentLiveChannelFrame) => void; + +export interface AgentActiveLiveStream { + readonly throughEventCursor: AgentLiveEventCursor; + readonly liveStreamId: string; + cancel(): Promise; +} + +export interface AgentPendingHistoryAttach { + readonly response: AgentBeginSessionHistoryAttachResponse; + activate(): Promise; + cancel(): Promise; +} + export interface AgentSendMessageRequest { sessionId: string; text: string; @@ -150,23 +326,366 @@ export interface AgentRunResponse { export type AgentPermissionDecision = "allow_once" | "deny_once" | "cancel"; -export interface AgentEventEnvelope { - eventType: string; +declare const agentExecutionTargetIdBrand: unique symbol; + +/** + * Stable, transport-opaque identity for the Maple installation executing an + * Agent operation. The string remains serializable while callers must obtain a + * target through one of the factories below instead of passing an arbitrary + * session or device string to AgentRuntimeService. + */ +export type AgentExecutionTargetId = string & { + readonly [agentExecutionTargetIdBrand]: "AgentExecutionTargetId"; +}; + +export interface AgentExecutionTarget { + readonly id: AgentExecutionTargetId; + readonly kind: "local" | "remote"; + /** Human-facing label only. It is never used for routing or authorization. */ + readonly displayName?: string; +} + +const LOCAL_AGENT_EXECUTION_TARGET_ID = "local" as AgentExecutionTargetId; +const MAX_AGENT_EXECUTION_TARGET_ID_BYTES = 128; +const MAX_AGENT_HOST_EPOCH_BYTES = 20; +const MAX_U64_DECIMAL = "18446744073709551615"; +const AGENT_EXECUTION_TARGET_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; +export const DEFAULT_AGENT_PAGE_SIZE = 25; +export const MAX_AGENT_PAGE_SIZE = 50; +const MAX_AGENT_CURSOR_BYTES = 512; +const MAX_AGENT_HISTORY_ITEMS_PER_RECORD = 200; +const MAX_AGENT_HISTORY_ROLE_BYTES = 128; +const MAX_AGENT_LIVE_ITEMS_PER_SESSION = 200; +const MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT = 64; +const MAX_AGENT_LIVE_ITEMS_PER_ACCOUNT = 512; +const MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ACCOUNT = 8 * 1024 * 1024; +const AGENT_LIVE_JOURNAL_ID_PATTERN = /^[0-9a-f]{32}$/; +const AGENT_LIVE_PRESENTATION_VERSION = 1; +const MAX_AGENT_LIVE_ID_BYTES = 128; +const MAX_AGENT_LIVE_TITLE_BYTES = 1_024; +const MAX_AGENT_LIVE_TEXT_BYTES = 192 * 1_024; +const MAX_AGENT_LIVE_STATUS_BYTES = 256; +const MAX_AGENT_LIVE_PROJECT_ROOT_BYTES = 4_096; +const MAX_AGENT_LIVE_MODEL_BYTES = 256; +const MAX_AGENT_LIVE_MODE_BYTES = 64; +export const MAX_AGENT_HISTORY_RECORD_PRESENTATION_BYTES = 1_048_576 - 8_192; +const AGENT_SAFE_HISTORY_TOKEN_PATTERN = /^[A-Za-z0-9._:-]+$/; +const SAFE_REMOTE_SETUP_WARNING = + "Some Agent integrations could not connect. Review Agent settings on the host."; +const SAFE_REMOTE_AGENT_ERROR = + "The Agent task failed. Open the host for additional diagnostic details."; +const SAFE_REMOTE_TOOL_TITLE = "Tool activity"; +const SAFE_REMOTE_TOOL_FAILED = "The tool failed. Open the host for additional diagnostic details."; +const SAFE_REMOTE_TOOL_CANCELLED = "The tool was cancelled."; +const SAFE_REMOTE_PERMISSION_TITLE = "Tool permission"; + +export const LOCAL_AGENT_EXECUTION_TARGET: AgentExecutionTarget = Object.freeze({ + id: LOCAL_AGENT_EXECUTION_TARGET_ID, + kind: "local" +}); + +export function createRemoteAgentExecutionTarget( + id: unknown, + displayName?: unknown +): AgentExecutionTarget { + if ( + !isString(id) || + id.length === 0 || + id.length > MAX_AGENT_EXECUTION_TARGET_ID_BYTES || + !AGENT_EXECUTION_TARGET_ID_PATTERN.test(id) + ) { + throw new Error( + "A remote Agent execution target ID must be 1-128 ASCII letters, digits, '.', '_', ':', or '-'" + ); + } + if (id === LOCAL_AGENT_EXECUTION_TARGET_ID) { + throw new Error(`The Agent execution target ID "${id}" is reserved`); + } + if (displayName !== undefined && !isString(displayName)) { + throw new Error("A remote Agent execution target display name must be a string"); + } + return Object.freeze({ + id: id as AgentExecutionTargetId, + kind: "remote", + ...(displayName ? { displayName } : {}) + }); +} + +interface AgentEventCommonFields { sessionId?: string | null; runId?: string | null; + eventEpoch?: string | null; + eventSequence?: number | null; item?: AgentTimelineItem | null; status?: AgentRuntimeStatus | null; session?: AgentSessionSummary | null; message?: string | null; } +export type AgentEventPayload = AgentEventCommonFields & + ( + | { eventType: "runtimeStatus"; status: AgentRuntimeStatus } + | { eventType: "sessionCreated"; sessionId: string; session: AgentSessionSummary } + | { + eventType: "sessionUpdated"; + sessionId: string; + runId?: string | null; + session: AgentSessionSummary; + } + | { + eventType: "timelineItem"; + sessionId: string; + runId?: string | null; + item: AgentTimelineItem; + } + | { eventType: "runStarted"; sessionId: string; runId: string } + | { eventType: "error"; runId: string; message: string } + | { + eventType: "error"; + sessionId: string; + runId: string; + item: AgentTimelineItem; + message?: string | null; + } + | { eventType: "historyReplaced"; sessionId: string; runId: string } + | { + eventType: "runFinished"; + sessionId: string; + runId: string; + message: "completed" | "cancelled" | "failed"; + } + ); + +export type AgentEventType = AgentEventPayload["eventType"]; + +/** Targeted events carry the full verified host incarnation + reconnect stamp. */ +export type TargetedAgentEventEnvelope = AgentEventPayload & { + eventEpoch: string; + eventSequence: number; + targetId: AgentExecutionTargetId; + hostEpoch: string; + connectionGeneration: number; +}; + +/** Embedded compatibility events are target-normalized but not yet sequenced. */ +export type EmbeddedAgentEventEnvelope = AgentEventPayload & { + targetId: AgentExecutionTargetId; + connectionGeneration: 0; +}; + +/** Backward-compatible embedded shape. Target metadata is never partially present. */ +export interface LegacyLocalAgentEventEnvelope extends AgentEventCommonFields { + eventType: AgentEventType; + targetId?: never; + connectionGeneration?: never; +} + +export type AgentEventEnvelope = + | TargetedAgentEventEnvelope + | EmbeddedAgentEventEnvelope + | LegacyLocalAgentEventEnvelope; export type AgentEventHandler = (event: AgentEventEnvelope) => void; +export type AgentBridgeEventHandler = (event: unknown) => void; export type UnlistenAgentEvents = () => void; +declare const agentExecutionLeaseBrand: unique symbol; + +/** + * Native-issued authority for one account, verified host registration, + * non-reusable host incarnation, and transport generation. It is constructed + * only by runtime validation of a bridge result, then frozen before handoff. + */ +export type AgentExecutionLease = Readonly<{ + accountId: string; + targetId: AgentExecutionTargetId; + hostEpoch: string; + connectionGeneration: number; + [agentExecutionLeaseBrand]: "AgentExecutionLease"; +}>; + +const LOCAL_COMMAND_BY_AGENT_RUNTIME_OPERATION = { + getRuntimeStatus: "agent_get_runtime_status", + startRuntime: "agent_start_runtime", + restartRuntime: "agent_restart_runtime", + stopRuntime: "agent_stop_runtime", + clearUserData: "agent_clear_user_data", + clearUserHistory: "agent_clear_user_history", + loadConfig: "agent_load_config", + saveConfig: "agent_save_config", + listMcpServers: "agent_list_mcp_servers", + saveMcpServers: "agent_save_mcp_servers", + listSessionMcpServers: "agent_list_session_mcp_servers", + setSessionMcpServerEnabled: "agent_set_session_mcp_server_enabled", + listRecentProjectRoots: "agent_list_recent_project_roots", + saveRecentProjectRoot: "agent_save_recent_project_root", + removeProjectRoot: "agent_remove_project_root", + getProjectSkillsTrust: "agent_get_project_skills_trust", + setProjectSkillsTrust: "agent_set_project_skills_trust", + saveProjectRootOrder: "agent_save_project_root_order", + createSession: "agent_create_session", + listSessions: "agent_list_sessions", + loadSession: "agent_load_session", + listSessionsPage: "agent_list_sessions_page", + listSessionRecordsPage: "agent_list_session_records_page", + renameSession: "agent_rename_session", + deleteSession: "agent_delete_session", + sendMessage: "agent_send_message", + cancelRun: "agent_cancel_run", + setPermissionMode: "agent_set_permission_mode", + respondToPermission: "agent_permission_respond" +} as const; + +export interface AgentRuntimeOperationRequestMap { + getRuntimeStatus: undefined; + startRuntime: { request: AgentStartRequest | null }; + restartRuntime: { request: AgentStartRequest | null }; + stopRuntime: undefined; + clearUserData: undefined; + clearUserHistory: undefined; + loadConfig: undefined; + saveConfig: { config: AgentConfig }; + listMcpServers: undefined; + saveMcpServers: { servers: AgentMcpServer[] }; + listSessionMcpServers: { sessionId: string }; + setSessionMcpServerEnabled: { + request: { sessionId: string; name: string; enabled: boolean }; + }; + listRecentProjectRoots: undefined; + saveRecentProjectRoot: { path: string }; + removeProjectRoot: { path: string; fallbackPath: string | null }; + getProjectSkillsTrust: { path: string }; + setProjectSkillsTrust: { path: string; trusted: boolean }; + saveProjectRootOrder: { paths: string[] }; + createSession: { request: AgentCreateSessionRequest | null }; + listSessions: { projectRoot: string | null }; + loadSession: { sessionId: string }; + listSessionsPage: { request: AgentListSessionsPageRequest }; + listSessionRecordsPage: { request: AgentListSessionRecordsPageRequest }; + renameSession: { request: AgentRenameSessionRequest }; + deleteSession: { sessionId: string }; + sendMessage: { request: AgentSendMessageRequest }; + cancelRun: { runId: string }; + setPermissionMode: { request: { sessionId: string; mode: string } }; + respondToPermission: { + response: { sessionId: string; requestId: string; decision: AgentPermissionDecision }; + }; +} + +export interface AgentRuntimeOperationResultMap { + getRuntimeStatus: AgentRuntimeStatus; + startRuntime: AgentRuntimeStatus; + restartRuntime: AgentRuntimeLifecycleOutcome; + stopRuntime: AgentRuntimeLifecycleOutcome; + clearUserData: void; + clearUserHistory: void; + loadConfig: AgentConfig; + saveConfig: void; + listMcpServers: AgentMcpServer[]; + saveMcpServers: AgentMcpServer[]; + listSessionMcpServers: AgentSessionMcpServer[]; + setSessionMcpServerEnabled: AgentSessionMcpServer[]; + listRecentProjectRoots: RecentProjectRoot[]; + saveRecentProjectRoot: AgentProjectRootRegistration; + removeProjectRoot: AgentConfig; + getProjectSkillsTrust: AgentProjectSkillsTrustStatus; + setProjectSkillsTrust: AgentProjectSkillsTrustStatus; + saveProjectRootOrder: RecentProjectRoot[]; + createSession: AgentSessionDetail; + listSessions: AgentSessionSummary[]; + loadSession: AgentSessionDetail; + listSessionsPage: AgentPage; + listSessionRecordsPage: AgentSessionRecordsPage; + renameSession: AgentSessionSummary; + deleteSession: void; + sendMessage: AgentRunResponse; + cancelRun: void; + setPermissionMode: void; + respondToPermission: void; +} + +/** Deliberate Maple operation vocabulary, independent of Tauri command names. */ +export type AgentRuntimeOperation = keyof AgentRuntimeOperationRequestMap; +type AgentRemoteRuntimeOperation = Exclude; + +type AgentRemoteRuntimeInvocation = { + [Operation in AgentRemoteRuntimeOperation]: AgentRuntimeOperationRequestMap[Operation] extends undefined + ? { operation: Operation } + : { operation: Operation; request: AgentRuntimeOperationRequestMap[Operation] }; +}[AgentRemoteRuntimeOperation]; + +/** + * Controller-side request vocabulary only. The remote host adapter must bind + * these operations to reviewed run capabilities, never to Tauri Desktop + * command dispatch or another arbitrary string calling surface. + */ +export type AgentRuntimeInvocation = AgentRemoteRuntimeInvocation; + export interface AgentRuntimeBridge { - syncAuth(userId: string): Promise; - runForUser(userId: string, operation: () => Promise): Promise; - invoke(command: string, args?: Record): Promise; + /** Embedded-only Maple API credential sync. Never call this for a remote target. */ + syncLocalAuth?(userId: string): Promise; + /** + * Ensure an already paired target is locally ready. Implementations may use + * cached endpoint/key state, but must not forward account tokens or place an + * enclave grant on the per-command/reconnect path. The returned native value + * is decoded into a frozen AgentExecutionLease before any bridge call. + */ + prepareTarget?(userId: string, target: AgentExecutionTarget): Promise; + runForUser( + userId: string, + operation: () => Promise, + target?: AgentExecutionTarget + ): Promise; + /** + * Backward-compatible local Tauri invocation seam. Remote bridges implement + * invokeTarget so native command strings never become their wire protocol. + */ + invoke?(command: string, args?: Record): Promise; + invokeTarget?(lease: AgentExecutionLease, invocation: AgentRuntimeInvocation): Promise; + listenToEvents?( + /** Null exists only for the legacy embedded caller. */ + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + handler: AgentBridgeEventHandler + ): Promise; + beginSessionHistoryAttach?( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + request: AgentListSessionRecordsPageRequest, + handler: AgentBridgeEventHandler + ): Promise; + activateSessionHistoryAttach?( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + attachId: string + ): Promise; + cancelSessionHistoryAttach?( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + attachId: string + ): Promise; + resumeLiveEvents?( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + cursor: AgentLiveEventCursor, + handler: AgentBridgeEventHandler + ): Promise; + cancelLiveEvents?( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + liveStreamId: string + ): Promise; +} + +export interface AgentBridgeLiveChannelResult { + /** Raw result decoded by AgentRuntimeService before any cache mutation. */ + readonly result: unknown; + /** Strong ownership for a Tauri Channel or remote stream callback. */ + readonly keepAlive: object; } export interface AgentRuntimeStopBridge { @@ -206,10 +725,34 @@ export class AgentRuntimePartialStopError extends Error { } } +export class AgentPageStaleError extends Error { + constructor() { + super("Agent history changed; reload its newest page"); + this.name = "AgentPageStaleError"; + } +} + +export function isAgentPageStaleError(error: unknown): error is AgentPageStaleError { + return error instanceof AgentPageStaleError; +} + +export class AgentHistoryRecordTooLargeError extends Error { + constructor() { + super("An Agent history record is too large to present safely"); + this.name = "AgentHistoryRecordTooLargeError"; + } +} + const defaultAgentRuntimeBridge: AgentRuntimeBridge = { - syncAuth: async (userId) => await mapleApiAuthService.sync(userId), + syncLocalAuth: async (userId) => await mapleApiAuthService.sync(userId), runForUser: async (userId, operation) => await agentOperationFence.run(userId, operation), - invoke: invokeAgent + invoke: invokeAgent, + listenToEvents: listenToLocalAgentEvents, + beginSessionHistoryAttach: beginLocalSessionHistoryAttach, + activateSessionHistoryAttach: activateLocalSessionHistoryAttach, + cancelSessionHistoryAttach: cancelLocalSessionHistoryAttach, + resumeLiveEvents: resumeLocalLiveEvents, + cancelLiveEvents: cancelLocalLiveEvents }; const agentRuntimeStopCoordinator = new AgentRuntimeStopCoordinator({ @@ -218,20 +761,288 @@ const agentRuntimeStopCoordinator = new AgentRuntimeStopCoordinator({ // ACP-plus-runtime lifecycle gate; the manual ACP Stop command is reserved // for the settings page because it also changes saved configuration. stopHost: async (userId) => { - return await invokeAgent("agent_stop_runtime", { userId }); + return await invokeAgent( + LOCAL_COMMAND_BY_AGENT_RUNTIME_OPERATION.stopRuntime, + { userId } + ); } }); +interface RemoteAgentSubscriptionBinding { + readonly unlisten: UnlistenAgentEvents; + cancelled: boolean; + cancellationPromise: Promise | null; +} + +interface RemoteAgentLogicalSubscription { + readonly accountId: string; + readonly handler: AgentEventHandler; + lease: AgentExecutionLease; + binding: RemoteAgentSubscriptionBinding | null; + readonly cleanupBindings: Set; + readonly replacementOpenings: Set; + bindingEpoch: number; + closeRequested: boolean; + cancellationPromise: Promise | null; + closed: boolean; +} + +type RemoteAgentSubscriptionBindPhase = "initial" | "replacement"; + +interface RemoteAgentLiveStreamBinding { + readonly lease: AgentExecutionLease; + readonly liveStreamId: string; + readonly keepAlive: unknown; + cancelled: boolean; + cancellationPromise: Promise | null; +} + +interface RemoteAgentLogicalLiveStream { + readonly accountId: string; + readonly handler: AgentLiveChannelHandler; + retainedCursor: AgentLiveEventCursor | null; + binding: RemoteAgentLiveStreamBinding | null; + readonly cleanupBindings: Set; + readonly replacementOpenings: Set; + bindingEpoch: number; + registered: boolean; + closeRequested: boolean; + cancellationPromise: Promise | null; + closed: boolean; +} + +interface RemoteAgentPendingHistoryAttachResource { + readonly accountId: string; + readonly lease: AgentExecutionLease; + readonly attachId: string; + readonly keepAlive: unknown; + phase: "pending" | "active"; + cleanupRequired: boolean; + cancelled: boolean; + cancellationPromise: Promise | null; +} + +interface RemoteAgentResourceOpening { + readonly accountId: string; + retired: boolean; + readonly settled: Promise; + settle(): void; +} + +class AgentRuntimeAccountResourceRegistry { + private readonly scopesByAccount = new Map>(); + private readonly blockedAccounts = new Set(); + private readonly retirements = new Map>(); + + claim(scope: AgentRuntimeServiceScope, accountId: string): void { + if (this.blockedAccounts.has(accountId)) { + throw new Error("Remote Agent account resources are blocked during account retirement"); + } + const scopes = this.scopesByAccount.get(accountId) ?? new Set(); + scopes.add(scope); + this.scopesByAccount.set(accountId, scopes); + } + + release(scope: AgentRuntimeServiceScope, accountId: string): void { + const scopes = this.scopesByAccount.get(accountId); + if (!scopes) return; + scopes.delete(scope); + if (scopes.size === 0) this.scopesByAccount.delete(accountId); + } + + activateAccount(accountId: string): void { + this.blockedAccounts.delete(accountId); + } + + async retryAccountCleanup(accountId: string): Promise { + const scopes = [...(this.scopesByAccount.get(accountId) ?? [])]; + const results = await Promise.allSettled( + scopes.map((scope) => scope.retryAccountCleanup(accountId)) + ); + throwAgentCleanupFailures("Unable to retry retained remote Agent cleanup", results); + } + + async retireAccount(accountId: string): Promise { + const existing = this.retirements.get(accountId); + if (existing) return await existing; + this.blockedAccounts.add(accountId); + const retirement = Promise.resolve().then(async () => { + const scopes = [...(this.scopesByAccount.get(accountId) ?? [])]; + const results = await Promise.allSettled( + scopes.map((scope) => scope.retireAccount(accountId)) + ); + throwAgentCleanupFailures("Unable to retire remote Agent account resources", results); + }); + this.retirements.set(accountId, retirement); + try { + await retirement; + } finally { + if (this.retirements.get(accountId) === retirement) this.retirements.delete(accountId); + } + } +} + +const agentRuntimeAccountResourceRegistry = new AgentRuntimeAccountResourceRegistry(); + +class AgentRuntimeServiceScope { + private readonly services = new Set(); + private readonly retiringAccounts = new Map>(); + + register(service: AgentRuntimeService): void { + this.services.add(service); + } + + claimAccount(accountId: string): void { + if (this.retiringAccounts.has(accountId)) { + throw new Error("Remote Agent service scope is retiring this account"); + } + agentRuntimeAccountResourceRegistry.claim(this, accountId); + } + + releaseAccountIfIdle(accountId: string): void { + for (const service of this.services) { + if (service.ownsRemoteAccountResources(accountId)) return; + } + agentRuntimeAccountResourceRegistry.release(this, accountId); + } + + async retireAccount(accountId: string): Promise { + const existing = this.retiringAccounts.get(accountId); + if (existing) return await existing; + const retirement = Promise.resolve().then(async () => { + const results = await Promise.allSettled( + [...this.services].map((service) => service.retireOwnAccount(accountId)) + ); + throwAgentCleanupFailures("Unable to retire remote Agent service resources", results); + this.releaseAccountIfIdle(accountId); + }); + this.retiringAccounts.set(accountId, retirement); + try { + await retirement; + } finally { + if (this.retiringAccounts.get(accountId) === retirement) { + this.retiringAccounts.delete(accountId); + } + } + } + + async retryAccountCleanup(accountId: string): Promise { + const results = await Promise.allSettled( + [...this.services].map((service) => service.retryOwnRemoteCleanupForAccount(accountId)) + ); + throwAgentCleanupFailures("Unable to retry retained remote Agent service cleanup", results); + this.releaseAccountIfIdle(accountId); + } +} + export class AgentRuntimeService { - constructor(private readonly bridge: AgentRuntimeBridge = defaultAgentRuntimeBridge) {} + private currentLease: AgentExecutionLease | null = null; + private preparationEpoch = 0; + private preparingAccountId: string | null = null; + private preparationInFlight: { + accountId: string; + promise: Promise; + } | null = null; + private readonly remoteSubscriptions = new Set(); + private readonly remoteLiveStreams = new Set(); + private readonly remotePendingHistoryAttaches = + new Set(); + private readonly remoteResourceOpenings = new Set(); + + constructor( + private readonly bridge: AgentRuntimeBridge = defaultAgentRuntimeBridge, + readonly target: AgentExecutionTarget = LOCAL_AGENT_EXECUTION_TARGET, + private readonly scope: AgentRuntimeServiceScope = new AgentRuntimeServiceScope() + ) { + this.scope.register(this); + } + + forTarget(target: AgentExecutionTarget): AgentRuntimeService { + return new AgentRuntimeService(this.bridge, target, this.scope); + } + + async retireAccount(userId: string): Promise { + await this.scope.retireAccount(userId); + } + + async retireOwnAccount(userId: string): Promise { + const openings = [...this.remoteResourceOpenings].filter( + (opening) => opening.accountId === userId + ); + for (const opening of openings) opening.retired = true; + if (this.currentLease?.accountId === userId) this.currentLease = null; + if (this.preparingAccountId === userId) { + this.preparingAccountId = null; + this.preparationEpoch += 1; + } + const firstCleanup = await Promise.allSettled([ + ...[...this.remoteSubscriptions] + .filter((subscription) => subscription.accountId === userId) + .map((subscription) => this.closeRemoteSubscription(subscription)), + ...[...this.remotePendingHistoryAttaches] + .filter((resource) => resource.accountId === userId) + .map((resource) => this.cancelRemotePendingHistoryAttach(resource, false)), + ...[...this.remoteLiveStreams] + .filter((stream) => stream.accountId === userId) + .map((stream) => this.closeRemoteLiveStream(stream, false)), + ...openings.map((opening) => opening.settled) + ]); + const secondCleanup = await Promise.allSettled([ + ...[...this.remoteSubscriptions] + .filter((subscription) => subscription.accountId === userId) + .map((subscription) => this.closeRemoteSubscription(subscription)), + ...[...this.remotePendingHistoryAttaches] + .filter((resource) => resource.accountId === userId) + .map((resource) => this.cancelRemotePendingHistoryAttach(resource, false)), + ...[...this.remoteLiveStreams] + .filter((stream) => stream.accountId === userId) + .map((stream) => this.closeRemoteLiveStream(stream, false)) + ]); + this.scope.releaseAccountIfIdle(userId); + throwAgentCleanupFailures("Unable to retire remote Agent account resources", [ + ...firstCleanup, + ...secondCleanup + ]); + } + + ownsRemoteAccountResources(userId: string): boolean { + return ( + [...this.remoteResourceOpenings].some((opening) => opening.accountId === userId) || + [...this.remoteSubscriptions].some( + (subscription) => !subscription.closed && subscription.accountId === userId + ) || + [...this.remotePendingHistoryAttaches].some( + (resource) => !resource.cancelled && resource.accountId === userId + ) || + [...this.remoteLiveStreams].some((stream) => !stream.closed && stream.accountId === userId) + ); + } + + /** + * Structural capability check for route plumbing only. This does not confer + * pairing authority: every operation still calls prepareTarget and accepts + * only the exact native-issued execution lease before crossing the bridge. + */ + supportsRemoteAgentMode(): boolean { + return ( + this.target.kind === "remote" && + typeof this.bridge.prepareTarget === "function" && + typeof this.bridge.invokeTarget === "function" && + typeof this.bridge.listenToEvents === "function" && + typeof this.bridge.beginSessionHistoryAttach === "function" && + typeof this.bridge.activateSessionHistoryAttach === "function" && + typeof this.bridge.cancelSessionHistoryAttach === "function" && + typeof this.bridge.resumeLiveEvents === "function" && + typeof this.bridge.cancelLiveEvents === "function" + ); + } async getRuntimeStatus(userId: string): Promise { - return await this.invokeForUser(userId, "agent_get_runtime_status"); + return await this.invokeForUser(userId, "getRuntimeStatus", undefined); } async startRuntime(userId: string, request?: AgentStartRequest): Promise { - return await this.invokeForUser(userId, "agent_start_runtime", { - userId, + return await this.invokeForUser(userId, "startRuntime", { request: request ?? null }); } @@ -240,37 +1051,45 @@ export class AgentRuntimeService { userId: string, request?: AgentStartRequest ): Promise { - return await this.invokeForUser(userId, "agent_restart_runtime", { - userId, + return await this.invokeForUser(userId, "restartRuntime", { request: request ?? null }); } + async stopRuntime(userId: string): Promise { + return await this.invokeControlForUser(userId, "stopRuntime", undefined); + } + + async clearUserData(userId: string): Promise { + await this.invokeForUser(userId, "clearUserData", undefined); + } + + async clearUserHistory(userId: string): Promise { + await this.invokeForUser(userId, "clearUserHistory", undefined); + } + async loadConfig(userId: string): Promise { - return await this.invokeForUser(userId, "agent_load_config"); + return await this.invokeForUser(userId, "loadConfig", undefined); } async saveConfig(userId: string, config: AgentConfig): Promise { - await this.invokeForUser(userId, "agent_save_config", { userId, config }); + await this.invokeForUser(userId, "saveConfig", { config }); } async listMcpServers(userId: string): Promise { - return await this.invokeForUser(userId, "agent_list_mcp_servers"); + return await this.invokeForUser(userId, "listMcpServers", undefined); } async saveMcpServers(userId: string, servers: AgentMcpServer[]): Promise { - return await this.invokeForUser(userId, "agent_save_mcp_servers", { - userId, + return await this.invokeForUser(userId, "saveMcpServers", { servers }); } async listSessionMcpServers(userId: string, sessionId: string): Promise { - return await this.invokeForUser( - userId, - "agent_list_session_mcp_servers", - { userId, sessionId } - ); + return await this.invokeForUser(userId, "listSessionMcpServers", { + sessionId + }); } async setSessionMcpServerEnabled( @@ -279,26 +1098,19 @@ export class AgentRuntimeService { name: string, enabled: boolean ): Promise { - return await this.invokeForUser( - userId, - "agent_set_session_mcp_server_enabled", - { userId, request: { sessionId, name, enabled } } - ); + return await this.invokeForUser(userId, "setSessionMcpServerEnabled", { + request: { sessionId, name, enabled } + }); } async listRecentProjectRoots(userId: string): Promise { - return await this.invokeForUser(userId, "agent_list_recent_project_roots"); + return await this.invokeForUser(userId, "listRecentProjectRoots", undefined); } async saveRecentProjectRoot(userId: string, path: string): Promise { - return await this.invokeForUser( - userId, - "agent_save_recent_project_root", - { - userId, - path - } - ); + return await this.invokeForUser(userId, "saveRecentProjectRoot", { + path + }); } async removeProjectRoot( @@ -306,8 +1118,7 @@ export class AgentRuntimeService { path: string, fallbackPath?: string | null ): Promise { - return await this.invokeForUser(userId, "agent_remove_project_root", { - userId, + return await this.invokeForUser(userId, "removeProjectRoot", { path, fallbackPath: fallbackPath ?? null }); @@ -317,11 +1128,7 @@ export class AgentRuntimeService { userId: string, path: string ): Promise { - return await this.invokeForUser( - userId, - "agent_get_project_skills_trust", - { userId, path } - ); + return await this.invokeForUser(userId, "getProjectSkillsTrust", { path }); } async setProjectSkillsTrust( @@ -329,16 +1136,11 @@ export class AgentRuntimeService { path: string, trusted: boolean ): Promise { - return await this.invokeForUser( - userId, - "agent_set_project_skills_trust", - { userId, path, trusted } - ); + return await this.invokeForUser(userId, "setProjectSkillsTrust", { path, trusted }); } async saveProjectRootOrder(userId: string, paths: string[]): Promise { - return await this.invokeForUser(userId, "agent_save_project_root_order", { - userId, + return await this.invokeForUser(userId, "saveProjectRootOrder", { paths }); } @@ -347,56 +1149,272 @@ export class AgentRuntimeService { userId: string, request?: AgentCreateSessionRequest ): Promise { - return await this.invokeForUser(userId, "agent_create_session", { - userId, + return await this.invokeForUser(userId, "createSession", { request: request ?? null }); } async listSessions(userId: string, projectRoot?: string | null): Promise { - return await this.invokeForUser(userId, "agent_list_sessions", { - userId, + this.assertLocalCompatibilityOperation("listSessions"); + return await this.invokeForUser(userId, "listSessions", { projectRoot: projectRoot ?? null }); } async loadSession(userId: string, sessionId: string): Promise { - return await this.invokeForUser(userId, "agent_load_session", { - userId, + this.assertLocalCompatibilityOperation("loadSession"); + return await this.invokeForUser(userId, "loadSession", { sessionId }); } + async listSessionsPage( + userId: string, + request: AgentListSessionsPageRequest = {} + ): Promise> { + validateAgentListSessionsPageRequest(request); + let page: AgentPage; + try { + page = await this.invokeForUser(userId, "listSessionsPage", { request }); + } catch (error) { + throw normalizeAgentPageError(error); + } + validateReturnedAgentPage("session", request, page.items.length, page.nextCursor ?? null); + return page; + } + + async listSessionRecordsPage( + userId: string, + request: AgentListSessionRecordsPageRequest + ): Promise { + validateAgentListSessionRecordsPageRequest(request); + let page: AgentSessionRecordsPage; + try { + page = await this.invokeForUser(userId, "listSessionRecordsPage", { request }); + } catch (error) { + throw normalizeAgentPageError(error); + } + validateReturnedAgentPage( + "history record", + request, + page.records.length, + page.nextCursor ?? null + ); + return page; + } + + async beginSessionHistoryAttach( + userId: string, + request: AgentListSessionRecordsPageRequest, + handler: AgentLiveChannelHandler + ): Promise { + validateAgentListSessionRecordsPageRequest(request); + if (request.cursor !== undefined && request.cursor !== null) { + throw new Error("A synchronized Agent history attach must start at the newest page"); + } + const opening = this.target.kind === "remote" ? this.beginRemoteResourceOpening(userId) : null; + try { + if (opening) await agentRuntimeAccountResourceRegistry.retryAccountCleanup(userId); + return await this.bridge.runForUser( + userId, + async () => { + const lease = await this.prepareInvocationTarget(userId, true); + if (!lease) { + throw new Error( + "Synchronized Agent history requires a verified remote host connection stamp" + ); + } + if (opening?.retired) { + throw new Error("Agent history attachment owner retired before opening"); + } + const begin = this.bridge.beginSessionHistoryAttach; + if ( + !begin || + !this.bridge.activateSessionHistoryAttach || + !this.bridge.cancelSessionHistoryAttach || + !this.bridge.cancelLiveEvents + ) { + throw new Error("Agent runtime bridge does not support synchronized history lifecycle"); + } + const logicalStream = this.createRemoteLogicalLiveStream(userId, handler); + let opened: AgentBridgeLiveChannelResult; + try { + opened = await begin.call( + this.bridge, + userId, + lease, + this.target, + { ...request, cursor: null }, + this.liveChannelDecoder(lease, handler, logicalStream) + ); + } catch (error) { + throw normalizeAgentLiveError(error); + } + const provisionalAttachId = attachIdFromUnknown(opened.result); + const pendingResource = provisionalAttachId + ? this.createRemotePendingHistoryAttachResource( + userId, + lease, + provisionalAttachId, + opened.keepAlive + ) + : null; + let response: AgentBeginSessionHistoryAttachResponse; + try { + if (!isRecord(opened.keepAlive)) { + throw new Error("Agent runtime bridge did not retain its live event channel"); + } + response = decodeAgentBeginSessionHistoryAttachResponse(opened.result); + validateReturnedAgentPage( + "history record", + request, + response.page.records.length, + response.page.nextCursor ?? null + ); + if (!pendingResource || pendingResource.attachId !== response.attachId) { + throw new Error("Agent history attachment lost its cleanup-owned native ID"); + } + if (opening?.retired) { + pendingResource.cleanupRequired = true; + await this.cancelRemotePendingHistoryAttach(pendingResource, false); + throw new Error("Agent history attachment owner retired while opening"); + } + } catch (error) { + if (pendingResource && !pendingResource.cancelled) { + pendingResource.cleanupRequired = true; + await this.cancelRemotePendingHistoryAttach(pendingResource, false); + } + throw error; + } + return this.createPendingHistoryAttach( + userId, + lease, + response, + logicalStream, + pendingResource + ); + }, + this.target + ); + } finally { + opening?.settle(); + } + } + + async resumeLiveEvents( + userId: string, + cursor: AgentLiveEventCursor, + handler: AgentLiveChannelHandler + ): Promise { + validateAgentLiveEventCursor(cursor); + const opening = this.target.kind === "remote" ? this.beginRemoteResourceOpening(userId) : null; + try { + if (opening) await agentRuntimeAccountResourceRegistry.retryAccountCleanup(userId); + return await this.bridge.runForUser( + userId, + async () => { + const lease = await this.prepareInvocationTarget(userId, true); + if (!lease) { + throw new Error("Agent live resume requires a verified remote host connection stamp"); + } + if (opening?.retired) { + throw new Error("Agent live resume owner retired before opening"); + } + const resume = this.bridge.resumeLiveEvents; + if (!resume || !this.bridge.cancelLiveEvents) { + throw new Error("Agent runtime bridge does not support live event resume lifecycle"); + } + const logicalStream = this.createRemoteLogicalLiveStream(userId, handler, cursor); + let opened: AgentBridgeLiveChannelResult; + try { + opened = await resume.call( + this.bridge, + userId, + lease, + this.target, + cursor, + this.liveChannelDecoder(lease, handler, logicalStream) + ); + } catch (error) { + throw normalizeAgentLiveError(error); + } + const provisionalId = liveStreamIdFromUnknown(opened.result); + const provisionalBinding = + logicalStream && provisionalId + ? this.createRemoteLiveStreamBinding(lease, provisionalId, opened.keepAlive) + : null; + if (logicalStream && provisionalBinding) { + this.retainRemoteLiveStreamBinding(logicalStream, provisionalBinding); + } + let barrier: AgentLiveBarrierResponse; + try { + if (!isRecord(opened.keepAlive)) { + throw new Error("Agent runtime bridge did not retain its live event channel"); + } + barrier = decodeAgentLiveBarrierResponse(opened.result); + if (!provisionalBinding || provisionalBinding.liveStreamId !== barrier.liveStreamId) { + throw new Error("Agent live resume lost its cleanup-owned native ID"); + } + if ( + barrier.throughEventCursor.journalId !== cursor.journalId || + barrier.throughEventCursor.sequence < cursor.sequence + ) { + throw new Error("Agent live resume returned a regressing event checkpoint"); + } + if (opening?.retired) { + throw new Error("Agent live resume owner retired while opening"); + } + } catch (error) { + if (logicalStream?.registered) { + logicalStream.closeRequested = true; + logicalStream.bindingEpoch += 1; + await this.closeRemoteLiveStream(logicalStream, false); + } + throw error; + } + return this.createActiveLiveStream( + userId, + lease, + opened.keepAlive, + barrier, + logicalStream, + provisionalBinding + ); + }, + this.target + ); + } finally { + opening?.settle(); + } + } + async renameSession( userId: string, request: AgentRenameSessionRequest ): Promise { - return await this.invokeForUser(userId, "agent_rename_session", { - userId, + return await this.invokeForUser(userId, "renameSession", { request }); } async deleteSession(userId: string, sessionId: string): Promise { - await this.invokeForUser(userId, "agent_delete_session", { userId, sessionId }); + await this.invokeForUser(userId, "deleteSession", { sessionId }); } async sendMessage(userId: string, request: AgentSendMessageRequest): Promise { - return await this.invokeForUser(userId, "agent_send_message", { - userId, + return await this.invokeForUser(userId, "sendMessage", { request }); } async cancelRun(userId: string, runId: string): Promise { - // Cancellation is a local control-plane operation. Keep it account-fenced, - // but never delay Stop on remote credential validation or token refresh. - await this.invokeLocalForUser(userId, "agent_cancel_run", { userId, runId }); + // Cancellation is a target control-plane operation. Keep it account- and + // target-fenced, but never delay Stop on credential validation or refresh. + await this.invokeControlForUser(userId, "cancelRun", { runId }); } async setPermissionMode(userId: string, sessionId: string, mode: string): Promise { - await this.invokeForUser(userId, "agent_set_permission_mode", { - userId, + await this.invokeForUser(userId, "setPermissionMode", { request: { sessionId, mode } }); } @@ -407,43 +1425,2514 @@ export class AgentRuntimeService { requestId: string, decision: AgentPermissionDecision ): Promise { - await this.invokeForUser(userId, "agent_permission_respond", { - userId, + await this.invokeForUser(userId, "respondToPermission", { response: { sessionId, requestId, decision } }); } - async listenToEvents(handler: AgentEventHandler): Promise { - if (!isTauriDesktop()) { + async listenToEvents(handler: AgentEventHandler): Promise; + async listenToEvents(userId: string, handler: AgentEventHandler): Promise; + async listenToEvents( + userIdOrHandler: string | AgentEventHandler, + accountHandler?: AgentEventHandler + ): Promise { + const userId = typeof userIdOrHandler === "string" ? userIdOrHandler : null; + const handler = typeof userIdOrHandler === "string" ? accountHandler : userIdOrHandler; + if (!handler) throw new Error("Agent event subscription requires a handler"); + if (this.target.kind === "remote" && !userId) { + throw new Error("Remote Agent event subscription requires an authenticated user"); + } + if (!this.bridge.listenToEvents) { + if (this.target.kind === "remote") { + throw new Error( + `Agent runtime bridge does not support events for remote target "${this.target.id as string}"` + ); + } return () => {}; } - const { listen } = await import("@tauri-apps/api/event"); - const unlisten = await listen("agent-event", (event) => { - handler(event.payload); + if (this.target.kind === "remote") { + const accountId = userId as string; + const opening = this.beginRemoteResourceOpening(accountId); + try { + await agentRuntimeAccountResourceRegistry.retryAccountCleanup(accountId); + return await this.bridge.runForUser( + accountId, + async () => { + const lease = await this.prepareRemoteLease(accountId); + if (opening.retired) { + throw new Error("Remote Agent event subscription owner retired before binding"); + } + if (!sameExecutionLease(this.currentLease, lease)) { + throw new Error( + "Remote Agent execution lease changed before event subscription handoff" + ); + } + const subscription: RemoteAgentLogicalSubscription = { + accountId, + handler, + lease, + binding: null, + cleanupBindings: new Set(), + replacementOpenings: new Set(), + bindingEpoch: 0, + closeRequested: false, + cancellationPromise: null, + closed: false + }; + this.remoteSubscriptions.add(subscription); + try { + await this.bindRemoteSubscription(subscription, lease, "initial"); + if (opening.retired) { + await this.closeRemoteSubscription(subscription); + throw retiredInitialRemoteSubscriptionError(); + } + } catch (error) { + if (subscription.closed) throw error; + await this.closeRemoteSubscription(subscription); + throw error; + } + return () => { + void this.closeRemoteSubscription(subscription).catch(() => { + // Cleanup ownership remains registered for account retirement + // or the next same-account resource open to retry durably. + }); + }; + }, + this.target + ); + } finally { + opening.settle(); + } + } + + return await this.bridge.listenToEvents(null, this.target, (event) => { + // The current embedded Tauri emitter has neither field. Normalize its + // single local stream into generation zero until native events carry it. + const decoded = decodeLegacyLocalAgentEvent(event); + if (!decoded) return; + handler({ + ...decoded, + targetId: this.target.id, + connectionGeneration: 0 + }); }); - return unlisten; } - private async invokeForUser( - userId: string, - command: string, - args?: Record - ): Promise { - return await this.bridge.runForUser(userId, async () => { - await this.bridge.syncAuth(userId); - return await this.bridge.invoke(command, { userId, ...args }); - }); + private liveChannelDecoder( + lease: AgentExecutionLease, + handler: AgentLiveChannelHandler, + logicalStream: RemoteAgentLogicalLiveStream | null = null, + bindingEpoch = logicalStream?.bindingEpoch ?? 0 + ): AgentBridgeEventHandler { + return (value) => { + if (this.target.kind === "remote" && !sameExecutionLease(this.currentLease, lease)) return; + if ( + logicalStream && + (logicalStream.closed || + logicalStream.closeRequested || + logicalStream.bindingEpoch !== bindingEpoch) + ) { + return; + } + const frame = decodeAgentLiveChannelFrame( + value, + lease.targetId, + lease.hostEpoch, + lease.connectionGeneration + ); + if (!frame) return; + if (logicalStream) { + const cursor = + frame.eventType === "snapshotRequired" + ? frame.lastEventCursor + : { journalId: frame.eventEpoch, sequence: frame.eventSequence }; + if ( + !logicalStream.retainedCursor || + (logicalStream.retainedCursor.journalId === cursor.journalId && + logicalStream.retainedCursor.sequence <= cursor.sequence) + ) { + logicalStream.retainedCursor = cursor; + } + } + handler(frame); + }; } - private async invokeLocalForUser( + private createRemoteLogicalLiveStream( + accountId: string, + handler: AgentLiveChannelHandler, + cursor: AgentLiveEventCursor | null = null + ): RemoteAgentLogicalLiveStream | null { + if (this.target.kind !== "remote") return null; + return { + accountId, + handler, + retainedCursor: cursor, + binding: null, + cleanupBindings: new Set(), + replacementOpenings: new Set(), + bindingEpoch: 0, + registered: false, + closeRequested: false, + cancellationPromise: null, + closed: false + }; + } + + private beginRemoteResourceOpening(accountId: string): RemoteAgentResourceOpening { + this.scope.claimAccount(accountId); + let settled = false; + let resolveSettled!: () => void; + const opening: RemoteAgentResourceOpening = { + accountId, + retired: false, + settled: new Promise((resolve) => { + resolveSettled = resolve; + }), + settle: () => { + if (settled) return; + settled = true; + this.remoteResourceOpenings.delete(opening); + resolveSettled(); + this.scope.releaseAccountIfIdle(accountId); + } + }; + this.remoteResourceOpenings.add(opening); + return opening; + } + + private beginRemoteLiveStreamReplacementOpening( + logicalStream: RemoteAgentLogicalLiveStream + ): RemoteAgentResourceOpening { + const opening = this.beginRemoteResourceOpening(logicalStream.accountId); + const settleOpening = opening.settle; + opening.settle = () => { + logicalStream.replacementOpenings.delete(opening); + settleOpening(); + }; + logicalStream.replacementOpenings.add(opening); + return opening; + } + + async retryOwnRemoteCleanupForAccount(accountId: string): Promise { + const results = await Promise.allSettled([ + ...[...this.remoteSubscriptions] + .filter( + (subscription) => subscription.accountId === accountId && subscription.closeRequested + ) + .map((subscription) => this.retryRemoteSubscriptionCleanup(subscription)), + ...[...this.remotePendingHistoryAttaches] + .filter((resource) => resource.accountId === accountId && resource.cleanupRequired) + .map((resource) => this.retryRemotePendingHistoryAttachCleanup(resource)), + ...[...this.remoteLiveStreams] + .filter((stream) => stream.accountId === accountId && stream.closeRequested) + .map((stream) => this.retryRemoteLiveStreamCleanup(stream)) + ]); + throwAgentCleanupFailures("Unable to retry retained remote Agent live cleanup", results); + } + + private async retryRemoteSubscriptionCleanup( + subscription: RemoteAgentLogicalSubscription + ): Promise { + const inheritedCancellation = subscription.cancellationPromise; + if (inheritedCancellation) await inheritedCancellation.catch(() => {}); + if (!subscription.closed) await this.closeRemoteSubscription(subscription); + } + + private async retryRemotePendingHistoryAttachCleanup( + resource: RemoteAgentPendingHistoryAttachResource + ): Promise { + const inheritedCancellation = resource.cancellationPromise; + if (inheritedCancellation) await inheritedCancellation.catch(() => {}); + if (!resource.cancelled) await this.cancelRemotePendingHistoryAttach(resource, true); + } + + private async retryRemoteLiveStreamCleanup(stream: RemoteAgentLogicalLiveStream): Promise { + const inheritedCancellation = stream.cancellationPromise; + if (inheritedCancellation) await inheritedCancellation.catch(() => {}); + if (!stream.closed) await this.closeRemoteLiveStream(stream, true); + } + + private createRemotePendingHistoryAttachResource( + accountId: string, + lease: AgentExecutionLease, + attachId: string, + keepAlive: unknown + ): RemoteAgentPendingHistoryAttachResource { + const resource: RemoteAgentPendingHistoryAttachResource = { + accountId, + lease, + attachId, + keepAlive, + phase: "pending", + cleanupRequired: false, + cancelled: false, + cancellationPromise: null + }; + this.remotePendingHistoryAttaches.add(resource); + return resource; + } + + private releaseRemotePendingHistoryAttach( + resource: RemoteAgentPendingHistoryAttachResource + ): void { + resource.cancelled = true; + resource.cleanupRequired = false; + this.remotePendingHistoryAttaches.delete(resource); + this.scope.releaseAccountIfIdle(resource.accountId); + } + + private async cancelRemotePendingHistoryAttach( + resource: RemoteAgentPendingHistoryAttachResource, + accountFenced: boolean + ): Promise { + if (resource.cancelled) return; + resource.cleanupRequired = true; + const existing = resource.cancellationPromise; + if (existing) return await existing; + const cancellation = (async () => { + await completeAgentLiveCleanup(async () => { + const cleanup = async () => { + if (resource.phase === "active") { + const cancel = this.bridge.cancelLiveEvents; + if (!cancel) throw new Error("Agent runtime bridge lost live stream cancellation"); + await cancel.call( + this.bridge, + resource.accountId, + resource.lease, + this.target, + resource.attachId + ); + return; + } + const cancel = this.bridge.cancelSessionHistoryAttach; + if (!cancel) { + throw new Error("Agent runtime bridge lost pending attachment cancellation"); + } + await cancel.call( + this.bridge, + resource.accountId, + resource.lease, + this.target, + resource.attachId + ); + }; + if (accountFenced) { + await this.runBoundLiveOperation(resource.accountId, resource.lease, false, cleanup); + } else { + await cleanup(); + } + }); + this.releaseRemotePendingHistoryAttach(resource); + })(); + resource.cancellationPromise = cancellation; + try { + await cancellation; + } finally { + if (resource.cancellationPromise === cancellation && !resource.cancelled) { + resource.cancellationPromise = null; + } + } + } + + private createPendingHistoryAttach( + userId: string, + lease: AgentExecutionLease, + response: AgentBeginSessionHistoryAttachResponse, + logicalStream: RemoteAgentLogicalLiveStream | null, + pendingResource: RemoteAgentPendingHistoryAttachResource + ): AgentPendingHistoryAttach { + let lifecycle: "pending" | "activating" | "active" | "cancelled" = "pending"; + let activeStream: AgentActiveLiveStream | null = null; + let activation: Promise | null = null; + let cleanupComplete = false; + let cancellationPromise: Promise | null = null; + const isCancelled = () => lifecycle === "cancelled"; + + const pending: AgentPendingHistoryAttach = { + response, + activate: async () => { + if (lifecycle === "cancelled") { + throw new Error("Agent history attachment was cancelled before activation"); + } + if (lifecycle === "active" && activeStream) return activeStream; + if (activation) return await activation; + const activationOpening = this.beginRemoteResourceOpening(userId); + lifecycle = "activating"; + activation = (async () => { + const activate = this.bridge.activateSessionHistoryAttach; + if (!activate) { + throw new Error("Agent runtime bridge does not support history activation"); + } + let barrier: AgentLiveBarrierResponse; + let ownedBinding: RemoteAgentLiveStreamBinding | null = null; + let activationReturned = false; + try { + const raw = await this.runBoundLiveOperation(userId, lease, true, async () => { + return await activate.call( + this.bridge, + userId, + lease, + this.target, + response.attachId + ); + }); + activationReturned = true; + // Native activation consumes the pending token before returning. + // Transfer its exact ID into live cleanup ownership before parsing + // or checking whether the component/account owner is still current. + pendingResource.phase = "active"; + if (logicalStream) { + ownedBinding = this.createRemoteLiveStreamBinding( + lease, + pendingResource.attachId, + pendingResource.keepAlive + ); + this.retainRemoteLiveStreamBinding(logicalStream, ownedBinding); + } + this.releaseRemotePendingHistoryAttach(pendingResource); + barrier = decodeAgentLiveBarrierResponse(raw); + if (barrier.liveStreamId !== response.attachId) { + throw new Error("Agent history activation returned a mismatched live stream ID"); + } + if ( + barrier.throughEventCursor.journalId !== response.throughEventCursor.journalId || + barrier.throughEventCursor.sequence < response.throughEventCursor.sequence + ) { + throw new Error("Agent history activation returned a regressing event checkpoint"); + } + } catch (error) { + if (activationReturned) pendingResource.phase = "active"; + if (logicalStream && !ownedBinding) { + ownedBinding = this.createRemoteLiveStreamBinding( + lease, + pendingResource.attachId, + pendingResource.keepAlive + ); + this.retainRemoteLiveStreamBinding(logicalStream, ownedBinding); + } + if (activationReturned && logicalStream && !pendingResource.cancelled) { + this.releaseRemotePendingHistoryAttach(pendingResource); + } + if (!pendingResource.cancelled) { + pendingResource.cleanupRequired = true; + } + const cleanupResults = await Promise.allSettled([ + ...(logicalStream?.registered + ? [this.closeRemoteLiveStream(logicalStream, true)] + : []), + ...(!pendingResource.cancelled + ? [this.cancelRemotePendingHistoryAttach(pendingResource, true)] + : []) + ]); + try { + throwAgentCleanupFailures( + "Unable to retire an ambiguously activated Agent attachment", + cleanupResults + ); + } catch (cleanupError) { + cleanupComplete = false; + throw cleanupError; + } + cleanupComplete = true; + throw normalizeAgentLiveError(error); + } + const stream = this.createActiveLiveStream( + userId, + lease, + pendingResource.keepAlive as object, + barrier, + logicalStream, + ownedBinding + ); + if (isCancelled()) { + // Cancellation may race activation after the pending token was + // already consumed. Retain the resulting active handle until its + // non-benign cleanup succeeds so the pending owner can retry it. + activeStream = stream; + cleanupComplete = false; + await stream.cancel(); + activeStream = null; + cleanupComplete = true; + throw new Error("Agent history attachment was cancelled during activation"); + } + lifecycle = "active"; + activeStream = stream; + return stream; + })().finally(() => activationOpening.settle()); + return await activation; + }, + cancel: async () => { + if (cleanupComplete) return; + if (cancellationPromise) return await cancellationPromise; + lifecycle = "cancelled"; + const cancellation = (async () => { + const streamToCancel = activeStream; + if (streamToCancel) { + await streamToCancel.cancel(); + activeStream = null; + cleanupComplete = true; + return; + } + const cleanupResults = await Promise.allSettled([ + ...(logicalStream?.registered ? [this.closeRemoteLiveStream(logicalStream, true)] : []), + ...(!pendingResource.cancelled + ? [this.cancelRemotePendingHistoryAttach(pendingResource, true)] + : []) + ]); + throwAgentCleanupFailures( + "Unable to retire the Agent history attachment", + cleanupResults + ); + cleanupComplete = true; + })(); + cancellationPromise = cancellation; + try { + await cancellation; + } finally { + if (cancellationPromise === cancellation) cancellationPromise = null; + } + } + }; + return Object.freeze(pending); + } + + private createActiveLiveStream( + userId: string, + lease: AgentExecutionLease | null, + keepAlive: object, + barrier: AgentLiveBarrierResponse, + logicalStream: RemoteAgentLogicalLiveStream | null, + ownedBinding: RemoteAgentLiveStreamBinding | null = null + ): AgentActiveLiveStream { + if (logicalStream && lease) { + const binding = + ownedBinding ?? this.createRemoteLiveStreamBinding(lease, barrier.liveStreamId, keepAlive); + if (binding.liveStreamId !== barrier.liveStreamId) { + throw new Error("Agent live stream barrier mismatched its cleanup-owned native ID"); + } + this.retainRemoteLiveStreamBinding(logicalStream, binding); + logicalStream.retainedCursor = laterLiveCursor( + logicalStream.retainedCursor, + barrier.throughEventCursor + ); + logicalStream.binding = binding; + return this.publicRemoteLiveStream(logicalStream); + } + let cancelled = false; + let cancellationPromise: Promise | null = null; + return Object.freeze({ + ...barrier, + cancel: async () => { + if (cancelled) return; + if (cancellationPromise) return await cancellationPromise; + const cancellation = (async () => { + void keepAlive; + await completeAgentLiveCleanup(() => + this.cancelBoundLiveStream(userId, lease, barrier.liveStreamId) + ); + cancelled = true; + })(); + cancellationPromise = cancellation; + try { + await cancellation; + } finally { + if (cancellationPromise === cancellation && !cancelled) cancellationPromise = null; + } + } + }); + } + + private createRemoteLiveStreamBinding( + lease: AgentExecutionLease, + liveStreamId: string, + keepAlive: unknown + ): RemoteAgentLiveStreamBinding { + return { + lease, + liveStreamId, + keepAlive, + cancelled: false, + cancellationPromise: null + }; + } + + private retainRemoteLiveStreamBinding( + logicalStream: RemoteAgentLogicalLiveStream, + binding: RemoteAgentLiveStreamBinding + ): void { + if (logicalStream.closed) { + // A replacement open can resolve only after an earlier owner/lease + // retirement finished. Resurrect only cleanup ownership, never delivery. + logicalStream.closed = false; + logicalStream.closeRequested = true; + } + logicalStream.cleanupBindings.add(binding); + logicalStream.registered = true; + this.remoteLiveStreams.add(logicalStream); + } + + private publicRemoteLiveStream( + logicalStream: RemoteAgentLogicalLiveStream + ): AgentActiveLiveStream { + const cancel = async () => { + await this.closeRemoteLiveStream(logicalStream, true); + }; + return Object.freeze({ + get throughEventCursor() { + const cursor = logicalStream.retainedCursor; + if (!cursor) throw new Error("Remote Agent live stream lost its retained cursor"); + return cursor; + }, + get liveStreamId() { + return ( + logicalStream.binding?.liveStreamId ?? + logicalStream.cleanupBindings.values().next().value?.liveStreamId ?? + "retired" + ); + }, + cancel + }); + } + + private async closeRemoteLiveStream( + logicalStream: RemoteAgentLogicalLiveStream, + accountFenced: boolean + ): Promise { + if (logicalStream.closed) return; + logicalStream.closeRequested = true; + const existing = logicalStream.cancellationPromise; + if (existing) return await existing; + logicalStream.bindingEpoch += 1; + const cancellation = (async () => { + while (logicalStream.replacementOpenings.size > 0 || logicalStream.cleanupBindings.size > 0) { + await Promise.all([...logicalStream.replacementOpenings].map((opening) => opening.settled)); + const results = await Promise.allSettled( + [...logicalStream.cleanupBindings].map((binding) => + this.cancelRemoteLiveStreamBinding(logicalStream, binding, accountFenced) + ) + ); + throwAgentCleanupFailures("Unable to retire the remote Agent live stream", results); + } + logicalStream.binding = null; + logicalStream.closed = true; + logicalStream.registered = false; + this.remoteLiveStreams.delete(logicalStream); + this.scope.releaseAccountIfIdle(logicalStream.accountId); + })(); + logicalStream.cancellationPromise = cancellation; + try { + await cancellation; + } finally { + if (logicalStream.cancellationPromise === cancellation) { + logicalStream.cancellationPromise = null; + } + } + } + + private async cancelRemoteLiveStreamBinding( + logicalStream: RemoteAgentLogicalLiveStream, + binding: RemoteAgentLiveStreamBinding, + accountFenced: boolean + ): Promise { + if (binding.cancelled) return; + const existing = binding.cancellationPromise; + if (existing) return await existing; + const cancellation = (async () => { + await completeAgentLiveCleanup(async () => { + const cancel = this.bridge.cancelLiveEvents; + if (!cancel) throw new Error("Agent runtime bridge lost live stream cancellation"); + const cleanup = async () => { + await cancel.call( + this.bridge, + logicalStream.accountId, + binding.lease, + this.target, + binding.liveStreamId + ); + }; + if (accountFenced) { + await this.runBoundLiveOperation(logicalStream.accountId, binding.lease, false, cleanup); + } else { + await cleanup(); + } + }); + binding.cancelled = true; + logicalStream.cleanupBindings.delete(binding); + if (logicalStream.binding === binding) logicalStream.binding = null; + })(); + binding.cancellationPromise = cancellation; + try { + await cancellation; + } finally { + if (binding.cancellationPromise === cancellation && !binding.cancelled) { + binding.cancellationPromise = null; + } + } + } + + private async cancelBoundLiveStream( + userId: string, + lease: AgentExecutionLease | null, + liveStreamId: string + ): Promise { + const cancel = this.bridge.cancelLiveEvents; + if (!cancel) throw new Error("Agent runtime bridge lost live stream cancellation"); + await this.runBoundLiveOperation(userId, lease, false, async () => { + await cancel.call(this.bridge, userId, lease, this.target, liveStreamId); + }); + } + + private async runBoundLiveOperation( userId: string, - command: string, - args?: Record + lease: AgentExecutionLease | null, + requireCurrentLease: boolean, + operation: () => Promise ): Promise { - return await this.bridge.runForUser(userId, async () => { - return await this.bridge.invoke(command, { userId, ...args }); + return await this.bridge.runForUser( + userId, + async () => { + if ( + requireCurrentLease && + this.target.kind === "remote" && + (!lease || !sameExecutionLease(this.currentLease, lease)) + ) { + throw new Error("Remote Agent execution lease changed before live stream activation"); + } + return await operation(); + }, + this.target + ); + } + + private async invokeForUser( + userId: string, + operation: Operation, + request: AgentRuntimeOperationRequestMap[Operation] + ): Promise { + const opening = this.target.kind === "remote" ? this.beginRemoteResourceOpening(userId) : null; + try { + return await this.bridge.runForUser( + userId, + async () => { + const lease = await this.prepareInvocationTarget(userId, true); + return await this.invokeTarget(operation, request, userId, lease); + }, + this.target + ); + } finally { + opening?.settle(); + } + } + + private async invokeControlForUser( + userId: string, + operation: Operation, + request: AgentRuntimeOperationRequestMap[Operation] + ): Promise { + const opening = this.target.kind === "remote" ? this.beginRemoteResourceOpening(userId) : null; + try { + return await this.bridge.runForUser( + userId, + async () => { + const lease = await this.prepareInvocationTarget(userId, false); + return await this.invokeTarget(operation, request, userId, lease); + }, + this.target + ); + } finally { + opening?.settle(); + } + } + + private async prepareInvocationTarget( + userId: string, + syncLocalAuth: boolean + ): Promise { + if (this.target.kind === "remote") { + return await this.prepareRemoteLease(userId); + } + if (!syncLocalAuth) return null; + if (!this.bridge.syncLocalAuth) { + throw new Error("Agent runtime bridge cannot synchronize local authentication"); + } + await this.bridge.syncLocalAuth(userId); + return null; + } + + private async prepareRemoteLease(userId: string): Promise { + if (!this.bridge.prepareTarget) { + throw new Error( + `Agent runtime bridge cannot prepare remote target "${this.target.id as string}"` + ); + } + const sharedPreparation = this.preparationInFlight; + if (sharedPreparation?.accountId === userId) { + return await sharedPreparation.promise; + } + + // Reserve authority before entering native async work. A later preparation + // always wins, and an account switch retires the prior account immediately + // rather than waiting for the replacement connection to finish. + const preparationEpoch = ++this.preparationEpoch; + this.preparingAccountId = userId; + const promise = (async () => { + if (this.currentLease?.accountId !== userId) { + this.currentLease = null; + await this.retireRemoteSubscriptionsExcept(userId); + await this.retireRemotePendingHistoryAttachesExcept(userId); + await this.retireRemoteLiveStreamsExcept(userId); + } + if (preparationEpoch !== this.preparationEpoch || this.preparingAccountId !== userId) { + throw new Error("Remote Agent target preparation was superseded"); + } + return await this.completeRemotePreparation(userId, preparationEpoch); + })(); + this.preparationInFlight = { accountId: userId, promise }; + try { + return await promise; + } finally { + if (this.preparationInFlight?.promise === promise) this.preparationInFlight = null; + } + } + + private async completeRemotePreparation( + userId: string, + preparationEpoch: number + ): Promise { + let rawLease: unknown; + try { + rawLease = await this.bridge.prepareTarget!(userId, this.target); + } catch (error) { + if (preparationEpoch !== this.preparationEpoch || this.preparingAccountId !== userId) { + throw new Error("Remote Agent target preparation was superseded"); + } + throw error; + } + if (preparationEpoch !== this.preparationEpoch || this.preparingAccountId !== userId) { + throw new Error("Remote Agent target preparation was superseded"); + } + const lease = decodeExecutionLease(rawLease, { + accountId: userId, + targetId: this.target.id }); + if (preparationEpoch !== this.preparationEpoch || this.preparingAccountId !== userId) { + throw new Error("Remote Agent target preparation was superseded"); + } + const previousLease = this.currentLease; + this.currentLease = lease; + if (!sameOptionalExecutionLease(previousLease, lease)) { + try { + await this.refreshRemoteSubscriptions(lease); + await this.refreshRemoteLiveStreams(lease); + } catch (error) { + if (sameExecutionLease(this.currentLease, lease)) this.currentLease = null; + throw error; + } + if (preparationEpoch !== this.preparationEpoch || this.preparingAccountId !== userId) { + throw new Error("Remote Agent target preparation was superseded"); + } + } + return lease; } + + private async refreshRemoteSubscriptions(lease: AgentExecutionLease): Promise { + const subscriptions = [...this.remoteSubscriptions].filter( + (subscription) => + !subscription.closed && + !subscription.closeRequested && + subscription.accountId === lease.accountId + ); + for (const subscription of subscriptions) { + await this.bindRemoteSubscription(subscription, lease, "replacement"); + } + } + + private async refreshRemoteLiveStreams(lease: AgentExecutionLease): Promise { + const streams = [...this.remoteLiveStreams].filter( + (stream) => !stream.closed && stream.accountId === lease.accountId + ); + for (const stream of streams) await this.rebindRemoteLiveStream(stream, lease); + } + + private async rebindRemoteLiveStream( + stream: RemoteAgentLogicalLiveStream, + lease: AgentExecutionLease + ): Promise { + if (stream.closeRequested) { + await this.closeRemoteLiveStream(stream, false); + return; + } + const resume = this.bridge.resumeLiveEvents; + const cancel = this.bridge.cancelLiveEvents; + const cursor = stream.retainedCursor; + if (!resume || !cancel || !cursor) { + this.notifyRemoteLiveStreamInvalidated(stream, lease); + throw new Error("Agent runtime bridge cannot replace its synchronized live stream"); + } + const bindingEpoch = ++stream.bindingEpoch; + const cleanupResults = await Promise.allSettled( + [...stream.cleanupBindings].map((binding) => + this.cancelRemoteLiveStreamBinding(stream, binding, false) + ) + ); + try { + throwAgentCleanupFailures("Unable to retire the replaced Agent live stream", cleanupResults); + } catch (error) { + this.notifyRemoteLiveStreamInvalidated(stream, lease); + throw error; + } + if (stream.closed || stream.closeRequested || !sameExecutionLease(this.currentLease, lease)) { + return; + } + + const replacementOpening = this.beginRemoteLiveStreamReplacementOpening(stream); + try { + let opened: AgentBridgeLiveChannelResult; + try { + opened = await resume.call( + this.bridge, + stream.accountId, + lease, + this.target, + cursor, + this.liveChannelDecoder(lease, stream.handler, stream, bindingEpoch) + ); + } catch (error) { + this.notifyRemoteLiveStreamInvalidated(stream, lease); + throw normalizeAgentLiveError(error); + } + + const replacementId = liveStreamIdFromUnknown(opened.result); + const provisionalBinding = replacementId + ? this.createRemoteLiveStreamBinding(lease, replacementId, opened.keepAlive) + : null; + if (provisionalBinding) this.retainRemoteLiveStreamBinding(stream, provisionalBinding); + + let barrier: AgentLiveBarrierResponse; + try { + if (!isRecord(opened.keepAlive)) { + throw new Error("Agent runtime bridge did not retain its replacement live event channel"); + } + barrier = decodeAgentLiveBarrierResponse(opened.result); + if (!provisionalBinding || provisionalBinding.liveStreamId !== barrier.liveStreamId) { + throw new Error("Agent replacement live stream lost its cleanup-owned native ID"); + } + if ( + barrier.throughEventCursor.journalId !== cursor.journalId || + barrier.throughEventCursor.sequence < cursor.sequence + ) { + throw new Error("Agent replacement live stream returned a regressing event checkpoint"); + } + } catch (error) { + stream.bindingEpoch += 1; + if (provisionalBinding) { + try { + await this.cancelRemoteLiveStreamBinding(stream, provisionalBinding, false); + } catch (cleanupError) { + this.notifyRemoteLiveStreamInvalidated(stream, lease); + throw cleanupError; + } + } + this.notifyRemoteLiveStreamInvalidated(stream, lease); + throw error; + } + + if ( + stream.closed || + stream.closeRequested || + stream.bindingEpoch !== bindingEpoch || + !sameExecutionLease(this.currentLease, lease) + ) { + if (provisionalBinding) { + await this.cancelRemoteLiveStreamBinding(stream, provisionalBinding, false); + } + return; + } + stream.retainedCursor = laterLiveCursor(stream.retainedCursor, barrier.throughEventCursor); + stream.binding = provisionalBinding; + } finally { + replacementOpening.settle(); + } + } + + private notifyRemoteLiveStreamInvalidated( + stream: RemoteAgentLogicalLiveStream, + lease: AgentExecutionLease + ): void { + const cursor = stream.retainedCursor; + if (!cursor || stream.closed) return; + stream.handler({ + liveEventVersion: 1, + eventType: "snapshotRequired", + targetId: lease.targetId, + hostEpoch: lease.hostEpoch, + connectionGeneration: lease.connectionGeneration, + reason: "ordering_lost", + lastEventCursor: cursor + }); + } + + private async bindRemoteSubscription( + subscription: RemoteAgentLogicalSubscription, + lease: AgentExecutionLease, + phase: RemoteAgentSubscriptionBindPhase + ): Promise { + if (!this.bridge.listenToEvents) { + throw new Error( + `Agent runtime bridge does not support events for remote target "${this.target.id as string}"` + ); + } + if (subscription.closeRequested) { + await this.closeRemoteSubscription(subscription); + if (phase === "initial") throw retiredInitialRemoteSubscriptionError(); + return; + } + const bindingEpoch = ++subscription.bindingEpoch; + subscription.lease = lease; + const replacementOpening = this.beginRemoteSubscriptionReplacementOpening(subscription); + try { + let replacementUnlisten: UnlistenAgentEvents; + try { + replacementUnlisten = await this.bridge.listenToEvents(lease, this.target, (event) => { + if ( + subscription.closed || + subscription.closeRequested || + subscription.bindingEpoch !== bindingEpoch || + !sameExecutionLease(subscription.lease, lease) || + !sameExecutionLease(this.currentLease, lease) + ) { + return; + } + const decoded = decodeRemoteAgentEvent(event, lease); + if (decoded) subscription.handler(decoded); + }); + } catch (error) { + if (subscription.closeRequested || subscription.closed) { + if (phase === "initial") throw retiredInitialRemoteSubscriptionError(); + return; + } + if ( + subscription.bindingEpoch !== bindingEpoch || + !sameExecutionLease(subscription.lease, lease) || + !sameExecutionLease(this.currentLease, lease) + ) { + // The rejecting native bind belongs to retired authority. A newer + // bind owns the logical subscription, so there is no native listener + // from this failed attempt to retain. + return; + } + throw error; + } + + const replacementBinding: RemoteAgentSubscriptionBinding = { + unlisten: replacementUnlisten, + cancelled: false, + cancellationPromise: null + }; + subscription.cleanupBindings.add(replacementBinding); + if ( + subscription.closed || + subscription.closeRequested || + subscription.bindingEpoch !== bindingEpoch || + !sameExecutionLease(this.currentLease, lease) + ) { + await this.cancelRemoteSubscriptionBinding(subscription, replacementBinding); + if (phase === "initial" && subscription.closeRequested) { + throw retiredInitialRemoteSubscriptionError(); + } + return; + } + + subscription.binding = replacementBinding; + const cleanupResults = await Promise.allSettled( + [...subscription.cleanupBindings] + .filter((binding) => binding !== replacementBinding) + .map((binding) => this.cancelRemoteSubscriptionBinding(subscription, binding)) + ); + throwAgentCleanupFailures("Unable to retire the replaced Agent subscription", cleanupResults); + } finally { + replacementOpening.settle(); + } + } + + private beginRemoteSubscriptionReplacementOpening( + subscription: RemoteAgentLogicalSubscription + ): RemoteAgentResourceOpening { + const opening = this.beginRemoteResourceOpening(subscription.accountId); + const settleOpening = opening.settle; + opening.settle = () => { + subscription.replacementOpenings.delete(opening); + settleOpening(); + }; + subscription.replacementOpenings.add(opening); + return opening; + } + + private async cancelRemoteSubscriptionBinding( + subscription: RemoteAgentLogicalSubscription, + binding: RemoteAgentSubscriptionBinding + ): Promise { + if (binding.cancelled) return; + const existing = binding.cancellationPromise; + if (existing) return await existing; + const cancellation = (async () => { + binding.unlisten(); + binding.cancelled = true; + subscription.cleanupBindings.delete(binding); + if (subscription.binding === binding) subscription.binding = null; + })(); + binding.cancellationPromise = cancellation; + try { + await cancellation; + } finally { + if (binding.cancellationPromise === cancellation && !binding.cancelled) { + binding.cancellationPromise = null; + } + } + } + + private async closeRemoteSubscription( + subscription: RemoteAgentLogicalSubscription + ): Promise { + if (subscription.closed) return; + subscription.closeRequested = true; + const existing = subscription.cancellationPromise; + if (existing) return await existing; + subscription.bindingEpoch += 1; + const cancellation = (async () => { + while (subscription.replacementOpenings.size > 0 || subscription.cleanupBindings.size > 0) { + await Promise.all([...subscription.replacementOpenings].map((opening) => opening.settled)); + const results = await Promise.allSettled( + [...subscription.cleanupBindings].map((binding) => + this.cancelRemoteSubscriptionBinding(subscription, binding) + ) + ); + throwAgentCleanupFailures("Unable to retire the remote Agent subscription", results); + } + subscription.binding = null; + subscription.closed = true; + this.remoteSubscriptions.delete(subscription); + this.scope.releaseAccountIfIdle(subscription.accountId); + })(); + subscription.cancellationPromise = cancellation; + try { + await cancellation; + } finally { + if (subscription.cancellationPromise === cancellation) { + subscription.cancellationPromise = null; + } + } + } + + private async retireRemoteSubscriptionsExcept(accountId: string): Promise { + const results = await Promise.allSettled( + [...this.remoteSubscriptions] + .filter((subscription) => subscription.accountId !== accountId) + .map((subscription) => this.closeRemoteSubscription(subscription)) + ); + throwAgentCleanupFailures("Unable to retire prior-account Agent subscriptions", results); + } + + private async retireRemoteLiveStreamsExcept(accountId: string): Promise { + for (const stream of [...this.remoteLiveStreams]) { + if (stream.accountId !== accountId) await this.closeRemoteLiveStream(stream, false); + } + } + + private async retireRemotePendingHistoryAttachesExcept(accountId: string): Promise { + for (const resource of [...this.remotePendingHistoryAttaches]) { + if (resource.accountId !== accountId) { + await this.cancelRemotePendingHistoryAttach(resource, false); + } + } + } + + private async invokeTarget( + operation: Operation, + request: AgentRuntimeOperationRequestMap[Operation], + userId: string, + lease: AgentExecutionLease | null + ): Promise { + if (this.target.kind === "remote") { + if (operation === "listSessions" || operation === "loadSession") { + throw new Error(`Remote Agent invocation cannot use unpaged operation "${operation}"`); + } + if (!lease || !sameExecutionLease(this.currentLease, lease) || !this.bridge.invokeTarget) { + throw new Error( + `Agent runtime bridge does not hold a current lease for remote target "${this.target.id as string}"` + ); + } + const invocation = createRemoteInvocation(operation, request); + const result = await this.bridge.invokeTarget(lease, invocation); + if (!sameExecutionLease(this.currentLease, lease)) { + throw new Error(`Remote Agent execution lease changed while "${operation}" was in flight`); + } + return decodeAgentOperationResult(operation, result, true); + } + if (this.target.id !== LOCAL_AGENT_EXECUTION_TARGET_ID) { + throw new Error(`Unknown local Agent execution target "${this.target.id as string}"`); + } + if (!this.bridge.invoke) { + throw new Error("Agent runtime bridge does not support the local execution target"); + } + if (!(operation in LOCAL_COMMAND_BY_AGENT_RUNTIME_OPERATION)) { + throw new Error(`The embedded Agent runtime does not yet support operation "${operation}"`); + } + const command = + LOCAL_COMMAND_BY_AGENT_RUNTIME_OPERATION[ + operation as keyof typeof LOCAL_COMMAND_BY_AGENT_RUNTIME_OPERATION + ]; + const args = { userId, ...(request ?? {}) }; + if (operation === "listSessionsPage" || operation === "listSessionRecordsPage") { + const result = await this.bridge.invoke(command, args); + return decodeAgentOperationResult(operation, result, false); + } + return await this.bridge.invoke(command, args); + } + + private assertLocalCompatibilityOperation(operation: "listSessions" | "loadSession"): void { + if (this.target.kind === "remote") { + throw new Error( + `Remote Agent targets must use paged history APIs; "${operation}" is embedded-only compatibility` + ); + } + } +} + +function retiredInitialRemoteSubscriptionError(): Error { + return new Error("Remote Agent event subscription was retired before its initial bind completed"); +} + +function isConnectionGeneration(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function isVerifiedConnectionGeneration(value: unknown): value is number { + return isConnectionGeneration(value) && value > 0; +} + +function isCanonicalHostEpoch(value: unknown): value is string { + return ( + isString(value) && + value.length > 0 && + value.length <= MAX_AGENT_HOST_EPOCH_BYTES && + /^[1-9][0-9]*$/.test(value) && + (value.length < MAX_U64_DECIMAL.length || value <= MAX_U64_DECIMAL) + ); +} + +function sameExecutionLease(left: AgentExecutionLease | null, right: AgentExecutionLease): boolean { + return ( + left !== null && + left.accountId === right.accountId && + left.targetId === right.targetId && + left.hostEpoch === right.hostEpoch && + left.connectionGeneration === right.connectionGeneration + ); +} + +function sameOptionalExecutionLease( + left: AgentExecutionLease | null, + right: AgentExecutionLease +): boolean { + return left !== null && sameExecutionLease(left, right); +} + +function laterLiveCursor( + current: AgentLiveEventCursor | null, + candidate: AgentLiveEventCursor +): AgentLiveEventCursor { + if (!current) return candidate; + if (current.journalId !== candidate.journalId) return candidate; + return candidate.sequence >= current.sequence ? candidate : current; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function isNullableString(value: unknown): value is string | null | undefined { + return value === null || value === undefined || isString(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function decodeExecutionLease( + value: unknown, + expected: { accountId: string; targetId: AgentExecutionTargetId } +): AgentExecutionLease { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["targetId", "hostEpoch", "connectionGeneration"]) || + value.targetId !== expected.targetId || + !isCanonicalHostEpoch(value.hostEpoch) || + !isVerifiedConnectionGeneration(value.connectionGeneration) + ) { + throw new Error("Remote Agent bridge returned an invalid or mismatched execution lease"); + } + return Object.freeze({ + accountId: expected.accountId, + targetId: value.targetId as AgentExecutionTargetId, + hostEpoch: value.hostEpoch, + connectionGeneration: value.connectionGeneration + }) as AgentExecutionLease; +} + +function validateAgentPageRequest( + request: unknown +): asserts request is AgentPageRequest & Record { + if (!isRecord(request)) throw new Error("Agent page request must be an object"); + if ( + request.limit !== undefined && + (!isFiniteNumber(request.limit) || + !Number.isInteger(request.limit) || + request.limit < 1 || + request.limit > MAX_AGENT_PAGE_SIZE) + ) { + throw new Error(`Agent page limit must be between 1 and ${MAX_AGENT_PAGE_SIZE}`); + } + if ( + request.cursor !== undefined && + request.cursor !== null && + (!isString(request.cursor) || + request.cursor.length === 0 || + request.cursor.length > MAX_AGENT_CURSOR_BYTES || + !isAscii(request.cursor)) + ) { + throw new Error("Agent page cursor must be non-empty bounded ASCII"); + } +} + +function validateReturnedAgentPage( + kind: string, + request: AgentPageRequest, + itemCount: number, + nextCursor: string | null +): void { + const requestedLimit = request.limit ?? DEFAULT_AGENT_PAGE_SIZE; + if (itemCount > requestedLimit) { + throw new Error(`Agent ${kind} page exceeded the requested record limit`); + } + if (nextCursor && itemCount === 0) { + throw new Error(`Agent ${kind} page returned a cursor without records`); + } + if (nextCursor && request.cursor && nextCursor === request.cursor) { + throw new Error(`Agent ${kind} page cursor did not progress`); + } +} + +function normalizeAgentPageError(error: unknown): unknown { + if (isAgentPageStaleError(error)) return error; + const code = isRecord(error) ? error.code : undefined; + if (code === "history_record_too_large") return new AgentHistoryRecordTooLargeError(); + const message = error instanceof Error ? error.message : isString(error) ? error : ""; + if ( + code === "stale_history" || + code === "StaleHistory" || + message.includes("Agent task history changed; reload its newest page") + ) { + return new AgentPageStaleError(); + } + return error; +} + +export class AgentLiveSnapshotRequiredError extends Error { + constructor( + readonly reason: AgentLiveSnapshotReason, + readonly lastEventCursor?: AgentLiveEventCursor + ) { + super("Agent live history requires a synchronized snapshot"); + this.name = "AgentLiveSnapshotRequiredError"; + } +} + +export function isAgentLiveSnapshotRequiredError( + error: unknown +): error is AgentLiveSnapshotRequiredError { + return error instanceof AgentLiveSnapshotRequiredError; +} + +function normalizeAgentLiveError(error: unknown): unknown { + if (isAgentLiveSnapshotRequiredError(error)) return error; + if (!isRecord(error)) return error; + const code = error.code; + if (code === "history_record_too_large") return new AgentHistoryRecordTooLargeError(); + if (code !== "snapshot_required") return error; + const reason = decodeAgentLiveSnapshotReason(error.reason); + if (!reason) return new Error("Agent live history returned an invalid snapshot reason"); + const cursor = + error.lastEventCursor === undefined + ? undefined + : decodeAgentLiveEventCursor(error.lastEventCursor); + if (cursor === null) return new Error("Agent live history returned an invalid event cursor"); + return new AgentLiveSnapshotRequiredError(reason, cursor); +} + +function isBenignAgentLiveCleanupError(error: unknown): boolean { + if (!isRecord(error)) return false; + return ( + error.code === "attach_not_found" || + error.code === "stale_lease" || + error.code === "channel_closed" + ); +} + +async function completeAgentLiveCleanup(cleanup: () => Promise): Promise { + try { + await cleanup(); + } catch (error) { + if (!isBenignAgentLiveCleanupError(error)) throw error; + } +} + +function throwAgentCleanupFailures( + message: string, + results: readonly PromiseSettledResult[] +): void { + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [] + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AgentRuntimeResourceCleanupError(message, failures); +} + +class AgentRuntimeResourceCleanupError extends Error { + constructor( + message: string, + readonly errors: readonly unknown[] + ) { + super(message); + this.name = "AgentRuntimeResourceCleanupError"; + } +} + +function validateAgentListSessionsPageRequest( + request: unknown +): asserts request is AgentListSessionsPageRequest { + validateAgentPageRequest(request); + if ( + request.projectRoot !== undefined && + request.projectRoot !== null && + !isString(request.projectRoot) + ) { + throw new Error("Agent session page project root must be a string or null"); + } +} + +function validateAgentListSessionRecordsPageRequest( + request: unknown +): asserts request is AgentListSessionRecordsPageRequest { + validateAgentPageRequest(request); + if (!isString(request.sessionId) || request.sessionId.length === 0) { + throw new Error("Agent history page session ID must be a non-empty string"); + } +} + +function validateAgentLiveEventCursor(value: unknown): asserts value is AgentLiveEventCursor { + if (!decodeAgentLiveEventCursor(value)) { + throw new Error("Agent live event cursor is invalid"); + } +} + +function isAscii(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) > 0x7f) return false; + } + return true; +} + +function isPrintableAscii(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code < 0x20 || code > 0x7e) return false; + } + return true; +} + +const AGENT_UTF8_ENCODER = new TextEncoder(); + +function hasUnsafeLiveIdentifierCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if ( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) || + codePoint === 0x061c || + codePoint === 0x200e || + codePoint === 0x200f || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + ) { + return true; + } + } + return false; +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true; + } + } + return false; +} + +function utf8ByteLength(value: string): number { + return AGENT_UTF8_ENCODER.encode(value).byteLength; +} + +function compareUtf8Bytes(left: string, right: string): number { + const leftBytes = AGENT_UTF8_ENCODER.encode(left); + const rightBytes = AGENT_UTF8_ENCODER.encode(right); + const sharedLength = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < sharedLength; index += 1) { + if (leftBytes[index] !== rightBytes[index]) return leftBytes[index] - rightBytes[index]; + } + return leftBytes.length - rightBytes.length; +} + +function isBoundedLiveIdentifier(value: unknown, maxBytes: number): value is string { + return ( + isString(value) && + value.length > 0 && + !hasUnpairedSurrogate(value) && + utf8ByteLength(value) <= maxBytes && + !hasUnsafeLiveIdentifierCharacter(value) + ); +} + +function isBoundedLiveText(value: unknown, maxBytes: number): value is string { + return ( + isString(value) && + !value.includes("\0") && + !hasUnpairedSurrogate(value) && + utf8ByteLength(value) <= maxBytes + ); +} + +function isOptionalBoundedLiveText(value: unknown, maxBytes: number): value is string | undefined { + return value === undefined || isBoundedLiveText(value, maxBytes); +} + +function isBoundedAgentDisplayText(value: unknown, maxBytes: number): value is string { + return ( + isBoundedLiveText(value, maxBytes) && + value.length > 0 && + !hasUnsafeLiveIdentifierCharacter(value) + ); +} + +function isOptionalBoundedAgentDisplayText( + value: unknown, + maxBytes: number +): value is string | null | undefined { + return value === undefined || value === null || isBoundedAgentDisplayText(value, maxBytes); +} + +function isNonnegativeSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)); +} + +function decodeAgentLiveEventCursor(value: unknown): AgentLiveEventCursor | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["journalId", "sequence"]) || + !isString(value.journalId) || + !AGENT_LIVE_JOURNAL_ID_PATTERN.test(value.journalId) || + !isConnectionGeneration(value.sequence) + ) { + return null; + } + return { journalId: value.journalId, sequence: value.sequence }; +} + +function decodeAgentPresentedTimelineItem(value: unknown): AgentPresentedTimelineItem | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, [ + "id", + "itemType", + "role", + "title", + "text", + "status", + "createdMs", + "merge" + ]) || + !isBoundedLiveIdentifier(value.id, MAX_AGENT_LIVE_ID_BYTES) || + (value.itemType !== "message" && + value.itemType !== "thinking" && + value.itemType !== "tool" && + value.itemType !== "permission" && + value.itemType !== "system" && + value.itemType !== "error") || + (value.role !== undefined && + value.role !== "user" && + value.role !== "assistant" && + value.role !== "thought" && + value.role !== "system") || + !isOptionalBoundedLiveText(value.title, MAX_AGENT_LIVE_TITLE_BYTES) || + !isOptionalBoundedLiveText(value.text, MAX_AGENT_LIVE_TEXT_BYTES) || + !isOptionalBoundedLiveText(value.status, MAX_AGENT_LIVE_STATUS_BYTES) || + !isNonnegativeSafeInteger(value.createdMs) || + (value.merge !== "append" && value.merge !== "replace") + ) { + return null; + } + + if (value.itemType === "tool") { + let expectedText: string | undefined; + switch (value.status) { + case undefined: + case "pending": + case "running": + case "completed": + expectedText = undefined; + break; + case "failed": + case "error": + expectedText = SAFE_REMOTE_TOOL_FAILED; + break; + case "cancelled": + expectedText = SAFE_REMOTE_TOOL_CANCELLED; + break; + default: + return null; + } + if ( + value.role !== "assistant" || + value.title !== SAFE_REMOTE_TOOL_TITLE || + value.text !== expectedText + ) { + return null; + } + } else if (value.itemType === "permission") { + if ( + value.role !== "system" || + value.title !== SAFE_REMOTE_PERMISSION_TITLE || + value.text !== undefined || + (value.status !== "allow_once" && + value.status !== "deny_once" && + value.status !== "completed" && + value.status !== "cancelled") + ) { + return null; + } + } else if (value.itemType === "error") { + if ( + value.role !== "system" || + value.title !== "Agent error" || + value.text !== SAFE_REMOTE_AGENT_ERROR || + value.status !== "failed" + ) { + return null; + } + } + + return value as unknown as AgentPresentedTimelineItem; +} + +function isAgentPresentedUserFacingErrorItem(item: AgentPresentedTimelineItem): boolean { + if (item.merge !== "replace" || item.role !== "system") return false; + return ( + (item.itemType === "system" && + item.title === "Agent warning" && + item.text === SAFE_REMOTE_SETUP_WARNING && + item.status === "warning") || + (item.itemType === "error" && + item.title === "Agent error" && + item.text === SAFE_REMOTE_AGENT_ERROR && + item.status === "failed") + ); +} + +function agentPresentedTimelineItemBudgetBytes(item: AgentPresentedTimelineItem): number { + return ( + 256 + + utf8ByteLength(item.id) + + utf8ByteLength(item.itemType) + + (item.role ? utf8ByteLength(item.role) : 0) + + (item.title ? utf8ByteLength(item.title) : 0) + + (item.text ? utf8ByteLength(item.text) : 0) + + (item.status ? utf8ByteLength(item.status) : 0) + + utf8ByteLength(item.merge) + ); +} + +function decodeAgentPresentedSessionSummary(value: unknown): AgentSessionSummary | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, [ + "id", + "title", + "projectRoot", + "createdMs", + "updatedMs", + "pageSortMs", + "messageCount", + "model", + "mode" + ]) || + !isBoundedLiveIdentifier(value.id, MAX_AGENT_LIVE_ID_BYTES) || + !isBoundedAgentDisplayText(value.title, MAX_AGENT_LIVE_TITLE_BYTES) || + !isBoundedAgentDisplayText(value.projectRoot, MAX_AGENT_LIVE_PROJECT_ROOT_BYTES) || + !isNonnegativeSafeInteger(value.createdMs) || + !isNonnegativeSafeInteger(value.updatedMs) || + !isNonnegativeSafeInteger(value.pageSortMs) || + !isNonnegativeSafeInteger(value.messageCount) || + !isOptionalBoundedAgentDisplayText(value.model, MAX_AGENT_LIVE_MODEL_BYTES) || + !isBoundedAgentDisplayText(value.mode, MAX_AGENT_LIVE_MODE_BYTES) + ) { + return null; + } + return { + id: value.id, + title: value.title, + projectRoot: value.projectRoot, + createdMs: value.createdMs, + updatedMs: value.updatedMs, + pageSortMs: value.pageSortMs, + messageCount: value.messageCount, + ...(value.model === undefined ? {} : { model: value.model }), + mode: value.mode + }; +} + +function isAgentPresentedHistoryRecord(value: unknown): value is AgentPresentedHistoryRecord { + if ( + !( + isRecord(value) && + hasOnlyKeys(value, ["recordId", "role", "createdMs", "items"]) && + isString(value.recordId) && + value.recordId.length > 0 && + value.recordId.length <= MAX_AGENT_CURSOR_BYTES && + AGENT_SAFE_HISTORY_TOKEN_PATTERN.test(value.recordId) && + isString(value.role) && + value.role.length > 0 && + value.role.length <= MAX_AGENT_HISTORY_ROLE_BYTES && + isPrintableAscii(value.role) && + isNonnegativeSafeInteger(value.createdMs) && + Array.isArray(value.items) && + value.items.length <= MAX_AGENT_HISTORY_ITEMS_PER_RECORD && + value.items.every((item) => decodeAgentPresentedTimelineItem(item) !== null) + ) + ) { + return false; + } + // Conservative counterpart to native's exact CBOR frame cap. Fixed per-item + // overhead exceeds the closed map's actual CBOR keys/integers without JSON's + // escape expansion, keeping the bridge boundary bounded near the same 1 MiB. + let estimatedBytes = 512 + utf8ByteLength(value.recordId) + utf8ByteLength(value.role); + for (const item of value.items as AgentPresentedTimelineItem[]) { + estimatedBytes += agentPresentedTimelineItemBudgetBytes(item); + if (estimatedBytes > MAX_AGENT_HISTORY_RECORD_PRESENTATION_BYTES) return false; + } + return true; +} + +function isAgentPresentedSessionRecordsPage( + value: unknown +): value is AgentPresentedSessionRecordsPage { + if ( + !( + isRecord(value) && + hasOnlyKeys(value, ["records", "nextCursor", "historyRevision"]) && + Array.isArray(value.records) && + value.records.length <= MAX_AGENT_PAGE_SIZE && + value.records.every(isAgentPresentedHistoryRecord) && + isNullableString(value.nextCursor) && + (value.nextCursor === undefined || + value.nextCursor === null || + (value.nextCursor.length > 0 && + value.nextCursor.length <= MAX_AGENT_CURSOR_BYTES && + AGENT_SAFE_HISTORY_TOKEN_PATTERN.test(value.nextCursor))) && + isString(value.historyRevision) && + value.historyRevision.length > 0 && + value.historyRevision.length <= MAX_AGENT_CURSOR_BYTES && + AGENT_SAFE_HISTORY_TOKEN_PATTERN.test(value.historyRevision) + ) + ) { + return false; + } + const recordIds = new Set(); + for (const record of value.records as AgentPresentedHistoryRecord[]) { + if (recordIds.has(record.recordId)) return false; + recordIds.add(record.recordId); + } + return true; +} + +function decodeAgentBeginSessionHistoryAttachResponse( + value: unknown +): AgentBeginSessionHistoryAttachResponse { + if ( + !isRecord(value) || + !hasOnlyKeys(value, [ + "attachId", + "page", + "liveSessionsComplete", + "liveSessionCount", + "liveSessions", + "throughEventCursor" + ]) || + !isBoundedAgentCursor(value.attachId) || + !isAgentPresentedSessionRecordsPage(value.page) || + value.page.nextCursor === null || + value.liveSessionsComplete !== true || + !Number.isSafeInteger(value.liveSessionCount) || + (value.liveSessionCount as number) < 0 || + (value.liveSessionCount as number) > MAX_AGENT_LIVE_SESSIONS_PER_ACCOUNT || + !Array.isArray(value.liveSessions) || + value.liveSessions.length !== value.liveSessionCount + ) { + throw new Error("Agent runtime bridge returned an invalid synchronized history attachment"); + } + let previousSessionId: string | null = null; + let totalItems = 0; + let projectedBytes = 4 * 1024; + for (const liveSession of value.liveSessions) { + if ( + !isRecord(liveSession) || + !hasOnlyKeys(liveSession, ["sessionId", "liveItems"]) || + !isBoundedLiveIdentifier(liveSession.sessionId, MAX_AGENT_LIVE_ID_BYTES) || + (previousSessionId !== null && + compareUtf8Bytes(previousSessionId, liveSession.sessionId) >= 0) || + !Array.isArray(liveSession.liveItems) || + liveSession.liveItems.length === 0 || + liveSession.liveItems.length > MAX_AGENT_LIVE_ITEMS_PER_SESSION + ) { + throw new Error("Agent runtime bridge returned invalid synchronized live sessions"); + } + previousSessionId = liveSession.sessionId; + projectedBytes += 256 + utf8ByteLength(liveSession.sessionId); + const itemIds = new Set(); + for (const item of liveSession.liveItems) { + const decoded = decodeAgentPresentedTimelineItem(item); + if (!decoded || decoded.merge !== "replace" || itemIds.has(decoded.id)) { + throw new Error("Agent runtime bridge returned an invalid synchronized live suffix"); + } + itemIds.add(decoded.id); + projectedBytes += agentPresentedTimelineItemBudgetBytes(decoded); + if (projectedBytes > MAX_AGENT_LIVE_PROJECTION_BYTES_PER_ACCOUNT) { + throw new Error("Agent runtime bridge returned an oversized synchronized live snapshot"); + } + } + totalItems += liveSession.liveItems.length; + if (totalItems > MAX_AGENT_LIVE_ITEMS_PER_ACCOUNT) { + throw new Error("Agent runtime bridge returned too many synchronized live items"); + } + } + const throughEventCursor = decodeAgentLiveEventCursor(value.throughEventCursor); + if (!throughEventCursor) { + throw new Error("Agent runtime bridge returned an invalid synchronized event checkpoint"); + } + return value as unknown as AgentBeginSessionHistoryAttachResponse; +} + +function decodeAgentLiveBarrierResponse(value: unknown): AgentLiveBarrierResponse { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["throughEventCursor", "liveStreamId"]) || + !isBoundedAgentCursor(value.liveStreamId) + ) { + throw new Error("Agent runtime bridge returned an invalid live stream barrier"); + } + const throughEventCursor = decodeAgentLiveEventCursor(value.throughEventCursor); + if (!throughEventCursor) { + throw new Error("Agent runtime bridge returned an invalid live stream checkpoint"); + } + return { throughEventCursor, liveStreamId: value.liveStreamId }; +} + +function liveStreamIdFromUnknown(value: unknown): string | null { + return isRecord(value) && isBoundedAgentCursor(value.liveStreamId) ? value.liveStreamId : null; +} + +function attachIdFromUnknown(value: unknown): string | null { + return isRecord(value) && isBoundedAgentCursor(value.attachId) ? value.attachId : null; +} + +const AGENT_LIVE_SNAPSHOT_REASONS = new Set([ + "paused_overflow", + "slow_subscriber", + "journal_replaced", + "retention_gap", + "cursor_ahead", + "owner_changed", + "ordering_lost", + "journal_unavailable" +]); + +function decodeAgentLiveSnapshotReason(value: unknown): AgentLiveSnapshotReason | null { + return isString(value) && AGENT_LIVE_SNAPSHOT_REASONS.has(value as AgentLiveSnapshotReason) + ? (value as AgentLiveSnapshotReason) + : null; +} + +function createRemoteInvocation( + operation: Operation, + request: AgentRuntimeOperationRequestMap[Operation] +): AgentRuntimeInvocation { + if (operation === "listSessions" || operation === "loadSession") { + throw new Error(`Remote Agent invocation cannot use unpaged operation "${operation}"`); + } + return (request === undefined ? { operation } : { operation, request }) as AgentRuntimeInvocation; +} + +function decodeRemoteAgentEvent( + value: unknown, + lease: AgentExecutionLease +): TargetedAgentEventEnvelope | null { + return decodeTargetedAgentEvent( + value, + lease.targetId, + lease.hostEpoch, + lease.connectionGeneration + ); +} + +function decodeTargetedAgentEvent( + value: unknown, + targetId: AgentExecutionTargetId, + hostEpoch: string, + connectionGeneration: number +): TargetedAgentEventEnvelope | null { + if ( + !isRecord(value) || + value.targetId !== targetId || + value.hostEpoch !== hostEpoch || + value.connectionGeneration !== connectionGeneration + ) { + return null; + } + const payload = decodeAgentEventPayload(value); + const ordering = decodeAgentEventOrdering(value); + if ( + !payload || + !ordering || + ordering.eventEpoch === undefined || + ordering.eventSequence === undefined + ) { + return null; + } + return Object.freeze({ + ...payload, + ...ordering, + targetId, + hostEpoch, + connectionGeneration + }) as TargetedAgentEventEnvelope; +} + +function decodeAgentLiveChannelFrame( + value: unknown, + targetId: AgentExecutionTargetId, + hostEpoch: string, + connectionGeneration: number +): AgentLiveChannelFrame | null { + if ( + isRecord(value) && + value.eventType === "snapshotRequired" && + value.liveEventVersion === AGENT_LIVE_PRESENTATION_VERSION && + value.targetId === targetId && + value.hostEpoch === hostEpoch && + value.connectionGeneration === connectionGeneration && + hasOnlyKeys(value, [ + "liveEventVersion", + "eventType", + "targetId", + "hostEpoch", + "connectionGeneration", + "reason", + "lastEventCursor" + ]) + ) { + const reason = decodeAgentLiveSnapshotReason(value.reason); + const lastEventCursor = decodeAgentLiveEventCursor(value.lastEventCursor); + return reason && lastEventCursor + ? { + liveEventVersion: AGENT_LIVE_PRESENTATION_VERSION, + eventType: "snapshotRequired", + targetId, + hostEpoch, + connectionGeneration, + reason, + lastEventCursor + } + : null; + } + if ( + !isRecord(value) || + value.liveEventVersion !== AGENT_LIVE_PRESENTATION_VERSION || + value.targetId !== targetId || + value.hostEpoch !== hostEpoch || + value.connectionGeneration !== connectionGeneration || + !isString(value.eventEpoch) || + !AGENT_LIVE_JOURNAL_ID_PATTERN.test(value.eventEpoch) || + !isNonnegativeSafeInteger(value.eventSequence) || + value.eventSequence === 0 || + !isBoundedLiveIdentifier(value.sessionId, MAX_AGENT_LIVE_ID_BYTES) + ) { + return null; + } + + const common = { + liveEventVersion: AGENT_LIVE_PRESENTATION_VERSION, + targetId, + hostEpoch, + connectionGeneration, + eventEpoch: value.eventEpoch, + eventSequence: value.eventSequence, + sessionId: value.sessionId + } as const; + const orderedKeys = [ + "liveEventVersion", + "targetId", + "hostEpoch", + "connectionGeneration", + "eventEpoch", + "eventSequence", + "sessionId", + "eventType" + ] as const; + const requiredRunId = () => + isBoundedLiveIdentifier(value.runId, MAX_AGENT_LIVE_ID_BYTES) ? value.runId : null; + const optionalRunId = () => + value.runId === undefined + ? undefined + : isBoundedLiveIdentifier(value.runId, MAX_AGENT_LIVE_ID_BYTES) + ? value.runId + : null; + + switch (value.eventType) { + case "runStarted": { + const runId = requiredRunId(); + return runId && hasOnlyKeys(value, [...orderedKeys, "runId"]) + ? { ...common, eventType: "runStarted", runId } + : null; + } + case "timelineUpsert": { + const runId = optionalRunId(); + const item = decodeAgentPresentedTimelineItem(value.item); + return runId !== null && item && hasOnlyKeys(value, [...orderedKeys, "runId", "item"]) + ? { + ...common, + eventType: "timelineUpsert", + ...(runId === undefined ? {} : { runId }), + item + } + : null; + } + case "timelineCleared": { + if (value.reason === "explicit_reload") { + return hasOnlyKeys(value, [...orderedKeys, "reason"]) + ? { ...common, eventType: "timelineCleared", reason: "explicit_reload" } + : null; + } + if (value.reason !== "run_started" && value.reason !== "history_replaced") return null; + const runId = requiredRunId(); + return runId && hasOnlyKeys(value, [...orderedKeys, "runId", "reason"]) + ? { ...common, eventType: "timelineCleared", runId, reason: value.reason } + : null; + } + case "historyReplaced": { + const runId = requiredRunId(); + return runId && hasOnlyKeys(value, [...orderedKeys, "runId"]) + ? { ...common, eventType: "historyReplaced", runId } + : null; + } + case "cursorAdvanced": + return hasOnlyKeys(value, orderedKeys) ? { ...common, eventType: "cursorAdvanced" } : null; + case "sessionUpdated": { + const runId = optionalRunId(); + const session = decodeAgentPresentedSessionSummary(value.session); + return runId !== null && + session?.id === value.sessionId && + hasOnlyKeys(value, [...orderedKeys, "runId", "session"]) + ? { + ...common, + eventType: "sessionUpdated", + ...(runId === undefined ? {} : { runId }), + session + } + : null; + } + case "runFinished": { + const runId = requiredRunId(); + return runId && + (value.terminal === "completed" || + value.terminal === "cancelled" || + value.terminal === "failed") && + hasOnlyKeys(value, [...orderedKeys, "runId", "terminal"]) + ? { ...common, eventType: "runFinished", runId, terminal: value.terminal } + : null; + } + case "sessionDeleted": + return hasOnlyKeys(value, orderedKeys) ? { ...common, eventType: "sessionDeleted" } : null; + case "userFacingError": { + const runId = requiredRunId(); + const item = decodeAgentPresentedTimelineItem(value.item); + return runId && + item && + isAgentPresentedUserFacingErrorItem(item) && + hasOnlyKeys(value, [...orderedKeys, "runId", "item"]) + ? { ...common, eventType: "userFacingError", runId, item } + : null; + } + default: + return null; + } +} + +function decodeLegacyLocalAgentEvent(value: unknown): AgentEventPayload | null { + if (!isRecord(value) || "targetId" in value || "connectionGeneration" in value) return null; + const payload = decodeAgentEventPayload(value); + const ordering = decodeAgentEventOrdering(value); + return payload && ordering ? ({ ...payload, ...ordering } as AgentEventPayload) : null; +} + +function decodeAgentEventOrdering( + value: Record +): Pick | null { + const hasEpoch = value.eventEpoch !== undefined; + const hasSequence = value.eventSequence !== undefined; + if (!hasEpoch && !hasSequence) return {}; + if ( + !hasEpoch || + !hasSequence || + !isBoundedAgentCursor(value.eventEpoch) || + !isConnectionGeneration(value.eventSequence) + ) { + return null; + } + return { + eventEpoch: value.eventEpoch, + eventSequence: value.eventSequence + }; +} + +function decodeAgentEventPayload(value: Record): AgentEventPayload | null { + switch (value.eventType) { + case "runtimeStatus": + return isAgentRuntimeStatus(value.status) + ? { eventType: "runtimeStatus", status: value.status } + : null; + case "sessionCreated": + return isString(value.sessionId) && isAgentSessionSummary(value.session) + ? { eventType: "sessionCreated", sessionId: value.sessionId, session: value.session } + : null; + case "sessionUpdated": + return isString(value.sessionId) && + isNullableString(value.runId) && + isAgentSessionSummary(value.session) + ? { + eventType: "sessionUpdated", + sessionId: value.sessionId, + ...(value.runId !== undefined ? { runId: value.runId } : {}), + session: value.session + } + : null; + case "timelineItem": + return isString(value.sessionId) && + isNullableString(value.runId) && + isAgentTimelineItem(value.item) + ? { + eventType: "timelineItem", + sessionId: value.sessionId, + ...(value.runId !== undefined ? { runId: value.runId } : {}), + item: value.item + } + : null; + case "runStarted": + return isString(value.sessionId) && isString(value.runId) + ? { eventType: "runStarted", sessionId: value.sessionId, runId: value.runId } + : null; + case "error": + if (!isString(value.runId)) return null; + if (isString(value.message) && value.sessionId === undefined && value.item === undefined) { + return { eventType: "error", runId: value.runId, message: value.message }; + } + return isString(value.sessionId) && + isAgentTimelineItem(value.item) && + isNullableString(value.message) + ? { + eventType: "error", + sessionId: value.sessionId, + runId: value.runId, + item: value.item, + ...(value.message !== undefined ? { message: value.message } : {}) + } + : null; + case "historyReplaced": + return isString(value.sessionId) && isString(value.runId) + ? { eventType: "historyReplaced", sessionId: value.sessionId, runId: value.runId } + : null; + case "runFinished": + return isString(value.sessionId) && + isString(value.runId) && + (value.message === "completed" || + value.message === "cancelled" || + value.message === "failed") + ? { + eventType: "runFinished", + sessionId: value.sessionId, + runId: value.runId, + message: value.message + } + : null; + default: + return null; + } +} + +function decodeAgentOperationResult( + operation: Operation, + value: unknown, + requireClosedRemotePresentation: boolean +): AgentRuntimeOperationResultMap[Operation] { + if (requireClosedRemotePresentation) { + if (operation === "createSession") { + const detail = decodeAgentPresentedCreatedSession(value); + if (!detail) { + throw new Error(`Agent runtime bridge returned an invalid result for "${operation}"`); + } + return detail as AgentRuntimeOperationResultMap[Operation]; + } + if (operation === "listSessionsPage") { + const page = decodeAgentPresentedSessionPage(value); + if (!page) { + throw new Error(`Agent runtime bridge returned an invalid result for "${operation}"`); + } + return page as AgentRuntimeOperationResultMap[Operation]; + } + } + const valid = isRemoteOperationResult(operation, value, requireClosedRemotePresentation); + if (!valid) throw new Error(`Agent runtime bridge returned an invalid result for "${operation}"`); + return value as AgentRuntimeOperationResultMap[Operation]; +} + +function decodeAgentPresentedCreatedSession(value: unknown): AgentSessionDetail | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["session", "timeline", "mcpErrors"]) || + !Array.isArray(value.timeline) || + value.timeline.length !== 0 || + !Array.isArray(value.mcpErrors) || + value.mcpErrors.length !== 0 + ) { + return null; + } + const session = decodeAgentPresentedSessionSummary(value.session); + return session ? { session, timeline: [], mcpErrors: [] } : null; +} + +function decodeAgentPresentedSessionPage(value: unknown): AgentPage | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["items", "nextCursor"]) || + !Array.isArray(value.items) || + value.items.length > MAX_AGENT_PAGE_SIZE || + !isNullableString(value.nextCursor) || + (value.nextCursor !== undefined && + value.nextCursor !== null && + (value.nextCursor.length === 0 || + value.nextCursor.length > MAX_AGENT_CURSOR_BYTES || + !isAscii(value.nextCursor))) + ) { + return null; + } + const items: AgentSessionSummary[] = []; + for (const item of value.items) { + const summary = decodeAgentPresentedSessionSummary(item); + if (!summary) return null; + items.push(summary); + } + return { + items, + ...(value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor }) + }; +} + +function isRemoteOperationResult( + operation: AgentRuntimeOperation, + value: unknown, + requireClosedRemotePresentation: boolean +): boolean { + switch (operation) { + case "getRuntimeStatus": + case "startRuntime": + return isAgentRuntimeStatus(value); + case "restartRuntime": + case "stopRuntime": + return ( + isRecord(value) && + isAgentRuntimeStatus(value.status) && + (value.acpShutdownError === null || isString(value.acpShutdownError)) + ); + case "loadConfig": + case "removeProjectRoot": + return isAgentConfig(value); + case "saveConfig": + case "clearUserData": + case "clearUserHistory": + case "deleteSession": + case "cancelRun": + case "setPermissionMode": + case "respondToPermission": + return value === undefined || value === null; + case "listMcpServers": + case "saveMcpServers": + return Array.isArray(value) && value.every(isAgentMcpServer); + case "listSessionMcpServers": + case "setSessionMcpServerEnabled": + return Array.isArray(value) && value.every(isAgentSessionMcpServer); + case "listRecentProjectRoots": + case "saveProjectRootOrder": + return Array.isArray(value) && value.every(isRecentProjectRoot); + case "saveRecentProjectRoot": + return ( + isRecord(value) && + isString(value.projectRoot) && + Array.isArray(value.roots) && + value.roots.every(isRecentProjectRoot) && + isAgentConfig(value.config) + ); + case "getProjectSkillsTrust": + case "setProjectSkillsTrust": + return isAgentProjectSkillsTrustStatus(value); + case "createSession": + return isAgentSessionDetail(value); + case "listSessions": + return Array.isArray(value) && value.every(isAgentSessionSummary); + case "loadSession": + return isAgentSessionDetail(value); + case "listSessionsPage": + return isAgentPage(value, isAgentSessionSummary); + case "listSessionRecordsPage": + return requireClosedRemotePresentation + ? isAgentPresentedSessionRecordsPage(value) + : isAgentSessionRecordsPage(value); + case "renameSession": + return isAgentSessionSummary(value); + case "sendMessage": + return isRecord(value) && isString(value.runId); + } +} + +function isAgentRuntimeStatus(value: unknown): value is AgentRuntimeStatus { + if (!isRecord(value) || typeof value.running !== "boolean") return false; + if ( + !isNullableString(value.projectRoot) || + !isNullableString(value.model) || + !isNullableString(value.mode) + ) { + return false; + } + return ( + value.activeRuns === undefined || + (isRecord(value.activeRuns) && Object.values(value.activeRuns).every(isString)) + ); +} + +function isAgentConfig(value: unknown): value is AgentConfig { + if (!isRecord(value) || !isString(value.defaultModel)) return false; + if (!isNullableString(value.defaultProjectRoot)) return false; + if ( + value.projectSkillsTrust !== undefined && + (!Array.isArray(value.projectSkillsTrust) || + !value.projectSkillsTrust.every( + (item) => isRecord(item) && isString(item.path) && typeof item.trusted === "boolean" + )) + ) { + return false; + } + return ( + value.removedProjectRoots === undefined || + (Array.isArray(value.removedProjectRoots) && value.removedProjectRoots.every(isString)) + ); +} + +function isAgentSessionSummary(value: unknown): value is AgentSessionSummary { + return ( + isRecord(value) && + isString(value.id) && + isString(value.title) && + isString(value.projectRoot) && + isFiniteNumber(value.createdMs) && + isFiniteNumber(value.updatedMs) && + isFiniteNumber(value.pageSortMs) && + isFiniteNumber(value.messageCount) && + isNullableString(value.model) && + isString(value.mode) + ); +} + +function isAgentTimelineItem(value: unknown): value is AgentTimelineItem { + return ( + isRecord(value) && + isBoundedAgentCursor(value.id) && + (value.itemType === "message" || + value.itemType === "thinking" || + value.itemType === "tool" || + value.itemType === "permission" || + value.itemType === "system" || + value.itemType === "error") && + isFiniteNumber(value.createdMs) && + isString(value.merge) && + isNullableString(value.role) && + isNullableString(value.title) && + isNullableString(value.text) && + isNullableString(value.status) + ); +} + +function isAgentSessionDetail(value: unknown): value is AgentSessionDetail { + return ( + isRecord(value) && + isAgentSessionSummary(value.session) && + Array.isArray(value.timeline) && + value.timeline.every(isAgentTimelineItem) && + Array.isArray(value.mcpErrors) && + value.mcpErrors.every( + (error) => isRecord(error) && isString(error.name) && isString(error.error) + ) + ); +} + +function isRecentProjectRoot(value: unknown): value is RecentProjectRoot { + return ( + isRecord(value) && + isString(value.path) && + isString(value.name) && + isFiniteNumber(value.lastUsedMs) + ); +} + +function isAgentProjectSkillsTrustStatus(value: unknown): value is AgentProjectSkillsTrustStatus { + return ( + isRecord(value) && + isString(value.path) && + (value.decision === undefined || + value.decision === null || + typeof value.decision === "boolean") && + typeof value.available === "boolean" + ); +} + +function isAgentMcpServer(value: unknown): value is AgentMcpServer { + return ( + isRecord(value) && + isString(value.name) && + isString(value.description) && + typeof value.enabled === "boolean" && + isFiniteNumber(value.timeoutSeconds) && + isRecord(value.transport) && + ((value.transport.type === "stdio" && + isString(value.transport.command) && + isKeyValueList(value.transport.environment)) || + (value.transport.type === "streamable_http" && + isString(value.transport.url) && + isKeyValueList(value.transport.environment) && + isKeyValueList(value.transport.headers))) + ); +} + +function isKeyValueList(value: unknown): value is AgentMcpKeyValue[] { + return ( + Array.isArray(value) && + value.every((item) => isRecord(item) && isString(item.key) && isString(item.value)) + ); +} + +function isAgentSessionMcpServer(value: unknown): value is AgentSessionMcpServer { + return ( + isRecord(value) && + isString(value.name) && + isString(value.description) && + (value.transport === "stdio" || value.transport === "streamable_http") && + typeof value.enabled === "boolean" && + typeof value.available === "boolean" + ); +} + +function isAgentPage( + value: unknown, + isItem: (item: unknown) => item is T +): value is AgentPage { + return ( + isRecord(value) && + Array.isArray(value.items) && + value.items.length <= MAX_AGENT_PAGE_SIZE && + value.items.every(isItem) && + isNullableString(value.nextCursor) && + (value.nextCursor === undefined || + value.nextCursor === null || + (value.nextCursor.length > 0 && + value.nextCursor.length <= MAX_AGENT_CURSOR_BYTES && + isAscii(value.nextCursor))) + ); +} + +function isBoundedAgentCursor(value: unknown): value is string { + return ( + isString(value) && value.length > 0 && value.length <= MAX_AGENT_CURSOR_BYTES && isAscii(value) + ); +} + +function isAgentHistoryRecord(value: unknown): value is AgentHistoryRecord { + return ( + isRecord(value) && + hasOnlyKeys(value, ["recordId", "role", "createdMs", "items"]) && + isBoundedAgentCursor(value.recordId) && + isString(value.role) && + value.role.length > 0 && + value.role.length <= MAX_AGENT_HISTORY_ROLE_BYTES && + // Record roles are opaque paging metadata, never presentation authority. + // Limit them to printable ASCII so controls and bidi markers cannot cross + // the native/remote boundary; rendered semantics come only from `items`. + isPrintableAscii(value.role) && + isFiniteNumber(value.createdMs) && + Array.isArray(value.items) && + value.items.length <= MAX_AGENT_HISTORY_ITEMS_PER_RECORD && + value.items.every(isAgentTimelineItem) + ); +} + +function isAgentSessionRecordsPage(value: unknown): value is AgentSessionRecordsPage { + return ( + isRecord(value) && + hasOnlyKeys(value, ["records", "nextCursor", "historyRevision"]) && + Array.isArray(value.records) && + value.records.length <= MAX_AGENT_PAGE_SIZE && + value.records.every(isAgentHistoryRecord) && + // Plain pages are intentionally unsynchronized. Absolute live state and + // event checkpoints belong only to the paused attach-coordinator result. + value.liveItems === undefined && + value.throughEventCursor === undefined && + isBoundedAgentCursor(value.historyRevision) && + isNullableString(value.nextCursor) && + (value.nextCursor === undefined || + value.nextCursor === null || + isBoundedAgentCursor(value.nextCursor)) + ); } async function invokeAgent(command: string, args?: Record): Promise { @@ -454,25 +3943,184 @@ async function invokeAgent(command: string, args?: Record): return await invoke(command, args); } +async function listenToLocalAgentEvents( + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + handler: AgentBridgeEventHandler +): Promise { + if (lease) throw new Error("The embedded Agent event bridge does not accept a remote lease"); + if (target.kind !== "local" || target.id !== LOCAL_AGENT_EXECUTION_TARGET_ID) { + throw new Error(`The local Agent bridge cannot subscribe to target "${target.id as string}"`); + } + if (!isTauriDesktop()) return () => {}; + const { listen } = await import("@tauri-apps/api/event"); + return await listen("agent-event", (event) => { + handler(event.payload); + }); +} + +function expectedLiveLease(lease: AgentExecutionLease): { + targetId: AgentExecutionTargetId; + hostEpoch: string; + connectionGeneration: number; +} { + return { + targetId: lease.targetId, + hostEpoch: lease.hostEpoch, + connectionGeneration: lease.connectionGeneration + }; +} + +function assertTauriRemoteLiveBridge( + lease: AgentExecutionLease | null, + target: AgentExecutionTarget +): asserts lease is AgentExecutionLease { + if (!lease || target.kind !== "remote" || target.id !== lease.targetId) { + throw new Error("Synchronized Agent history requires a verified remote host lease"); + } + if (!isTauriDesktop()) throw new Error("Agent Mode is available in Maple Desktop."); +} + +async function beginLocalSessionHistoryAttach( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + request: AgentListSessionRecordsPageRequest, + handler: AgentBridgeEventHandler +): Promise { + assertTauriRemoteLiveBridge(lease, target); + const { Channel, invoke } = await import("@tauri-apps/api/core"); + const events = new Channel(handler); + const result = await invoke("agent_begin_session_history_attach", { + userId, + request, + expectedLease: expectedLiveLease(lease), + events + }); + return { result, keepAlive: events }; +} + +async function activateLocalSessionHistoryAttach( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + attachId: string +): Promise { + assertTauriRemoteLiveBridge(lease, target); + return await invokeAgent("agent_activate_session_history_attach", { + userId, + attachId, + expectedLease: expectedLiveLease(lease) + }); +} + +async function cancelLocalSessionHistoryAttach( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + attachId: string +): Promise { + assertTauriRemoteLiveBridge(lease, target); + await invokeAgent("agent_cancel_session_history_attach", { + userId, + attachId, + expectedLease: expectedLiveLease(lease) + }); +} + +async function resumeLocalLiveEvents( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + cursor: AgentLiveEventCursor, + handler: AgentBridgeEventHandler +): Promise { + assertTauriRemoteLiveBridge(lease, target); + const { Channel, invoke } = await import("@tauri-apps/api/core"); + const events = new Channel(handler); + const result = await invoke("agent_resume_live_events", { + userId, + cursor, + expectedLease: expectedLiveLease(lease), + events + }); + return { result, keepAlive: events }; +} + +async function cancelLocalLiveEvents( + userId: string, + lease: AgentExecutionLease | null, + target: AgentExecutionTarget, + liveStreamId: string +): Promise { + assertTauriRemoteLiveBridge(lease, target); + await invokeAgent("agent_cancel_live_events", { + userId, + liveStreamId, + expectedLease: expectedLiveLease(lease) + }); +} + export const agentRuntimeService = new AgentRuntimeService(); -const agentAuthLifecycle = new AgentAuthLifecycleCoordinator( - async (userId) => { - if (!isTauriDesktop()) return; - const block = await stopAgentRuntimeForUser(userId); - try { - await mapleApiAuthService.clear(userId); - // Auth may already be gone, so remote revocation is not reliable here. - // Scrub the local credential immediately; the exact tracked backend-key - // record remains available for retry if this account signs in again. - const { proxyService } = await import("@/services/proxyService"); - await proxyService.stopAndResetProxy(); - } finally { - block.retainUntilNextSession(); +export async function retireAgentRuntimeAccountResources(userId: string): Promise { + await agentRuntimeAccountResourceRegistry.retireAccount(userId); +} + +export function activateAgentRuntimeAccountResources(userId: string): void { + agentRuntimeAccountResourceRegistry.activateAccount(userId); +} + +export interface AgentAuthAccountRetirementBridge { + blockAndDrain(userId: string): Promise; + retireRemoteAccount(userId: string): Promise; + isDesktop(): boolean; + stopLocalHost(userId: string): Promise; + clearLocalAuth(userId: string): Promise; + stopLocalProxy(): Promise; +} + +export async function retireAgentAuthAccount( + userId: string, + bridge: AgentAuthAccountRetirementBridge +): Promise { + const block = await bridge.blockAndDrain(userId); + try { + await bridge.retireRemoteAccount(userId); + if (bridge.isDesktop()) { + const outcome = await bridge.stopLocalHost(userId); + if (outcome.acpShutdownError) throw new AgentRuntimePartialStopError(outcome); + await bridge.clearLocalAuth(userId); + await bridge.stopLocalProxy(); } - }, + block.retainUntilNextSession(); + } catch (error) { + block.release(); + throw error; + } +} + +const defaultAgentAuthAccountRetirementBridge: AgentAuthAccountRetirementBridge = { + blockAndDrain: async (userId) => await agentOperationFence.blockAndDrain(userId), + retireRemoteAccount: retireAgentRuntimeAccountResources, + isDesktop: isTauriDesktop, + stopLocalHost: async (userId) => + await invokeAgent( + LOCAL_COMMAND_BY_AGENT_RUNTIME_OPERATION.stopRuntime, + { userId } + ), + clearLocalAuth: async (userId) => await mapleApiAuthService.clear(userId), + stopLocalProxy: async () => { + const { proxyService } = await import("@/services/proxyService"); + await proxyService.stopAndResetProxy(); + } +}; + +const agentAuthLifecycle = new AgentAuthLifecycleCoordinator( + async (userId) => await retireAgentAuthAccount(userId, defaultAgentAuthAccountRetirementBridge), async (userId) => { await mapleApiAuthService.activate(userId); + activateAgentRuntimeAccountResources(userId); agentOperationFence.activateUserSession(userId); } ); @@ -498,19 +4146,35 @@ export async function restoreMapleApiAuthForUser(userId?: string | null): Promis } export async function stopAgentRuntimeForUser( - userId?: string | null + userId?: string | null, + runtimeService: AgentRuntimeService = agentRuntimeService ): Promise { - if (!isTauriDesktop()) return noOpOperationBlock(); + if (runtimeService === agentRuntimeService) { + if (!isTauriDesktop()) return noOpOperationBlock(); + if (!userId) throw new Error("Cannot stop Agent Mode without an authenticated user"); + return await agentRuntimeStopCoordinator.stop(userId); + } if (!userId) throw new Error("Cannot stop Agent Mode without an authenticated user"); - return await agentRuntimeStopCoordinator.stop(userId); + const outcome = await runtimeService.stopRuntime(userId); + if (outcome.acpShutdownError) throw new AgentRuntimePartialStopError(outcome); + return noOpOperationBlock(); } -export async function clearAgentDataForUser(userId?: string | null): Promise { - if (!isTauriDesktop()) return noOpOperationBlock(); +export async function clearAgentDataForUser( + userId?: string | null, + runtimeService: AgentRuntimeService = agentRuntimeService +): Promise { + if (runtimeService === agentRuntimeService && !isTauriDesktop()) return noOpOperationBlock(); if (!userId) throw new Error("Cannot clear Agent Mode data without an authenticated user"); - const block = await agentRuntimeStopCoordinator.stop(userId); + const block = await stopAgentRuntimeForUser(userId, runtimeService); try { - await invokeAgent("agent_clear_user_data", { userId }); + if (runtimeService === agentRuntimeService && isTauriDesktop()) { + // The local cleanup fence is intentionally held, so bypass its ordinary + // run gate while still deriving the command from the semantic operation. + await invokeAgent(LOCAL_COMMAND_BY_AGENT_RUNTIME_OPERATION.clearUserData, { userId }); + } else { + await runtimeService.clearUserData(userId); + } return block; } catch (error) { block.release(); @@ -519,13 +4183,18 @@ export async function clearAgentDataForUser(userId?: string | null): Promise { - if (!isTauriDesktop()) return noOpOperationBlock(); + if (runtimeService === agentRuntimeService && !isTauriDesktop()) return noOpOperationBlock(); if (!userId) throw new Error("Cannot clear Agent Mode history without an authenticated user"); - const block = await agentRuntimeStopCoordinator.stop(userId); + const block = await stopAgentRuntimeForUser(userId, runtimeService); try { - await invokeAgent("agent_clear_user_history", { userId }); + if (runtimeService === agentRuntimeService && isTauriDesktop()) { + await invokeAgent(LOCAL_COMMAND_BY_AGENT_RUNTIME_OPERATION.clearUserHistory, { userId }); + } else { + await runtimeService.clearUserHistory(userId); + } return block; } catch (error) { block.release(); diff --git a/frontend/src/services/agentSessionPagination.test.ts b/frontend/src/services/agentSessionPagination.test.ts new file mode 100644 index 000000000..1da568de1 --- /dev/null +++ b/frontend/src/services/agentSessionPagination.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test"; + +import { AgentSessionPaginationCache } from "./agentSessionPagination"; +import type { AgentSessionSummary } from "./agentRuntimeService"; + +function session( + id: string, + title = id, + updatedMs = Number(id.replace(/\D/g, "")) || 1, + pageSortMs = updatedMs +): AgentSessionSummary { + return { + id, + title, + projectRoot: "/project", + createdMs: 1, + updatedMs, + pageSortMs, + messageCount: 1, + model: "model", + mode: "smart_approve" + }; +} + +describe("AgentSessionPaginationCache", () => { + test("refreshes the head without discarding loaded older task pages", () => { + const cache = new AgentSessionPaginationCache(); + cache.commit(cache.beginHead(), { items: [session("s4"), session("s3")], nextCursor: "s3" }); + cache.commit(cache.beginOlder()!, { items: [session("s2"), session("s1")], nextCursor: null }); + cache.commit(cache.beginHead(), { items: [session("s5"), session("s4")], nextCursor: "s4" }); + + expect(cache.snapshot().items.map((item) => item.id)).toEqual(["s5", "s4", "s3", "s2", "s1"]); + expect(cache.snapshot().hasMore).toBe(false); + }); + + test("treats a complete head refresh as authoritative for unchanged tasks", () => { + const cache = new AgentSessionPaginationCache(); + cache.commit(cache.beginHead(), { + items: [session("s2"), session("s1")], + nextCursor: null + }); + + cache.commit(cache.beginHead(), { items: [session("s2")], nextCursor: null }); + + expect(cache.snapshot().items.map((item) => item.id)).toEqual(["s2"]); + expect(cache.snapshot().hasMore).toBe(false); + }); + + test("keeps an absent task mutated while an authoritative head refresh was in flight", () => { + const cache = new AgentSessionPaginationCache(); + cache.commit(cache.beginHead(), { + items: [session("s2", "Two", 20), session("s1", "One", 10)], + nextCursor: null + }); + const token = cache.beginHead(); + cache.upsert(session("s1", "Live title", 30)); + + cache.commit(token, { items: [session("s2", "Two", 20)], nextCursor: null }); + + expect(cache.snapshot().items.map((item) => item.id)).toEqual(["s1", "s2"]); + expect(cache.snapshot().items[0].title).toBe("Live title"); + }); + + test("drops previously loaded older tasks when a complete head supersedes them", () => { + const cache = new AgentSessionPaginationCache(); + cache.commit(cache.beginHead(), { + items: [session("s4"), session("s3")], + nextCursor: "after-s3" + }); + cache.commit(cache.beginOlder()!, { + items: [session("s2"), session("s1")], + nextCursor: null + }); + + cache.commit(cache.beginHead(), { items: [session("s5")], nextCursor: null }); + + expect(cache.snapshot().items.map((item) => item.id)).toEqual(["s5"]); + expect(cache.snapshot().nextCursor).toBeNull(); + }); + + test("restarts older paging from the refreshed head when no loaded task overlaps", () => { + const cache = new AgentSessionPaginationCache(); + cache.commit(cache.beginHead(), { + items: [session("s4"), session("s3")], + nextCursor: "after-s3" + }); + cache.commit(cache.beginOlder()!, { + items: [session("s2"), session("s1")], + nextCursor: null + }); + + cache.commit(cache.beginHead(), { + items: [session("s8"), session("s7")], + nextCursor: "after-s7" + }); + + expect(cache.snapshot().items.map((item) => item.id)).toEqual([ + "s8", + "s7", + "s4", + "s3", + "s2", + "s1" + ]); + expect(cache.snapshot().nextCursor).toBe("after-s7"); + expect(cache.beginOlder()?.cursor).toBe("after-s7"); + }); + + test("does not mistake an event-only task for overlap with the loaded page range", () => { + const cache = new AgentSessionPaginationCache(); + cache.commit(cache.beginHead(), { + items: [session("s4"), session("s3")], + nextCursor: "after-s3" + }); + cache.commit(cache.beginOlder()!, { + items: [session("s2"), session("s1")], + nextCursor: null + }); + cache.upsert(session("s8", "Eight", 80)); + + cache.commit(cache.beginHead(), { + items: [session("s9", "Nine", 90), session("s8", "Eight", 80)], + nextCursor: "after-s8" + }); + + expect(cache.snapshot().nextCursor).toBe("after-s8"); + expect(cache.beginOlder()?.cursor).toBe("after-s8"); + }); + + test("keeps a reactive summary that raced a head page", () => { + const cache = new AgentSessionPaginationCache(); + const token = cache.beginHead(); + cache.upsert(session("s1", "Live title")); + + cache.commit(token, { items: [session("s1", "Stale title")], nextCursor: null }); + + expect(cache.snapshot().items[0].title).toBe("Live title"); + }); + + test("rejects a superseded head request", () => { + const cache = new AgentSessionPaginationCache(); + const first = cache.beginHead(); + const second = cache.beginHead(); + + expect(cache.commit(first, { items: [session("stale")], nextCursor: null })).toBe("stale"); + expect(cache.commit(second, { items: [session("fresh")], nextCursor: null })).toBe("applied"); + expect(cache.snapshot().items.map((item) => item.id)).toEqual(["fresh"]); + }); + + test("does not revive a task deleted while a page was in flight", () => { + const cache = new AgentSessionPaginationCache(); + cache.upsert(session("s1")); + const token = cache.beginHead(); + cache.remove("s1"); + + cache.commit(token, { items: [session("s1")], nextCursor: null }); + + expect(cache.snapshot().items).toEqual([]); + }); + + test("moves an event-updated task to newest-first position", () => { + const cache = new AgentSessionPaginationCache(); + cache.commit(cache.beginHead(), { + items: [session("s2", "Two", 20), session("s1", "One", 10)], + nextCursor: null + }); + + cache.upsert(session("s1", "One updated", 11, 30)); + + expect(cache.snapshot().items.map((item) => item.id)).toEqual(["s1", "s2"]); + }); + + test("uses descending task id as a deterministic equal-page-key tie break", () => { + const cache = new AgentSessionPaginationCache(); + + cache.commit(cache.beginHead(), { + items: [ + session("task-a", "A", 300, 20), + session("task-c", "C", 100, 20), + session("task-b", "B", 200, 20) + ], + nextCursor: null + }); + + expect(cache.snapshot().items.map((item) => item.id)).toEqual(["task-c", "task-b", "task-a"]); + }); + + test("keeps an updated older task stable when it moves between page requests", () => { + const cache = new AgentSessionPaginationCache(); + cache.commit(cache.beginHead(), { + items: [session("s4", "Four", 40, 40), session("s3", "Three", 30, 30)], + nextCursor: "after-s3" + }); + cache.upsert(session("s1", "One moved", 11, 50)); + + cache.commit(cache.beginOlder()!, { + items: [session("s2", "Two", 20, 20), session("s1", "One stale", 10, 10)], + nextCursor: null + }); + + expect(cache.snapshot().items.map((item) => item.id)).toEqual(["s1", "s4", "s3", "s2"]); + expect(cache.snapshot().items[0]).toMatchObject({ + title: "One moved", + updatedMs: 11, + pageSortMs: 50 + }); + }); +}); diff --git a/frontend/src/services/agentSessionPagination.ts b/frontend/src/services/agentSessionPagination.ts new file mode 100644 index 000000000..6cb8af532 --- /dev/null +++ b/frontend/src/services/agentSessionPagination.ts @@ -0,0 +1,169 @@ +import type { AgentPage, AgentSessionSummary } from "./agentRuntimeService"; + +export interface AgentSessionPageToken { + readonly kind: "head" | "older"; + readonly cursor: string | null; + readonly requestId: number; + readonly mutationRevision: number; +} + +export interface AgentSessionPageSnapshot { + readonly items: readonly AgentSessionSummary[]; + readonly nextCursor: string | null; + readonly headLoaded: boolean; + readonly isLoading: boolean; + readonly hasMore: boolean; +} + +export type AgentSessionPageCommitResult = "applied" | "stale"; + +function newestSessionFirst(left: AgentSessionSummary, right: AgentSessionSummary): number { + const pageOrder = right.pageSortMs - left.pageSortMs; + if (pageOrder !== 0) return pageOrder; + if (left.id === right.id) return 0; + return left.id < right.id ? 1 : -1; +} + +/** Account-scoped count pager for the Agent task sidebar. */ +export class AgentSessionPaginationCache { + private items: AgentSessionSummary[] = []; + private nextCursor: string | null = null; + private headLoaded = false; + private loadedOlder = false; + private nextRequestId = 0; + private activeRequestId: number | null = null; + private mutationRevision = 0; + private readonly revisionsById = new Map(); + private readonly deletedIds = new Set(); + private readonly pagedIds = new Set(); + + beginHead(): AgentSessionPageToken { + return this.begin("head", null); + } + + beginOlder(): AgentSessionPageToken | null { + if (!this.headLoaded || !this.nextCursor || this.activeRequestId !== null) return null; + return this.begin("older", this.nextCursor); + } + + commit( + token: AgentSessionPageToken, + page: AgentPage + ): AgentSessionPageCommitResult { + if (this.activeRequestId !== token.requestId) return "stale"; + this.activeRequestId = null; + + const currentById = new Map(this.items.map((item) => [item.id, item])); + const resolveRacingMutation = (item: AgentSessionSummary) => + (this.revisionsById.get(item.id) ?? 0) > token.mutationRevision + ? (currentById.get(item.id) ?? item) + : item; + const incoming = page.items + .filter((item) => !this.deletedIds.has(item.id)) + .map(resolveRacingMutation); + const incomingIds = new Set(incoming.map((item) => item.id)); + + if (token.kind === "older") { + const currentIds = new Set(this.items.map((item) => item.id)); + this.items = [...this.items, ...incoming.filter((item) => !currentIds.has(item.id))].sort( + newestSessionFirst + ); + incoming.forEach((item) => this.pagedIds.add(item.id)); + this.nextCursor = page.nextCursor ?? null; + this.loadedOlder = true; + return "applied"; + } + + const changedAfterRequest = this.items.filter( + (item) => + (this.revisionsById.get(item.id) ?? 0) > token.mutationRevision && + !incomingIds.has(item.id) && + !this.deletedIds.has(item.id) + ); + const changedIds = new Set(changedAfterRequest.map((item) => item.id)); + const isCompleteSnapshot = page.nextCursor == null; + const retained = isCompleteSnapshot + ? [] + : this.items.filter( + (item) => + !incomingIds.has(item.id) && !changedIds.has(item.id) && !this.deletedIds.has(item.id) + ); + const overlapsLoadedRange = incoming.some((item) => this.pagedIds.has(item.id)); + this.items = [...changedAfterRequest, ...incoming, ...retained].sort(newestSessionFirst); + if (isCompleteSnapshot) { + this.pagedIds.clear(); + this.items.forEach((item) => this.pagedIds.add(item.id)); + } else { + incoming.forEach((item) => this.pagedIds.add(item.id)); + } + if (isCompleteSnapshot || !this.headLoaded || !this.loadedOlder || !overlapsLoadedRange) { + this.nextCursor = page.nextCursor ?? null; + } + this.headLoaded = true; + return "applied"; + } + + fail(token: AgentSessionPageToken): void { + if (this.activeRequestId === token.requestId) this.activeRequestId = null; + } + + upsert(summary: AgentSessionSummary): void { + this.mutationRevision += 1; + this.revisionsById.set(summary.id, this.mutationRevision); + this.deletedIds.delete(summary.id); + const index = this.items.findIndex((item) => item.id === summary.id); + if (index < 0) { + this.items = [summary, ...this.items].sort(newestSessionFirst); + return; + } + const next = [...this.items]; + next[index] = summary; + this.items = next.sort(newestSessionFirst); + } + + remove(sessionId: string): void { + this.mutationRevision += 1; + this.revisionsById.set(sessionId, this.mutationRevision); + this.deletedIds.add(sessionId); + this.pagedIds.delete(sessionId); + this.items = this.items.filter((item) => item.id !== sessionId); + } + + snapshot(): AgentSessionPageSnapshot { + return { + items: this.items, + nextCursor: this.nextCursor, + headLoaded: this.headLoaded, + isLoading: this.activeRequestId !== null, + hasMore: Boolean(this.nextCursor) + }; + } + + summaryRevision(sessionId: string): number { + return this.revisionsById.get(sessionId) ?? 0; + } + + clear(): void { + this.items = []; + this.nextCursor = null; + this.headLoaded = false; + this.loadedOlder = false; + this.nextRequestId += 1; + this.activeRequestId = null; + this.mutationRevision = 0; + this.revisionsById.clear(); + this.deletedIds.clear(); + this.pagedIds.clear(); + } + + private begin(kind: "head" | "older", cursor: string | null): AgentSessionPageToken { + this.nextRequestId += 1; + this.activeRequestId = this.nextRequestId; + return Object.freeze({ + kind, + cursor, + requestId: this.nextRequestId, + mutationRevision: this.mutationRevision + }); + } +} diff --git a/frontend/src/services/agentSessionSelection.test.ts b/frontend/src/services/agentSessionSelection.test.ts index 779ced111..7c44171d0 100644 --- a/frontend/src/services/agentSessionSelection.test.ts +++ b/frontend/src/services/agentSessionSelection.test.ts @@ -17,6 +17,16 @@ describe("AgentSessionSelectionMemory", () => { expect(memory.resolve("user-a", [{ id: "deleted-session" }])).toBeNull(); }); + test("does not clear a remembered session while older task pages remain", () => { + const memory = new AgentSessionSelectionMemory(); + memory.remember("user-a", "session-older"); + + expect( + memory.resolve("user-a", [{ id: "session-head" }], { historyComplete: false }) + ).toBeNull(); + expect(memory.resolve("user-a", [{ id: "session-older" }])).toBe("session-older"); + }); + test("forgets conditionally when an expected session is supplied", () => { const memory = new AgentSessionSelectionMemory(); memory.remember("user-a", "session-a"); diff --git a/frontend/src/services/agentSessionSelection.ts b/frontend/src/services/agentSessionSelection.ts index 32cd683f5..948655795 100644 --- a/frontend/src/services/agentSessionSelection.ts +++ b/frontend/src/services/agentSessionSelection.ts @@ -1,30 +1,36 @@ export class AgentSessionSelectionMemory { - private readonly sessionIdsByUser = new Map(); + private readonly sessionIdsByOwner = new Map(); - remember(userId: string, sessionId: string): void { - this.sessionIdsByUser.set(userId, sessionId); + remember(ownerKey: string, sessionId: string): void { + this.sessionIdsByOwner.set(ownerKey, sessionId); } - forget(userId: string, expectedSessionId?: string): void { + forget(ownerKey: string, expectedSessionId?: string): void { if ( expectedSessionId !== undefined && - this.sessionIdsByUser.get(userId) !== expectedSessionId + this.sessionIdsByOwner.get(ownerKey) !== expectedSessionId ) { return; } - this.sessionIdsByUser.delete(userId); + this.sessionIdsByOwner.delete(ownerKey); } - resolve(userId: string, sessions: readonly { id: string }[]): string | null { - const rememberedSessionId = this.sessionIdsByUser.get(userId); + resolve( + ownerKey: string, + sessions: readonly { id: string }[], + { historyComplete = true }: { historyComplete?: boolean } = {} + ): string | null { + const rememberedSessionId = this.sessionIdsByOwner.get(ownerKey); if (rememberedSessionId === undefined) return null; if (sessions.some((session) => session.id === rememberedSessionId)) { return rememberedSessionId; } - this.sessionIdsByUser.delete(userId); + // A paged sidebar cannot distinguish a deleted task from one beyond the + // loaded head until its cursor is exhausted. Preserve the memory meanwhile. + if (historyComplete) this.sessionIdsByOwner.delete(ownerKey); return null; } } diff --git a/frontend/src/services/agentSessionSummaries.test.ts b/frontend/src/services/agentSessionSummaries.test.ts index cd437c60d..493a6b3f7 100644 --- a/frontend/src/services/agentSessionSummaries.test.ts +++ b/frontend/src/services/agentSessionSummaries.test.ts @@ -9,6 +9,7 @@ function session(id: string, title: string, updatedMs = 1): AgentSessionSummary projectRoot: "/tmp/project", createdMs: 0, updatedMs, + pageSortMs: updatedMs, messageCount: 1, model: "glm-5-2", mode: "smart_approve" diff --git a/frontend/src/services/agentTimeline.test.ts b/frontend/src/services/agentTimeline.test.ts index c04056526..2b6b4da5b 100644 --- a/frontend/src/services/agentTimeline.test.ts +++ b/frontend/src/services/agentTimeline.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { AgentTimelineItem } from "./agentRuntimeService"; import { + AgentAssistantTurnKeyRegistry, AgentLiveThoughtPhaseTracker, activeAgentThinkingItemId, + agentTimelineHistoryAnchorIds, agentThinkingPhaseId, agentThoughtPhasesForLatestTurn, + agentUserTurnReactKey, coalesceAdjacentThinkingItems, getAgentTurnCopyText, groupAgentTimelineItems, @@ -84,6 +87,7 @@ describe("coalesceAdjacentThinkingItems", () => { expect(projected).toHaveLength(1); expect(projected[0].text).toBe("Inspecting."); + expect(agentTimelineHistoryAnchorIds(projected[0])).toEqual(["reasoning", "punctuation"]); }); test("keeps reasoning phases separated by a tool row distinct", () => { @@ -101,6 +105,16 @@ describe("coalesceAdjacentThinkingItems", () => { expect(projected.map((item) => item.text)).toEqual(["Before", undefined, "After"]); }); + + test("retains long thought source history without rebuilding a growing source array per chunk", () => { + const sourceItems = Array.from({ length: 4_096 }, (_, index) => + thinking(`thought-${index}`, "x") + ); + const [projected] = coalesceAdjacentThinkingItems(sourceItems); + + expect(projected.text?.length).toBe(sourceItems.length); + expect(agentTimelineHistoryAnchorIds(projected)).toEqual(sourceItems.map((item) => item.id)); + }); }); describe("AgentLiveThoughtPhaseTracker", () => { @@ -441,6 +455,17 @@ describe("activeAgentThinkingItemId", () => { }); describe("groupAgentTimelineItems", () => { + test("namespaces raw user IDs away from generated assistant React keys", () => { + const registry = new AgentAssistantTurnKeyRegistry(); + const assistantTurn = groupAgentTimelineItems([ + item("assistant-source", "message", "assistant", "answer") + ])[0]; + const assistantKey = registry.resolve("session", [assistantTurn]).get(assistantTurn)!; + + expect(agentUserTurnReactKey(assistantKey)).not.toBe(assistantKey); + expect(agentUserTurnReactKey(assistantKey)).toBe(`agent-user-item:${assistantKey}`); + }); + test("groups a complete agent response under one assistant turn", () => { const turns = groupAgentTimelineItems([ item("user", "message", "user"), @@ -496,6 +521,77 @@ describe("groupAgentTimelineItems", () => { expect(before[1].id).toBe("assistant-after-user"); expect(after[1].id).toBe(before[1].id); }); + + test("keeps assistant render identity when an older page supplies its preceding user", () => { + const registry = new AgentAssistantTurnKeyRegistry(); + const initialTurns = groupAgentTimelineItems([ + item("thought-newer", "thinking", "thought"), + item("tool", "tool"), + item("answer", "message", "assistant") + ]); + const initialKeys = registry.resolve("session", initialTurns); + const initialAssistant = initialTurns[0]; + expect(initialAssistant.type).toBe("assistant"); + const initialKey = initialKeys.get(initialAssistant); + + const prependedTurns = groupAgentTimelineItems([ + item("user", "message", "user"), + item("thought-older", "thinking", "thought"), + item("thought-newer", "thinking", "thought"), + item("tool", "tool"), + item("answer", "message", "assistant") + ]); + const prependedKeys = registry.resolve("session", prependedTurns); + const prependedAssistant = prependedTurns[1]; + + expect(initialAssistant).toMatchObject({ id: "assistant-leading" }); + expect(prependedAssistant).toMatchObject({ id: "assistant-after-user" }); + expect(prependedKeys.get(prependedAssistant)).toBe(initialKey); + }); + + test("keeps a group key across live appends and resets it across sessions", () => { + const registry = new AgentAssistantTurnKeyRegistry(); + const initialTurns = groupAgentTimelineItems([ + item("user", "message", "user"), + item("tool", "tool") + ]); + const initialKey = registry.resolve("session-a", initialTurns).get(initialTurns[1]); + const appendedTurns = groupAgentTimelineItems([ + item("user", "message", "user"), + item("tool", "tool"), + item("answer", "message", "assistant") + ]); + const appendedKey = registry.resolve("session-a", appendedTurns).get(appendedTurns[1]); + const replacementKey = registry.resolve("session-b", appendedTurns).get(appendedTurns[1]); + + expect(appendedKey).toBe(initialKey); + expect(replacementKey).not.toBe(initialKey); + }); + + test("never reuses one prior assistant key for two groups after history replacement splits it", () => { + const registry = new AgentAssistantTurnKeyRegistry(); + const initialItems = coalesceAdjacentThinkingItems([ + thinking("thought-a", "A"), + thinking("thought-b", "B") + ]); + const initialTurns = groupAgentTimelineItems(initialItems); + const initialKey = registry.resolve("session", initialTurns).get(initialTurns[0]); + + const splitTurns = groupAgentTimelineItems([ + thinking("thought-a", "A"), + item("replacement-user", "message", "user", "new boundary"), + thinking("thought-b", "B") + ]); + const splitKeys = registry.resolve("session", splitTurns); + const assistantKeys = splitTurns + .filter((turn) => turn.type === "assistant") + .map((turn) => splitKeys.get(turn)); + + expect(assistantKeys).toHaveLength(2); + expect(assistantKeys[0]).not.toBe(initialKey); + expect(assistantKeys[1]).toBe(initialKey); + expect(new Set(assistantKeys).size).toBe(2); + }); }); describe("shouldShowAgentAssistantLoader", () => { diff --git a/frontend/src/services/agentTimeline.ts b/frontend/src/services/agentTimeline.ts index 327464009..5e9df1123 100644 --- a/frontend/src/services/agentTimeline.ts +++ b/frontend/src/services/agentTimeline.ts @@ -1,9 +1,100 @@ import type { AgentTimelineItem } from "./agentRuntimeService"; +type AgentTimelineSourceIds = + | { readonly kind: "item"; readonly id: string } + | { + readonly kind: "concat"; + readonly left: AgentTimelineSourceIds; + readonly right: AgentTimelineSourceIds; + }; + +const agentTimelineSourceIds = new WeakMap(); + +function agentTimelineSourceIdTree(item: AgentTimelineItem): AgentTimelineSourceIds { + return agentTimelineSourceIds.get(item) ?? { kind: "item", id: item.id }; +} + +export function agentTimelineHistoryAnchorIds(item: AgentTimelineItem): string[] { + const ids: string[] = []; + const pending = [agentTimelineSourceIdTree(item)]; + while (pending.length > 0) { + const source = pending.pop()!; + if (source.kind === "item") { + ids.push(source.id); + } else { + // Push right first so the iterative traversal preserves source order. + pending.push(source.right, source.left); + } + } + return ids; +} + export type AgentTimelineTurn = | { type: "user"; item: AgentTimelineItem; id: string } | { type: "assistant"; items: AgentTimelineItem[]; id: string }; +/** + * React identity for assistant groups cannot come from the preceding user row: + * that row may arrive only when an older record page is prepended. Reuse an + * existing key whenever any stable source item remains in the group, and drop + * mappings for items no longer present so replacement history stays bounded. + */ +export class AgentAssistantTurnKeyRegistry { + private sessionId: string | null | undefined; + private sessionGeneration = 0; + private nextKey = 0; + private keyBySourceItemId = new Map(); + + resolve( + sessionId: string | null, + turns: readonly AgentTimelineTurn[] + ): ReadonlyMap { + if (this.sessionId !== sessionId) { + this.sessionId = sessionId; + this.sessionGeneration += 1; + this.nextKey = 0; + this.keyBySourceItemId.clear(); + } + + const previousKeys = this.keyBySourceItemId; + const nextKeys = new Map(); + const keyByTurn = new Map(); + const claimedKeys = new Set(); + // Prefer the newest/current assistant fragment when a replacement splits + // one former group. Older fragments receive a fresh key instead of + // remounting the active tool/thought subtree. + for (let turnIndex = turns.length - 1; turnIndex >= 0; turnIndex -= 1) { + const turn = turns[turnIndex]; + if (turn.type !== "assistant") continue; + const sourceItemIds = [ + ...new Set(turn.items.flatMap((item) => agentTimelineHistoryAnchorIds(item))) + ]; + let key: string | undefined; + for (let index = sourceItemIds.length - 1; index >= 0; index -= 1) { + const sourceItemId = sourceItemIds[index]; + const candidate = nextKeys.get(sourceItemId) ?? previousKeys.get(sourceItemId); + if (candidate && !claimedKeys.has(candidate)) { + key = candidate; + break; + } + } + if (!key) { + this.nextKey += 1; + key = `agent-assistant-group-${this.sessionGeneration}-${this.nextKey}`; + } + claimedKeys.add(key); + for (const sourceItemId of sourceItemIds) nextKeys.set(sourceItemId, key); + keyByTurn.set(turn, key); + } + this.keyBySourceItemId = nextKeys; + return keyByTurn; + } +} + +export function agentUserTurnReactKey(itemId: string): string { + return `agent-user-item:${itemId}`; +} + export interface AgentThoughtPhase { sessionId: string; phaseId: string; @@ -315,10 +406,16 @@ export function coalesceAdjacentThinkingItems(items: AgentTimelineItem[]): Agent return items.reduce((projected, item) => { const previous = projected[projected.length - 1]; if (item.itemType === "thinking" && previous?.itemType === "thinking") { - projected[projected.length - 1] = { + const merged = { ...previous, text: `${previous.text ?? ""}${item.text ?? ""}` }; + agentTimelineSourceIds.set(merged, { + kind: "concat", + left: agentTimelineSourceIdTree(previous), + right: agentTimelineSourceIdTree(item) + }); + projected[projected.length - 1] = merged; return projected; } projected.push(item); From 616d7909d667426e74e179bc5fdb5ea8bb2a54c4 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:53:29 +0000 Subject: [PATCH 3/3] feat(agent): add fail-closed portable history surface --- frontend/src/app.tsx | 7 +- .../RemoteAgentReadOnlyMode.test.tsx | 342 ++++++++++ .../components/RemoteAgentReadOnlyMode.tsx | 524 +++++++++++++++ frontend/src/components/Sidebar.tsx | 9 +- .../contexts/AgentPortableRuntimeContext.tsx | 28 + ...PortableRuntimeControllerProvider.test.tsx | 238 +++++++ ...AgentPortableRuntimeControllerProvider.tsx | 102 +++ frontend/src/routes/agent.tsx | 185 +++++- .../agentNativePortableBridge.test.ts | 315 +++++++++ .../src/services/agentNativePortableBridge.ts | 489 ++++++++++++++ ...entNativePortableRuntimeController.test.ts | 274 ++++++++ .../agentNativePortableRuntimeController.ts | 248 +++++++ .../services/agentRemoteCapabilities.test.ts | 71 ++ .../src/services/agentRemoteCapabilities.ts | 85 +++ .../agentRemoteProviderBridge.test.ts | 369 +++++++++++ .../src/services/agentRemoteProviderBridge.ts | 622 ++++++++++++++++++ .../agentRemoteSessionPagination.test.ts | 101 +++ .../services/agentRemoteSessionPagination.ts | 126 ++++ .../src/services/agentRouteRuntime.test.ts | 315 +++++++++ frontend/src/services/agentRouteRuntime.ts | 201 ++++++ frontend/src/utils/platform.test.ts | 21 + frontend/src/utils/platform.ts | 39 +- 22 files changed, 4682 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/RemoteAgentReadOnlyMode.test.tsx create mode 100644 frontend/src/components/RemoteAgentReadOnlyMode.tsx create mode 100644 frontend/src/contexts/AgentPortableRuntimeContext.tsx create mode 100644 frontend/src/contexts/AgentPortableRuntimeControllerProvider.test.tsx create mode 100644 frontend/src/contexts/AgentPortableRuntimeControllerProvider.tsx create mode 100644 frontend/src/services/agentNativePortableBridge.test.ts create mode 100644 frontend/src/services/agentNativePortableBridge.ts create mode 100644 frontend/src/services/agentNativePortableRuntimeController.test.ts create mode 100644 frontend/src/services/agentNativePortableRuntimeController.ts create mode 100644 frontend/src/services/agentRemoteCapabilities.test.ts create mode 100644 frontend/src/services/agentRemoteCapabilities.ts create mode 100644 frontend/src/services/agentRemoteProviderBridge.test.ts create mode 100644 frontend/src/services/agentRemoteProviderBridge.ts create mode 100644 frontend/src/services/agentRemoteSessionPagination.test.ts create mode 100644 frontend/src/services/agentRemoteSessionPagination.ts create mode 100644 frontend/src/services/agentRouteRuntime.test.ts create mode 100644 frontend/src/services/agentRouteRuntime.ts create mode 100644 frontend/src/utils/platform.test.ts diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 2f4538c6e..7f95e4128 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -18,6 +18,7 @@ import { ProxyEventListener } from "./components/ProxyEventListener"; import { UpdateEventListener } from "./components/UpdateEventListener"; import { TTSProvider } from "./services/tts/TTSContext"; import { openSecretPcrEnvironment } from "./config/openSecretPcrEnvironment"; +import { AgentPortableRuntimeControllerProvider } from "./contexts/AgentPortableRuntimeControllerProvider"; const DEFAULT_OPEN_SECRET_CLIENT_ID = "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"; @@ -40,7 +41,11 @@ declare module "@tanstack/react-router" { function InnerApp() { const os = useOpenSecret(); - return ; + return ( + + + + ); } const queryClient = new QueryClient({ diff --git a/frontend/src/components/RemoteAgentReadOnlyMode.test.tsx b/frontend/src/components/RemoteAgentReadOnlyMode.test.tsx new file mode 100644 index 000000000..5e57f317d --- /dev/null +++ b/frontend/src/components/RemoteAgentReadOnlyMode.test.tsx @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import { RemoteAgentReadOnlyMode } from "@/components/RemoteAgentReadOnlyMode"; +import { AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES } from "@/services/agentRemoteCapabilities"; +import type { + AgentRemoteReadOnlyClient, + AgentRemoteRecordsPage, + AgentRemoteSessionPage, + AgentRemoteSessionSummary +} from "@/services/agentRemoteProviderBridge"; + +function session(id: string, title: string, updatedMs: number): AgentRemoteSessionSummary { + return { + id, + title, + createdMs: updatedMs - 1, + updatedMs, + pageSortMs: updatedMs, + messageCount: 2 + }; +} + +const SESSION_A = session("session-a", "Task A", 20); +const SESSION_B = session("session-b", "Task B", 10); + +function headHistoryPage(): AgentRemoteRecordsPage { + return { + // Native pages are newest-first; the cache must present record containers + // chronologically without splitting their internal items. + records: [ + { + recordId: "record-new", + role: "assistant", + createdMs: 20, + items: [ + { + id: "permission-a", + itemType: "permission", + role: "system", + title: "Tool permission", + status: "completed", + createdMs: 20, + merge: "replace" + }, + { + id: "assistant-a", + itemType: "message", + role: "assistant", + text: "", + createdMs: 21, + merge: "replace" + } + ] + }, + { + recordId: "record-old", + role: "user", + createdMs: 10, + items: [ + { + id: "user-a", + itemType: "message", + role: "user", + text: "Older user text", + createdMs: 10, + merge: "replace" + } + ] + } + ], + nextCursor: "history-cursor-older", + historyRevision: "history-revision-a" + }; +} + +function client(overrides?: { + readonly getRuntimeStatus?: AgentRemoteReadOnlyClient["getRuntimeStatus"]; + readonly listSessionSummariesPage?: AgentRemoteReadOnlyClient["listSessionSummariesPage"]; + readonly listPersistedRecordsPage?: AgentRemoteReadOnlyClient["listPersistedRecordsPage"]; +}): AgentRemoteReadOnlyClient { + return Object.freeze({ + binding: Object.freeze({ + accountId: "account-a", + targetId: "target-a", + targetLabel: "Office Mac" + }), + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + getRuntimeStatus: + overrides?.getRuntimeStatus ?? (async () => ({ running: true, activeRunCount: 1 })), + listSessionSummariesPage: + overrides?.listSessionSummariesPage ?? + (async () => ({ items: [SESSION_A], nextCursor: "session-cursor-older" })), + listPersistedRecordsPage: overrides?.listPersistedRecordsPage ?? (async () => headHistoryPage()) + }); +} + +function nodeText(node: ReactTestInstance): string { + return node.children + .map((child) => (typeof child === "string" ? child : nodeText(child))) + .join(""); +} + +function button(renderer: ReactTestRenderer, text: string): ReactTestInstance { + const match = renderer.root + .findAllByType("button") + .find((candidate) => nodeText(candidate).includes(text)); + if (!match) throw new Error(`Button not found: ${text}`); + return match; +} + +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("RemoteAgentReadOnlyMode", () => { + let renderer: ReactTestRenderer | null = null; + + afterEach(() => { + if (renderer) act(() => renderer?.unmount()); + renderer = null; + }); + + test("loads only bounded read pages and renders persisted transcript as React text", async () => { + const statusCalls: unknown[][] = []; + const sessionCalls: Parameters[] = []; + const historyCalls: Parameters[] = []; + const readOnlyClient = client({ + getRuntimeStatus: mock(async (...args: unknown[]) => { + statusCalls.push(args); + return { running: true, activeRunCount: 1 }; + }), + listSessionSummariesPage: mock(async (request) => { + sessionCalls.push([request]); + return request.cursor + ? { items: [SESSION_B], nextCursor: null } + : { items: [SESSION_A], nextCursor: "session-cursor-older" }; + }), + listPersistedRecordsPage: mock(async (request): Promise => { + historyCalls.push([request]); + return request.cursor + ? { + records: [ + { + recordId: "record-oldest", + role: "system", + createdMs: 1, + items: [ + { + id: "system-oldest", + itemType: "system", + role: "system", + text: "Oldest persisted text", + createdMs: 1, + merge: "replace" + } + ] + } + ], + nextCursor: null, + historyRevision: "history-revision-a" + } + : headHistoryPage(); + }) + }); + + await act(async () => { + renderer = create(); + await settle(); + }); + if (!renderer) throw new Error("Remote Agent transcript browser did not mount"); + + expect(statusCalls).toEqual([[]]); + expect(sessionCalls).toEqual([[{ cursor: null, limit: 25 }]]); + expect(historyCalls).toEqual([]); + const initialText = nodeText(renderer.root); + expect(initialText).toContain("Read-only persisted transcripts from Office Mac"); + for (const prohibited of [ + "New task", + "Send message", + "Rename", + "Delete", + "Allow once", + "Deny once", + "MCP", + "Start runtime" + ]) { + expect(initialText).not.toContain(prohibited); + } + + await act(async () => { + button(renderer!, "Task A").props.onClick(); + await settle(); + }); + expect(historyCalls).toEqual([[{ sessionId: "session-a", cursor: null, limit: 25 }]]); + // A next cursor is presented as one explicit button, never followed in an + // automatic full-history loop. + expect(button(renderer, "Load older transcript")).toBeDefined(); + expect(historyCalls).toHaveLength(1); + + const transcriptItems = renderer.root.findAll( + (node) => typeof node.props["data-item-type"] === "string" + ); + expect(transcriptItems.map(nodeText)).toEqual([ + "YouOlder user text", + "Tool permissioncompleted", + "Assistant" + ]); + expect(renderer.root.findAllByType("script")).toHaveLength(0); + const renderedButtons = renderer.root.findAllByType("button").map(nodeText); + expect(renderedButtons).not.toContain("Allow once"); + expect(renderedButtons).not.toContain("Deny once"); + expect(renderedButtons).not.toContain("Cancel"); + + await act(async () => { + button(renderer!, "Load older transcript").props.onClick(); + await settle(); + }); + expect(historyCalls[1]).toEqual([ + { sessionId: "session-a", cursor: "history-cursor-older", limit: 25 } + ]); + + await act(async () => { + button(renderer!, "Load older tasks").props.onClick(); + await settle(); + }); + expect(sessionCalls[1]).toEqual([{ cursor: "session-cursor-older", limit: 25 }]); + }); + + test("does not overlap repeated history-head selection for the same task", async () => { + let resolveHistory!: (page: AgentRemoteRecordsPage) => void; + const historyPromise = new Promise((resolve) => { + resolveHistory = resolve; + }); + const historyCalls: Parameters[] = []; + const readOnlyClient = client({ + listPersistedRecordsPage: mock(async (request) => { + historyCalls.push([request]); + return await historyPromise; + }) + }); + + await act(async () => { + renderer = create(); + await settle(); + }); + if (!renderer) throw new Error("Remote Agent transcript browser did not mount"); + const taskButton = button(renderer, "Task A"); + act(() => { + taskButton.props.onClick(); + taskButton.props.onClick(); + }); + expect(historyCalls).toHaveLength(1); + + await act(async () => { + resolveHistory(headHistoryPage()); + await settle(); + }); + }); + + test("settles a 129th stalled task at the bounded history-state limit", async () => { + const sessions = Array.from({ length: 129 }, (_, index) => + session( + `session-${index.toString().padStart(3, "0")}`, + `Task ${index.toString().padStart(3, "0")}`, + 1_000 - index + ) + ); + const historyCalls: Parameters[] = []; + const readOnlyClient = client({ + listSessionSummariesPage: mock(async (request) => { + const start = request.cursor ? Number(request.cursor) : 0; + const end = Math.min(start + request.limit, sessions.length); + return { + items: sessions.slice(start, end), + nextCursor: end < sessions.length ? String(end) : null + }; + }), + listPersistedRecordsPage: mock(async (request) => { + historyCalls.push([request]); + return await new Promise(() => {}); + }) + }); + + await act(async () => { + renderer = create(); + await settle(); + }); + if (!renderer) throw new Error("Remote Agent transcript browser did not mount"); + + for (let page = 1; page < 6; page += 1) { + await act(async () => { + button(renderer!, "Load older tasks").props.onClick(); + await settle(); + }); + } + expect(renderer.root.findAllByType("li")).toHaveLength(129); + + for (let index = 0; index < 128; index += 1) { + act(() => { + button(renderer!, `Task ${index.toString().padStart(3, "0")}`).props.onClick(); + }); + } + expect(historyCalls).toHaveLength(128); + + await act(async () => { + button(renderer!, "Task 128").props.onClick(); + await settle(); + }); + expect(historyCalls).toHaveLength(128); + expect(nodeText(renderer.root)).toContain( + "This transcript reached Maple’s bounded history window." + ); + + act(() => { + button(renderer!, "Task 000").props.onClick(); + }); + expect(historyCalls).toHaveLength(128); + expect(nodeText(renderer.root)).toContain("Loading transcript…"); + }); + + test("renders generic failure copy without native or transport diagnostics", async () => { + const diagnostic = "access-token-canary remote-transport-stack"; + const rejectingSessions = mock( + async (): Promise => await Promise.reject(new Error(diagnostic)) + ); + const readOnlyClient = client({ + getRuntimeStatus: mock(async () => await Promise.reject(new Error(diagnostic))), + listSessionSummariesPage: rejectingSessions + }); + + await act(async () => { + renderer = create(); + await settle(); + }); + if (!renderer) throw new Error("Remote Agent transcript browser did not mount"); + const rendered = nodeText(renderer.root); + expect(rendered).toContain("Maple couldn’t load tasks from the paired host."); + expect(rendered).toContain("Retry host status"); + expect(rendered).not.toContain(diagnostic); + }); +}); diff --git a/frontend/src/components/RemoteAgentReadOnlyMode.tsx b/frontend/src/components/RemoteAgentReadOnlyMode.tsx new file mode 100644 index 000000000..4f351cdcb --- /dev/null +++ b/frontend/src/components/RemoteAgentReadOnlyMode.tsx @@ -0,0 +1,524 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { MapleWordmark } from "@/components/MapleWordmark"; +import { Button } from "@/components/ui/button"; +import { + AgentHistoryPaginationCache, + AgentHistoryProjectionLimitError, + type AgentHistoryPageToken, + type AgentHistorySnapshot +} from "@/services/agentHistoryPagination"; +import { + AgentRemoteSessionPaginationCache, + AgentRemoteSessionWindowLimitError +} from "@/services/agentRemoteSessionPagination"; +import { type AgentTimelineItem } from "@/services/agentRuntimeService"; +import { + type AgentRemoteReadOnlyClient, + type AgentRemoteRuntimeSummary, + type AgentRemoteSessionSummary +} from "@/services/agentRemoteProviderBridge"; + +const REMOTE_AGENT_PAGE_SIZE = 25; +const MAX_REMOTE_AGENT_SESSION_SUMMARIES = 200; + +type RemoteAgentLoadError = "unavailable" | "windowLimit" | null; + +export interface RemoteAgentReadOnlyModeProps { + readonly client: AgentRemoteReadOnlyClient; + readonly runtimeKey: string; +} + +/** + * Persisted transcript browser for an authenticated paired host. This surface + * intentionally has no composer, permissions, mutations, settings, MCP, + * administration, raw tool payloads, or live-event attachment. + */ +export function RemoteAgentReadOnlyMode({ client, runtimeKey }: RemoteAgentReadOnlyModeProps) { + const accountId = client.binding.accountId; + const targetId = client.binding.targetId; + const ownerKey = JSON.stringify([accountId, targetId, runtimeKey]); + const { sessionCache, historyCache } = useMemo( + () => createRemoteAgentCaches(ownerKey, accountId, targetId), + [accountId, ownerKey, targetId] + ); + const [runtimeSummary, setRuntimeSummary] = useState(null); + const [runtimeLoading, setRuntimeLoading] = useState(true); + const [runtimeError, setRuntimeError] = useState(null); + const [sessionSnapshot, setSessionSnapshot] = useState(() => sessionCache.snapshot()); + const [sessionError, setSessionError] = useState(null); + const [selectedSessionId, setSelectedSessionId] = useState(null); + const [historySnapshot, setHistorySnapshot] = useState(null); + const [historyError, setHistoryError] = useState(null); + const lifecycleGenerationRef = useRef(0); + const statusRequestRef = useRef(0); + const selectedSessionIdRef = useRef(null); + + const loadRuntimeStatus = useCallback( + async (generation: number) => { + const requestId = ++statusRequestRef.current; + setRuntimeLoading(true); + setRuntimeError(null); + try { + const status = await client.getRuntimeStatus(); + if ( + lifecycleGenerationRef.current !== generation || + statusRequestRef.current !== requestId + ) { + return; + } + setRuntimeSummary(status); + } catch { + if ( + lifecycleGenerationRef.current === generation && + statusRequestRef.current === requestId + ) { + setRuntimeSummary(null); + setRuntimeError("unavailable"); + } + } finally { + if ( + lifecycleGenerationRef.current === generation && + statusRequestRef.current === requestId + ) { + setRuntimeLoading(false); + } + } + }, + [client] + ); + + const loadSessionPage = useCallback( + async (kind: "head" | "older", generation: number) => { + // Refresh replaces the bounded task window instead of retaining every + // previously paged summary indefinitely. + if (kind === "head") sessionCache.clear(); + const before = sessionCache.snapshot(); + const remaining = MAX_REMOTE_AGENT_SESSION_SUMMARIES - before.items.length; + if (kind === "older" && remaining <= 0) return; + const token = kind === "head" ? sessionCache.beginHead() : sessionCache.beginOlder(); + if (!token) return; + const limit = + kind === "head" ? REMOTE_AGENT_PAGE_SIZE : Math.min(REMOTE_AGENT_PAGE_SIZE, remaining); + setSessionSnapshot(sessionCache.snapshot()); + setSessionError(null); + try { + const page = await client.listSessionSummariesPage({ + cursor: token.cursor, + limit + }); + if (lifecycleGenerationRef.current !== generation) { + sessionCache.fail(token); + return; + } + sessionCache.commit(token, page); + setSessionSnapshot(sessionCache.snapshot()); + } catch (error) { + sessionCache.fail(token); + if (lifecycleGenerationRef.current === generation) { + const snapshot = sessionCache.snapshot(); + setSessionSnapshot(snapshot); + if (!snapshot.isLoading) { + setSessionError( + error instanceof AgentRemoteSessionWindowLimitError ? "windowLimit" : "unavailable" + ); + } + } + } + }, + [client, sessionCache] + ); + + const loadHistoryPage = useCallback( + async (sessionId: string, kind: "head" | "older", generation: number) => { + let token: AgentHistoryPageToken | null = null; + try { + token = + kind === "head" ? historyCache.beginHead(sessionId) : historyCache.beginOlder(sessionId); + if (!token) return; + if (selectedSessionIdRef.current === sessionId) { + setHistorySnapshot(historyCache.snapshot(sessionId)); + setHistoryError(null); + } + const page = await client.listPersistedRecordsPage({ + sessionId, + cursor: token.cursor, + limit: REMOTE_AGENT_PAGE_SIZE + }); + if (lifecycleGenerationRef.current !== generation) { + historyCache.fail(token); + return; + } + const result = historyCache.commit(token, page); + historyCache.reconcileRetention( + new Set(selectedSessionIdRef.current ? [selectedSessionIdRef.current] : []) + ); + if (selectedSessionIdRef.current === sessionId) { + setHistorySnapshot(historyCache.snapshot(sessionId)); + setHistoryError(result === "history-replaced" ? "unavailable" : null); + } + } catch (error) { + if (token) historyCache.fail(token); + historyCache.reconcileRetention( + new Set(selectedSessionIdRef.current ? [selectedSessionIdRef.current] : []) + ); + if ( + lifecycleGenerationRef.current === generation && + selectedSessionIdRef.current === sessionId + ) { + const snapshot = historyCache.snapshot(sessionId); + setHistorySnapshot(snapshot); + if (!snapshot.isLoading) { + setHistoryError( + error instanceof AgentHistoryProjectionLimitError ? "windowLimit" : "unavailable" + ); + } + } + } + }, + [client, historyCache] + ); + + useEffect(() => { + const generation = ++lifecycleGenerationRef.current; + sessionCache.clear(); + historyCache.clear(); + selectedSessionIdRef.current = null; + setSelectedSessionId(null); + setHistorySnapshot(null); + setHistoryError(null); + setSessionSnapshot(sessionCache.snapshot()); + void loadRuntimeStatus(generation); + void loadSessionPage("head", generation); + return () => { + if (lifecycleGenerationRef.current === generation) { + lifecycleGenerationRef.current += 1; + statusRequestRef.current += 1; + } + }; + }, [client, historyCache, loadRuntimeStatus, loadSessionPage, ownerKey, sessionCache]); + + const selectSession = useCallback( + (sessionId: string) => { + selectedSessionIdRef.current = sessionId; + setSelectedSessionId(sessionId); + setHistoryError(null); + historyCache.reconcileRetention(new Set([sessionId])); + const snapshot = historyCache.snapshot(sessionId); + setHistorySnapshot(snapshot); + if (!snapshot.headLoaded && !snapshot.isLoading) { + void loadHistoryPage(sessionId, "head", lifecycleGenerationRef.current); + } + }, + [historyCache, loadHistoryPage] + ); + + const selectedSession = selectedSessionId + ? (sessionSnapshot.items.find((session) => session.id === selectedSessionId) ?? null) + : null; + const reachedSessionWindow = sessionSnapshot.items.length >= MAX_REMOTE_AGENT_SESSION_SUMMARIES; + + return ( +
+
+ +
+ +
+ + +
+ {!selectedSessionId ? ( +
+ Choose a task to browse its persisted transcript. +
+ ) : ( + + void loadHistoryPage(selectedSessionId, "head", lifecycleGenerationRef.current) + } + onLoadOlder={() => + void loadHistoryPage(selectedSessionId, "older", lifecycleGenerationRef.current) + } + /> + )} +
+
+
+ ); +} + +function RuntimeStatus({ + summary, + loading, + error, + onRetry +}: { + readonly summary: AgentRemoteRuntimeSummary | null; + readonly loading: boolean; + readonly error: RemoteAgentLoadError; + readonly onRetry: () => void; +}) { + if (loading) { + return ( + + Checking host… + + ); + } + if (error || !summary) { + return ( + + ); + } + return ( +
+ + Host {summary.running ? "active" : "idle"} + + {summary.activeRunCount > 0 && {summary.activeRunCount} active runs} +
+ ); +} + +function TranscriptPanel({ + session, + snapshot, + error, + onRefresh, + onLoadOlder +}: { + readonly session: AgentRemoteSessionSummary | null; + readonly snapshot: AgentHistorySnapshot | null; + readonly error: RemoteAgentLoadError; + readonly onRefresh: () => void; + readonly onLoadOlder: () => void; +}) { + return ( +
+
+
+

{session?.title ?? "Agent task"}

+

+ Persisted history only. Live updates and Agent actions are unavailable here. +

+
+ +
+ + {error && ( + + {error === "windowLimit" + ? "This transcript reached Maple’s bounded history window." + : "Maple couldn’t load this persisted transcript."} + + )} + {!snapshot?.headLoaded && snapshot?.isLoading && ( +

+ Loading transcript… +

+ )} + {snapshot?.headLoaded && snapshot.timeline.length === 0 && ( +

This task has no persisted transcript.

+ )} + + {snapshot && snapshot.timeline.length > 0 && ( +
    + {snapshot.timeline.map((item) => ( + + ))} +
+ )} + + {snapshot?.hasMore && ( + + )} +
+ ); +} + +function TranscriptItem({ item }: { readonly item: AgentTimelineItem }) { + const label = transcriptItemLabel(item); + return ( +
  • +
    + {label} + {item.status && {item.status}} +
    + {item.title && item.title !== label &&

    {item.title}

    } + {item.text &&

    {item.text}

    } +
  • + ); +} + +function transcriptItemLabel(item: AgentTimelineItem): string { + if (item.itemType === "message") { + if (item.role === "user") return "You"; + if (item.role === "assistant") return "Assistant"; + } + switch (item.itemType) { + case "thinking": + return "Thinking"; + case "tool": + return "Tool activity"; + case "permission": + return "Tool permission"; + case "error": + return "Agent error"; + case "system": + return "System"; + default: + return "Message"; + } +} + +function LoadNotice({ + children, + onRetry +}: { + readonly children: React.ReactNode; + onRetry: () => void; +}) { + return ( +
    +

    {children}

    + +
    + ); +} + +function formatAgentDate(timestamp: number): string { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? "Unknown date" : date.toLocaleDateString(); +} + +function createRemoteAgentCaches(ownerKey: string, accountId: string, targetId: string) { + // The opaque lifecycle key deliberately participates in cache identity even + // though it is not authorization and is never forwarded to the bridge. + if (!ownerKey) throw new Error("Remote Agent transcript cache owner is unavailable"); + return { + sessionCache: new AgentRemoteSessionPaginationCache(MAX_REMOTE_AGENT_SESSION_SUMMARIES), + historyCache: new AgentHistoryPaginationCache({ accountId, targetId }) + }; +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index bca51544a..af2d02c87 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -33,8 +33,9 @@ import { useSidebarSearchState } from "@/state/useLocalState"; import { SIDEBAR_MAX_WIDTH_CLASS, SIDEBAR_WIDTH_CLASS } from "@/constants/layout"; -import { isTauriDesktop } from "@/utils/platform"; +import { isTauriDesktop, isTauriMobile } from "@/utils/platform"; import { useOpenSecret } from "@opensecret/react"; +import { useAgentPortableRuntime } from "@/contexts/AgentPortableRuntimeContext"; import { FEATURE_FLAGS, flagsClient, isForcedOn } from "@/services/flags"; import { rememberWorkspaceMode } from "@/services/workspaceModePreference"; import { UpgradePromptDialog } from "@/components/UpgradePromptDialog"; @@ -73,6 +74,7 @@ export function Sidebar({ const router = useRouter(); const location = useLocation(); const { returnToHome } = usePersistentHomeNavigation(); + const portableAgentRuntime = useAgentPortableRuntime(); const os = useOpenSecret(); const runtimeStore = useChatRuntimeStore(); const userId = os.auth.user?.user.id; @@ -219,7 +221,10 @@ export function Sidebar({ const isMobile = useIsMobile(); const isLandscapeMobile = useIsLandscapeMobile(); const isCompactLayout = isMobile || isLandscapeMobile; - const agentModeAvailable = isTauriDesktop(); + // Portable clients expose the switch only when the account-scoped pairing + // provider is actually installed. Its unavailable state still routes to the + // honest explanation instead of silently falling back to local execution. + const agentModeAvailable = isTauriDesktop() || (isTauriMobile() && portableAgentRuntime !== null); const [agentModeFlag, setAgentModeFlag] = useState<{ userId: string; enabled: boolean; diff --git a/frontend/src/contexts/AgentPortableRuntimeContext.tsx b/frontend/src/contexts/AgentPortableRuntimeContext.tsx new file mode 100644 index 000000000..5374a3203 --- /dev/null +++ b/frontend/src/contexts/AgentPortableRuntimeContext.tsx @@ -0,0 +1,28 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { AgentPortableRuntimeState } from "@/services/agentRouteRuntime"; + +const AgentPortableRuntimeContext = createContext(null); + +/** + * Injection seam for the future authoritative paired-target registry. Keeping + * it separate from the route prevents URL or preference state from becoming a + * target-selection authority. + */ +export function AgentPortableRuntimeProvider({ + value, + children +}: { + value: AgentPortableRuntimeState | null; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useAgentPortableRuntime(): AgentPortableRuntimeState | null { + return useContext(AgentPortableRuntimeContext); +} diff --git a/frontend/src/contexts/AgentPortableRuntimeControllerProvider.test.tsx b/frontend/src/contexts/AgentPortableRuntimeControllerProvider.test.tsx new file mode 100644 index 000000000..a5c4a0ea1 --- /dev/null +++ b/frontend/src/contexts/AgentPortableRuntimeControllerProvider.test.tsx @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { useEffect } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { + AgentPortableRuntimeControllerProvider, + type AgentPortableRuntimeController +} from "@/contexts/AgentPortableRuntimeControllerProvider"; +import { useAgentPortableRuntime } from "@/contexts/AgentPortableRuntimeContext"; +import { AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES } from "@/services/agentRemoteCapabilities"; +import type { AgentRemoteReadOnlyClient } from "@/services/agentRemoteProviderBridge"; +import type { AgentPortableRuntimeState } from "@/services/agentRouteRuntime"; + +class TestPortableRuntimeController implements AgentPortableRuntimeController { + private readonly snapshots = new Map(); + private readonly listeners = new Map void>>(); + + getSnapshot(accountId: string): AgentPortableRuntimeState | null { + return this.snapshots.get(accountId) ?? null; + } + + subscribe(accountId: string, listener: () => void): () => void { + const listeners = this.listeners.get(accountId) ?? new Set(); + listeners.add(listener); + this.listeners.set(accountId, listeners); + return () => listeners.delete(listener); + } + + set(accountId: string, snapshot: AgentPortableRuntimeState | null): void { + this.snapshots.set(accountId, snapshot); + for (const listener of this.listeners.get(accountId) ?? []) listener(); + } + + subscriberCount(accountId: string): number { + return this.listeners.get(accountId)?.size ?? 0; + } +} + +function RuntimeProbe({ + onRender, + onReadOnlyReady +}: { + readonly onRender: (value: AgentPortableRuntimeState | null) => void; + readonly onReadOnlyReady?: (client: AgentRemoteReadOnlyClient) => void; +}) { + const value = useAgentPortableRuntime(); + onRender(value); + useEffect(() => { + if (value?.status === "readOnlyReady") onReadOnlyReady?.(value.client); + }, [onReadOnlyReady, value]); + return {value ? `${value.accountId}:${value.status}` : "unavailable"}; +} + +describe("AgentPortableRuntimeControllerProvider", () => { + let renderer: ReactTestRenderer | null = null; + + afterEach(() => { + if (renderer) act(() => renderer?.unmount()); + renderer = null; + }); + + test("keeps production fail-closed when no controller is installed", () => { + let current: AgentPortableRuntimeState | null | undefined; + act(() => { + renderer = create( + + (current = value)} /> + + ); + }); + + expect(current).toBeNull(); + expect(renderer?.toJSON()).toMatchObject({ children: ["unavailable"] }); + }); + + test("publishes only a snapshot bound to the requested account", () => { + const controller = new TestPortableRuntimeController(); + controller.set("account-a", { accountId: "account-a", status: "loading" }); + let current: AgentPortableRuntimeState | null | undefined; + act(() => { + renderer = create( + + (current = value)} /> + + ); + }); + + expect(current).toEqual({ accountId: "account-a", status: "loading" }); + expect(controller.subscriberCount("account-a")).toBe(1); + + act(() => { + controller.set("account-a", { accountId: "account-b", status: "loading" }); + }); + expect(current).toBeNull(); + }); + + test("unsubscribes the prior account before publishing a replacement account", () => { + const controller = new TestPortableRuntimeController(); + controller.set("account-a", { accountId: "account-a", status: "loading" }); + controller.set("account-b", { + accountId: "account-b", + status: "unavailable", + reason: "noPairedHost" + }); + const probe = (accountId: string) => ( + + {}} /> + + ); + + act(() => { + renderer = create(probe("account-a")); + }); + expect(controller.subscriberCount("account-a")).toBe(1); + act(() => renderer?.update(probe("account-b"))); + expect(controller.subscriberCount("account-a")).toBe(0); + expect(controller.subscriberCount("account-b")).toBe(1); + expect(renderer?.toJSON()).toMatchObject({ children: ["account-b:unavailable"] }); + }); + + test("removes controller state immediately for a signed-out account", () => { + const controller = new TestPortableRuntimeController(); + controller.set("account-a", { accountId: "account-a", status: "loading" }); + const probe = (accountId: string | null) => ( + + {}} /> + + ); + + act(() => { + renderer = create(probe("account-a")); + }); + act(() => renderer?.update(probe(null))); + expect(controller.subscriberCount("account-a")).toBe(0); + expect(renderer?.toJSON()).toMatchObject({ children: ["unavailable"] }); + }); + + test("does not revive stale readiness when the same account signs back in", () => { + const statusCalls: string[] = []; + const client: AgentRemoteReadOnlyClient = { + binding: { accountId: "account-a", targetId: "target-a" }, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + getRuntimeStatus: async () => { + statusCalls.push("status"); + return { running: true, activeRunCount: 0 }; + }, + listSessionSummariesPage: async () => ({ items: [], nextCursor: null }), + listPersistedRecordsPage: async () => ({ + records: [], + nextCursor: null, + historyRevision: "revision-a" + }) + }; + const controller = new TestPortableRuntimeController(); + controller.set("account-a", { + accountId: "account-a", + status: "readOnlyReady", + client, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + runtimeKey: "binding-a" + }); + const renderedStatuses: string[] = []; + const onRender = (value: AgentPortableRuntimeState | null) => { + renderedStatuses.push(value?.status ?? "unavailable"); + }; + const onReadOnlyReady = (readyClient: AgentRemoteReadOnlyClient) => { + void readyClient.getRuntimeStatus(); + }; + const probe = (accountId: string | null) => ( + + + + ); + + act(() => { + renderer = create(probe("account-a")); + }); + expect(statusCalls).toEqual(["status"]); + + act(() => renderer?.update(probe(null))); + expect(controller.subscriberCount("account-a")).toBe(0); + controller.set("account-a", { accountId: "account-a", status: "loading" }); + renderedStatuses.length = 0; + statusCalls.length = 0; + + act(() => renderer?.update(probe("account-a"))); + expect(renderedStatuses).not.toContain("readOnlyReady"); + expect(statusCalls).toEqual([]); + expect(controller.subscriberCount("account-a")).toBe(1); + expect(renderer?.toJSON()).toMatchObject({ children: ["account-a:loading"] }); + }); + + test("fails closed when readiness cannot subscribe to revocation updates", () => { + const snapshot: AgentPortableRuntimeState = { accountId: "account-a", status: "loading" }; + const getSnapshot = mock(() => snapshot); + const controller: AgentPortableRuntimeController = { + getSnapshot, + subscribe: () => { + throw new Error("subscription unavailable"); + } + }; + + act(() => { + renderer = create( + + {}} /> + + ); + }); + expect(renderer?.toJSON()).toMatchObject({ children: ["unavailable"] }); + expect(getSnapshot).not.toHaveBeenCalled(); + }); + + test("ignores a synchronous notification when subscription then fails", () => { + const getSnapshot = mock( + (): AgentPortableRuntimeState => ({ + accountId: "account-a", + status: "loading" + }) + ); + const controller: AgentPortableRuntimeController = { + getSnapshot, + subscribe: (_accountId, listener) => { + listener(); + throw new Error("subscription failed after notification"); + } + }; + + act(() => { + renderer = create( + + {}} /> + + ); + }); + expect(renderer?.toJSON()).toMatchObject({ children: ["unavailable"] }); + expect(getSnapshot).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/contexts/AgentPortableRuntimeControllerProvider.tsx b/frontend/src/contexts/AgentPortableRuntimeControllerProvider.tsx new file mode 100644 index 000000000..0d244009c --- /dev/null +++ b/frontend/src/contexts/AgentPortableRuntimeControllerProvider.tsx @@ -0,0 +1,102 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { AgentPortableRuntimeProvider } from "@/contexts/AgentPortableRuntimeContext"; +import type { AgentPortableRuntimeState } from "@/services/agentRouteRuntime"; + +export interface AgentPortableRuntimeController { + /** Return the controller's cached immutable snapshot for this exact account. */ + getSnapshot(accountId: string): AgentPortableRuntimeState | null; + /** Subscribe only to state owned by this exact account. */ + subscribe(accountId: string, listener: () => void): () => void; +} + +interface AgentPortableRuntimeControllerProviderProps { + readonly accountId: string | null; + readonly controller?: AgentPortableRuntimeController | null; + readonly children: ReactNode; +} + +interface PublishedPortableRuntime { + readonly accountId: string; + readonly controller: AgentPortableRuntimeController; + readonly dependencyIdentity: object; + readonly snapshot: AgentPortableRuntimeState | null; +} + +/** + * Account-fenced injection point for a future authoritative paired-target + * controller. Maple does not install one in production yet, so the default is + * deliberately null and the portable route remains unavailable. + */ +export function AgentPortableRuntimeControllerProvider({ + accountId, + controller = null, + children +}: AgentPortableRuntimeControllerProviderProps) { + const [published, setPublished] = useState(null); + // A render-stable identity changes for every account/controller transition, + // including A -> signed out -> the same A/controller. This synchronously + // fences retained state before the replacement subscription effect runs. + const dependencyIdentity = useMemo( + () => Object.freeze({ accountId, controller }), + [accountId, controller] + ); + + useEffect(() => { + if (!controller || !accountId) return; + let active = true; + const publishSnapshot = () => { + if (!active) return; + try { + const snapshot = controller.getSnapshot(accountId); + setPublished({ + accountId, + controller, + dependencyIdentity, + snapshot: snapshot?.accountId === accountId ? snapshot : null + }); + } catch { + setPublished({ accountId, controller, dependencyIdentity, snapshot: null }); + } + }; + + let unsubscribe: (() => void) | null = null; + let subscriptionReady = false; + const onControllerChange = () => { + // A malformed controller may invoke its listener and then throw instead + // of returning a revocation handle. Read only after subscribe succeeds. + if (subscriptionReady) publishSnapshot(); + }; + try { + unsubscribe = controller.subscribe(accountId, onControllerChange); + if (typeof unsubscribe !== "function") unsubscribe = null; + } catch { + unsubscribe = null; + } + // Never publish readiness without a revocation subscription already held. + if (unsubscribe) { + subscriptionReady = true; + publishSnapshot(); + } else setPublished({ accountId, controller, dependencyIdentity, snapshot: null }); + + return () => { + active = false; + try { + unsubscribe?.(); + } catch { + // The account/controller identity check below removes the old snapshot + // synchronously even when a provider cleanup reports failure. + } + }; + }, [accountId, controller, dependencyIdentity]); + + const portableRuntime = + published?.dependencyIdentity === dependencyIdentity && + published.accountId === accountId && + published.controller === controller + ? published.snapshot + : null; + + return ( + {children} + ); +} diff --git a/frontend/src/routes/agent.tsx b/frontend/src/routes/agent.tsx index 5543f8816..2b41c207a 100644 --- a/frontend/src/routes/agent.tsx +++ b/frontend/src/routes/agent.tsx @@ -1,10 +1,21 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { Link, createFileRoute } from "@tanstack/react-router"; import { useOpenSecret } from "@opensecret/react"; import { AppEntryPage } from "@/components/AppEntryPage"; import { useRouteMeta } from "@/utils/routeMeta"; import { appUrl } from "@/config/domains"; -import { isTauriDesktop } from "@/utils/platform"; +import { isTauri, isTauriDesktop, isTauriMobile } from "@/utils/platform"; import { AgentMode } from "@/components/AgentMode"; +import { RemoteAgentReadOnlyMode } from "@/components/RemoteAgentReadOnlyMode"; +import { MapleWordmark } from "@/components/MapleWordmark"; +import { Button } from "@/components/ui/button"; +import { useAgentPortableRuntime } from "@/contexts/AgentPortableRuntimeContext"; +import { + agentRouteProjectionKey, + agentRemoteReadOnlyProjectionKey, + resolveAgentRouteRuntime, + type AgentRouteRuntimeState, + type AgentRouteUnavailableReason +} from "@/services/agentRouteRuntime"; export const Route = createFileRoute("/agent")({ component: AgentRoute @@ -12,22 +23,176 @@ export const Route = createFileRoute("/agent")({ function AgentRoute() { const os = useOpenSecret(); - const agentModeAvailable = isTauriDesktop(); + const portableRuntime = useAgentPortableRuntime(); + const userId = os.auth.user?.user.id; + const runtime = userId + ? resolveAgentRouteRuntime({ + accountId: userId, + platform: { + isTauri: isTauri(), + isTauriDesktop: isTauriDesktop(), + isTauriMobile: isTauriMobile() + }, + portableRuntime + }) + : null; useRouteMeta({ - title: agentModeAvailable && os.auth.user ? "Maple Agent Mode" : "Maple AI", + title: + runtime?.status === "ready" + ? "Maple Agent Mode" + : runtime?.status === "readOnlyReady" + ? "Maple Agent History" + : "Maple AI", description: "Maple Agent Mode.", canonicalUrl: appUrl("/agent") }); - if (!agentModeAvailable) { - return ; - } - if (!os.auth.user) { return ; } - const userId = os.auth.user.user.id; - return ; + if (!runtime) return null; + return ; +} + +function AgentRouteContent({ + userId, + runtime +}: { + userId: string; + runtime: AgentRouteRuntimeState; +}) { + if (runtime.status === "ready") { + return ( + + ); + } + + if (runtime.status === "readOnlyReady") { + return ( + + ); + } + + if (runtime.status === "loading") { + return ( + + + Maple is checking the paired hosts available to this account. + + + ); + } + + if (runtime.status === "selectionRequired") { + return ( + +

    Choose the paired desktop whose persisted Agent history you want to browse.

    +
      + {runtime.targets.map((target) => ( +
    • + +
    • + ))} +
    +
    + ); + } + + const copy = unavailableCopy(runtime.reason); + return ( + + {copy.description} + + ); +} + +function AgentRouteState({ + title, + children, + showHomeLink = false +}: { + title: string; + children: React.ReactNode; + showHomeLink?: boolean; +}) { + return ( +
    +
    + +
    +

    {title}

    +
    {children}
    +
    + {showHomeLink && ( + + )} +
    +
    + ); +} + +function unavailableCopy(reason: AgentRouteUnavailableReason): { + title: string; + description: string; +} { + switch (reason) { + case "requiresTauri": + return { + title: "Agent Mode requires the Maple app", + description: + "Agent sessions cannot run from this browser. Open Maple on a supported device." + }; + case "unsupportedTauriClient": + return { + title: "Agent Mode isn’t supported here", + description: "This Maple app does not support local or paired-host Agent sessions." + }; + case "remoteProviderUnavailable": + return { + title: "Remote Agent Mode isn’t ready on this device", + description: + "This build cannot verify a paired desktop host, so Maple will not fall back to local execution." + }; + case "noPairedHost": + return { + title: "No paired Agent host", + description: "Pair this Maple app with a desktop host before browsing its Agent history." + }; + case "pairingUnavailable": + return { + title: "Paired Agent host unavailable", + description: "Maple could not verify an Agent host for this account." + }; + case "invalidPortableRuntime": + return { + title: "Remote Agent Mode is unavailable", + description: "Maple refused an invalid execution target instead of running it locally." + }; + } } diff --git a/frontend/src/services/agentNativePortableBridge.test.ts b/frontend/src/services/agentNativePortableBridge.test.ts new file mode 100644 index 000000000..d62aa7b95 --- /dev/null +++ b/frontend/src/services/agentNativePortableBridge.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, mock, test } from "bun:test"; +import { AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES } from "@/services/agentRemoteCapabilities"; +import type { AgentRemoteRecordsPage } from "@/services/agentRemoteProviderBridge"; +import { + AGENT_NATIVE_PORTABLE_COMMANDS, + createAgentNativePortableReadOnlySource, + decodeAgentNativePortableError, + decodeAgentNativePortableRecordsPage, + decodeAgentNativePortableRefreshResult, + decodeAgentNativePortableWireLease, + isAgentNativePortableAccountId, + tauriAgentNativePortableBridge, + type AgentNativePortableBridge, + type AgentNativePortableReadBinding, + type AgentNativePortableWireLease +} from "@/services/agentNativePortableBridge"; + +const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111"; +const RUNTIME_ID = `runtime_${"1".repeat(48)}`; +const TARGET_HANDLE = `target_${"2".repeat(48)}`; +const LEASE_HANDLE = `lease_${"3".repeat(48)}`; + +function lease(): AgentNativePortableWireLease { + return { + leaseHandle: LEASE_HANDLE, + targetHandle: TARGET_HANDLE, + hostEpoch: "18446744073709551615", + connectionGeneration: 7 + }; +} + +function binding(): AgentNativePortableReadBinding { + return { accountId: ACCOUNT_ID, runtimeId: RUNTIME_ID, lease: lease() }; +} + +function recordItemsPage() { + return { + items: [ + { + recordId: "record-a", + role: "assistant", + createdMs: 4, + items: [ + { + id: "message-a", + itemType: "message", + role: "assistant", + text: "safe text", + createdMs: 4, + merge: "replace" + } + ] + } + ], + historyRevision: "revision-a", + nextCursor: "history-cursor-b" + }; +} + +describe("native portable Agent bridge", () => { + test("pins the exact five commands and exposes no generic invoke", () => { + expect(AGENT_NATIVE_PORTABLE_COMMANDS).toEqual({ + refreshTargets: "agent_portable_refresh_targets", + prepareTarget: "agent_portable_prepare_target", + getRuntimeStatus: "agent_portable_get_runtime_status", + listSessionsPage: "agent_portable_list_sessions_page", + listRecordsPage: "agent_portable_list_records_page" + }); + expect(Object.keys(tauriAgentNativePortableBridge).sort()).toEqual( + [ + "getRuntimeStatus", + "listRecordsPage", + "listSessionsPage", + "prepareTarget", + "refreshTargets" + ].sort() + ); + expect("invoke" in tauriAgentNativePortableBridge).toBe(false); + }); + + test("decodes only the exact authenticated refresh grant and target roster", () => { + const result = decodeAgentNativePortableRefreshResult({ + schemaVersion: 1, + runtimeId: RUNTIME_ID, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + items: [{ handle: TARGET_HANDLE, label: "Office Mac" }] + }); + expect(result).toEqual({ + schemaVersion: 1, + runtimeId: RUNTIME_ID, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + items: [{ handle: TARGET_HANDLE, label: "Office Mac" }] + }); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + capabilities: { ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, mutations: true } + }) + ).toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ ...result, providerPrivateField: "secret" }) + ).toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [ + { handle: TARGET_HANDLE, label: "Office Mac" }, + { handle: TARGET_HANDLE, label: "Duplicate" } + ] + }) + ).toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: "Trusted\u202ehost" }] + }) + ).toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: " Office Mac" }] + }) + ).toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: "Office Mac " }] + }) + ).toBeNull(); + for (const whitespace of [ + "\u00a0", + "\u1680", + "\u2000", + "\u2028", + "\u202f", + "\u205f", + "\u3000" + ]) { + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: `${whitespace}Office Mac` }] + }) + ).toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: `Office Mac${whitespace}` }] + }) + ).toBeNull(); + } + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: "\ufeffOffice Mac" }] + }) + ).not.toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: "Office Mac\ufeff" }] + }) + ).not.toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: "x".repeat(80) }] + }) + ).not.toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: "x".repeat(81) }] + }) + ).toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: "🦀".repeat(64) }] + }) + ).not.toBeNull(); + expect( + decodeAgentNativePortableRefreshResult({ + ...result, + items: [{ handle: TARGET_HANDLE, label: "🦀".repeat(65) }] + }) + ).toBeNull(); + }); + + test("accepts only canonical lowercase non-nil UUID account IDs", () => { + expect(isAgentNativePortableAccountId(ACCOUNT_ID)).toBe(true); + expect(isAgentNativePortableAccountId("ffffffff-ffff-ffff-ffff-ffffffffffff")).toBe(true); + expect(isAgentNativePortableAccountId("00000000-0000-0000-0000-000000000000")).toBe(false); + expect(isAgentNativePortableAccountId("11111111-1111-1111-1111-11111111111A")).toBe(false); + expect(isAgentNativePortableAccountId("111111111111-1111-1111-111111111111")).toBe(false); + expect(isAgentNativePortableAccountId(` ${ACCOUNT_ID}`)).toBe(false); + expect(isAgentNativePortableAccountId(`${ACCOUNT_ID} `)).toBe(false); + }); + + test("keeps a fresh lease handle distinct from its selected target handle", () => { + expect(decodeAgentNativePortableWireLease(lease())).toEqual(lease()); + expect( + decodeAgentNativePortableWireLease({ + ...lease(), + leaseHandle: TARGET_HANDLE + }) + ).toBeNull(); + expect( + decodeAgentNativePortableWireLease({ + ...lease(), + hostEpoch: "01" + }) + ).toBeNull(); + expect( + decodeAgentNativePortableWireLease({ + ...lease(), + hostEpoch: "9007199254740992" + }) + ).not.toBeNull(); + expect( + decodeAgentNativePortableWireLease({ + ...lease(), + hostEpoch: "18446744073709551616" + }) + ).toBeNull(); + expect( + decodeAgentNativePortableWireLease({ + ...lease(), + hostEpoch: "0" + }) + ).toBeNull(); + expect( + decodeAgentNativePortableWireLease({ + ...lease(), + connectionGeneration: Number.MAX_SAFE_INTEGER + 1 + }) + ).toBeNull(); + expect( + decodeAgentNativePortableWireLease({ + ...lease(), + nativeLease: "must-not-cross" + }) + ).toBeNull(); + }); + + test("explicitly maps native history items to Phase 1 records", () => { + const decoded = decodeAgentNativePortableRecordsPage(recordItemsPage(), { + sessionId: "session-a", + cursor: "history-cursor-a", + limit: 5 + }); + expect(decoded).toEqual({ + records: recordItemsPage().items, + historyRevision: "revision-a", + nextCursor: "history-cursor-b" + } as AgentRemoteRecordsPage); + expect("items" in decoded!).toBe(false); + expect( + decodeAgentNativePortableRecordsPage( + { + ...recordItemsPage(), + records: recordItemsPage().items + }, + { sessionId: "session-a", limit: 5 } + ) + ).toBeNull(); + }); + + test("accepts only the closed native error code envelope", () => { + expect(decodeAgentNativePortableError({ code: "stale_lease" })).toBe("stale_lease"); + expect(decodeAgentNativePortableError({ code: "stale_lease", message: "secret" })).toBe( + "unavailable" + ); + expect(decodeAgentNativePortableError("raw native error with secrets")).toBe("unavailable"); + }); + + test("fails closed without leaking platform or invoke errors off mobile", async () => { + await expect(tauriAgentNativePortableBridge.refreshTargets(ACCOUNT_ID)).rejects.toMatchObject({ + name: "AgentNativePortableError", + code: "unavailable" + }); + }); + + test("the read source binds account, runtime, and every lease scalar before and after await", async () => { + let current = true; + let releaseStatus!: (value: { running: boolean; activeRunCount: number }) => void; + const status = new Promise<{ running: boolean; activeRunCount: number }>((resolve) => { + releaseStatus = resolve; + }); + const statusCalls: AgentNativePortableReadBinding[] = []; + const bridge: AgentNativePortableBridge = { + refreshTargets: async () => { + throw new Error("unexpected refresh"); + }, + prepareTarget: async () => { + throw new Error("unexpected prepare"); + }, + getRuntimeStatus: async (captured) => { + statusCalls.push(captured); + return await status; + }, + listSessionsPage: mock(async () => ({ items: [] })), + listRecordsPage: mock(async () => ({ records: [], historyRevision: "revision-a" })) + }; + const source = createAgentNativePortableReadOnlySource(bridge, binding(), () => { + if (!current) throw new Error("stale binding"); + }); + + const pending = source.getRuntimeStatus(); + expect(statusCalls).toEqual([binding()]); + current = false; + releaseStatus({ running: true, activeRunCount: 0 }); + await expect(pending).rejects.toThrow("stale binding"); + }); +}); diff --git a/frontend/src/services/agentNativePortableBridge.ts b/frontend/src/services/agentNativePortableBridge.ts new file mode 100644 index 000000000..474299f0c --- /dev/null +++ b/frontend/src/services/agentNativePortableBridge.ts @@ -0,0 +1,489 @@ +import { isTauriMobile } from "@/utils/platform"; +import { + decodeAgentRemoteRecordsPage, + decodeAgentRemoteRuntimeSummary, + decodeAgentRemoteSessionPage, + type AgentRemotePageRequest, + type AgentRemoteReadOnlySource, + type AgentRemoteRecordsPage, + type AgentRemoteRecordsPageRequest, + type AgentRemoteRuntimeSummary, + type AgentRemoteSessionPage +} from "@/services/agentRemoteProviderBridge"; +import { + decodeAgentRemoteCapabilitySnapshot, + isAgentRemotePersistedTranscriptReady, + type AgentRemoteCapabilitySnapshot +} from "@/services/agentRemoteCapabilities"; + +const MAX_NATIVE_TARGETS = 64; +const MAX_TARGET_LABEL_BYTES = 256; +const MAX_TARGET_LABEL_CHARACTERS = 80; +const MAX_RUNTIME_ID_BYTES = 128; +const MAX_U64 = 18_446_744_073_709_551_615n; +const MAX_U64_DECIMAL_DIGITS = 20; +const NIL_ACCOUNT_ID = "00000000-0000-0000-0000-000000000000"; +const ACCOUNT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const RUNTIME_ID_PATTERN = /^runtime_[0-9a-f]{48}$/; +const TARGET_HANDLE_PATTERN = /^target_[0-9a-f]{48}$/; +const LEASE_HANDLE_PATTERN = /^lease_[0-9a-f]{48}$/; + +export const AGENT_NATIVE_PORTABLE_COMMANDS = Object.freeze({ + refreshTargets: "agent_portable_refresh_targets", + prepareTarget: "agent_portable_prepare_target", + getRuntimeStatus: "agent_portable_get_runtime_status", + listSessionsPage: "agent_portable_list_sessions_page", + listRecordsPage: "agent_portable_list_records_page" +}); + +export type AgentNativePortableErrorCode = + | "unavailable" + | "unauthenticated" + | "pairing_unavailable" + | "unknown_target" + | "busy" + | "cancelled" + | "stale_runtime" + | "stale_lease" + | "invalid_request" + | "invalid_response" + | "peer_unavailable" + | "cleanup_failed"; + +const NATIVE_PORTABLE_ERROR_CODES = new Set([ + "unavailable", + "unauthenticated", + "pairing_unavailable", + "unknown_target", + "busy", + "cancelled", + "stale_runtime", + "stale_lease", + "invalid_request", + "invalid_response", + "peer_unavailable", + "cleanup_failed" +]); + +export class AgentNativePortableError extends Error { + constructor(readonly code: AgentNativePortableErrorCode) { + super(`Native paired-host access failed (${code})`); + this.name = "AgentNativePortableError"; + } +} + +export interface AgentNativePortableTarget { + readonly handle: string; + readonly label: string; +} + +export interface AgentNativePortableRefreshResult { + readonly schemaVersion: 1; + readonly runtimeId: string; + readonly capabilities: AgentRemoteCapabilitySnapshot; + readonly items: readonly AgentNativePortableTarget[]; +} + +export interface AgentNativePortableWireLease { + readonly leaseHandle: string; + readonly targetHandle: string; + readonly hostEpoch: string; + readonly connectionGeneration: number; +} + +export interface AgentNativePortableReadBinding { + readonly accountId: string; + readonly runtimeId: string; + readonly lease: AgentNativePortableWireLease; +} + +/** Exact named native operations; there is deliberately no generic invoke. */ +export interface AgentNativePortableBridge { + refreshTargets(accountId: string): Promise; + prepareTarget( + accountId: string, + runtimeId: string, + targetHandle: string + ): Promise; + getRuntimeStatus(binding: AgentNativePortableReadBinding): Promise; + listSessionsPage( + binding: AgentNativePortableReadBinding, + page: AgentRemotePageRequest + ): Promise; + listRecordsPage( + binding: AgentNativePortableReadBinding, + page: AgentRemoteRecordsPageRequest + ): Promise; +} + +export const tauriAgentNativePortableBridge: AgentNativePortableBridge = Object.freeze({ + async refreshTargets(accountId: string) { + requireAccountId(accountId); + const value = await invokePortable(AGENT_NATIVE_PORTABLE_COMMANDS.refreshTargets, { + request: { accountId } + }); + const decoded = decodeAgentNativePortableRefreshResult(value); + if (!decoded) throw new AgentNativePortableError("invalid_response"); + return decoded; + }, + + async prepareTarget(accountId: string, runtimeId: string, targetHandle: string) { + requireAccountId(accountId); + requireRuntimeId(runtimeId); + requireTargetHandle(targetHandle); + const value = await invokePortable(AGENT_NATIVE_PORTABLE_COMMANDS.prepareTarget, { + request: { accountId, runtimeId, targetHandle } + }); + const decoded = decodeAgentNativePortableWireLease(value); + if (!decoded || decoded.targetHandle !== targetHandle) { + throw new AgentNativePortableError("invalid_response"); + } + return decoded; + }, + + async getRuntimeStatus(binding: AgentNativePortableReadBinding) { + const request = nativeReadRequest(binding); + const value = await invokePortable(AGENT_NATIVE_PORTABLE_COMMANDS.getRuntimeStatus, { + request + }); + const decoded = decodeAgentRemoteRuntimeSummary(value); + if (!decoded) throw new AgentNativePortableError("invalid_response"); + return decoded; + }, + + async listSessionsPage(binding: AgentNativePortableReadBinding, page: AgentRemotePageRequest) { + const request = nativeReadRequest(binding); + const normalizedPage = nativePageRequest(page); + const value = await invokePortable(AGENT_NATIVE_PORTABLE_COMMANDS.listSessionsPage, { + request: { ...request, page: normalizedPage } + }); + const decoded = decodeAgentRemoteSessionPage(value, page); + if (!decoded) throw new AgentNativePortableError("invalid_response"); + return decoded; + }, + + async listRecordsPage( + binding: AgentNativePortableReadBinding, + page: AgentRemoteRecordsPageRequest + ) { + const request = nativeReadRequest(binding); + const normalizedPage = nativeRecordsPageRequest(page); + const value = await invokePortable(AGENT_NATIVE_PORTABLE_COMMANDS.listRecordsPage, { + request: { ...request, page: normalizedPage } + }); + const decoded = decodeAgentNativePortableRecordsPage(value, page); + if (!decoded) throw new AgentNativePortableError("invalid_response"); + return decoded; + } +}); + +export function createAgentNativePortableReadOnlySource( + bridge: AgentNativePortableBridge, + binding: AgentNativePortableReadBinding, + assertCurrent: () => void +): AgentRemoteReadOnlySource { + const captured = freezeReadBinding(binding); + return Object.freeze({ + getRuntimeStatus: async () => { + assertCurrent(); + const result = await bridge.getRuntimeStatus(captured); + assertCurrent(); + return result; + }, + listSessionSummariesPage: async (page: AgentRemotePageRequest) => { + assertCurrent(); + const result = await bridge.listSessionsPage(captured, page); + assertCurrent(); + return result; + }, + listPersistedRecordsPage: async (page: AgentRemoteRecordsPageRequest) => { + assertCurrent(); + const result = await bridge.listRecordsPage(captured, page); + assertCurrent(); + return result; + } + }); +} + +export function decodeAgentNativePortableRefreshResult( + value: unknown +): AgentNativePortableRefreshResult | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["schemaVersion", "runtimeId", "capabilities", "items"]) || + value.schemaVersion !== 1 || + !isRuntimeId(value.runtimeId) || + !Array.isArray(value.items) || + value.items.length > MAX_NATIVE_TARGETS + ) { + return null; + } + const capabilities = decodeAgentRemoteCapabilitySnapshot(value.capabilities); + if (!capabilities || !isAgentRemotePersistedTranscriptReady(capabilities)) return null; + + const handles = new Set(); + const items: AgentNativePortableTarget[] = []; + for (const item of value.items) { + if ( + !isRecord(item) || + !hasOnlyKeys(item, ["handle", "label"]) || + !isTargetHandle(item.handle) || + !isSafeTargetLabel(item.label) || + handles.has(item.handle) + ) { + return null; + } + handles.add(item.handle); + items.push(Object.freeze({ handle: item.handle, label: item.label })); + } + return Object.freeze({ schemaVersion: 1, runtimeId: value.runtimeId, capabilities, items }); +} + +export function decodeAgentNativePortableWireLease( + value: unknown +): AgentNativePortableWireLease | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["leaseHandle", "targetHandle", "hostEpoch", "connectionGeneration"]) || + !isLeaseHandle(value.leaseHandle) || + !isTargetHandle(value.targetHandle) || + !isCanonicalPositiveU64(value.hostEpoch) || + !isPositiveSafeInteger(value.connectionGeneration) + ) { + return null; + } + return Object.freeze({ + leaseHandle: value.leaseHandle, + targetHandle: value.targetHandle, + hostEpoch: value.hostEpoch, + connectionGeneration: value.connectionGeneration + }); +} + +export function decodeAgentNativePortableRecordsPage( + value: unknown, + request: AgentRemoteRecordsPageRequest +): AgentRemoteRecordsPage | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["items", "historyRevision", "nextCursor"]) || + !Array.isArray(value.items) + ) { + return null; + } + // Rust deliberately names the outer collection `items`. Phase 1 names the + // same bounded, decoded collection `records`; reconstruct instead of + // retaining or mutating the provider-owned response. + return decodeAgentRemoteRecordsPage( + { + records: value.items, + historyRevision: value.historyRevision, + ...(value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor }) + }, + request + ); +} + +export function decodeAgentNativePortableError(value: unknown): AgentNativePortableErrorCode { + if ( + isRecord(value) && + hasOnlyKeys(value, ["code"]) && + typeof value.code === "string" && + NATIVE_PORTABLE_ERROR_CODES.has(value.code as AgentNativePortableErrorCode) + ) { + return value.code as AgentNativePortableErrorCode; + } + return "unavailable"; +} + +function freezeReadBinding( + binding: AgentNativePortableReadBinding +): AgentNativePortableReadBinding { + requireAccountId(binding.accountId); + requireRuntimeId(binding.runtimeId); + const lease = decodeAgentNativePortableWireLease(binding.lease); + if (!lease) throw new AgentNativePortableError("invalid_request"); + return Object.freeze({ accountId: binding.accountId, runtimeId: binding.runtimeId, lease }); +} + +function nativeReadRequest(binding: AgentNativePortableReadBinding) { + const captured = freezeReadBinding(binding); + return { + accountId: captured.accountId, + runtimeId: captured.runtimeId, + lease: captured.lease + }; +} + +function nativePageRequest(page: AgentRemotePageRequest) { + if ( + !isRecord(page) || + !hasOnlyKeys(page, ["cursor", "limit"]) || + !isPositiveSafeInteger(page.limit) || + page.limit > 50 || + !isNullableSafeToken(page.cursor) + ) { + throw new AgentNativePortableError("invalid_request"); + } + return { + ...(typeof page.cursor === "string" ? { cursor: page.cursor } : {}), + limit: page.limit + }; +} + +function nativeRecordsPageRequest(page: AgentRemoteRecordsPageRequest) { + if ( + !isRecord(page) || + !hasOnlyKeys(page, ["sessionId", "cursor", "limit"]) || + !isSafeToken(page.sessionId, 128) + ) { + throw new AgentNativePortableError("invalid_request"); + } + return { + sessionId: page.sessionId, + ...nativePageRequest({ + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + limit: page.limit + }) + }; +} + +async function invokePortable(command: string, args: Record): Promise { + try { + if (!isTauriMobile()) throw new AgentNativePortableError("unavailable"); + const { invoke } = await import("@tauri-apps/api/core"); + return await invoke(command, args); + } catch (error) { + throw new AgentNativePortableError(decodeAgentNativePortableError(error)); + } +} + +function requireAccountId(accountId: string): void { + if (!isAgentNativePortableAccountId(accountId)) { + throw new AgentNativePortableError("invalid_request"); + } +} + +export function isAgentNativePortableAccountId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length === 36 && + value !== NIL_ACCOUNT_ID && + ACCOUNT_ID_PATTERN.test(value) + ); +} + +function requireRuntimeId(runtimeId: string): void { + if (!isRuntimeId(runtimeId)) throw new AgentNativePortableError("invalid_request"); +} + +function requireTargetHandle(handle: string): void { + if (!isTargetHandle(handle)) throw new AgentNativePortableError("invalid_request"); +} + +function isRuntimeId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= MAX_RUNTIME_ID_BYTES && + RUNTIME_ID_PATTERN.test(value) + ); +} + +function isTargetHandle(value: unknown): value is string { + return typeof value === "string" && TARGET_HANDLE_PATTERN.test(value); +} + +function isLeaseHandle(value: unknown): value is string { + return typeof value === "string" && LEASE_HANDLE_PATTERN.test(value); +} + +function isCanonicalPositiveU64(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length < 1 || + value.length > MAX_U64_DECIMAL_DIGITS || + !/^[1-9][0-9]*$/.test(value) + ) { + return false; + } + return BigInt(value) <= MAX_U64; +} + +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +function isSafeTargetLabel(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length < 1 || + hasRustWhitespaceEdge(value) || + [...value].length > MAX_TARGET_LABEL_CHARACTERS || + new TextEncoder().encode(value).byteLength > MAX_TARGET_LABEL_BYTES + ) { + return false; + } + for (const character of value) { + const code = character.codePointAt(0)!; + if ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + code === 0x061c || + code === 0x200e || + code === 0x200f || + (code >= 0xd800 && code <= 0xdfff) || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ) { + return false; + } + } + return true; +} + +function hasRustWhitespaceEdge(value: string): boolean { + return ( + isRustWhitespaceCodeUnit(value.charCodeAt(0)) || + isRustWhitespaceCodeUnit(value.charCodeAt(value.length - 1)) + ); +} + +// Rust str::trim follows Unicode White_Space. Keep this explicit because +// ECMAScript trim also removes U+FEFF, which native deliberately admits. +function isRustWhitespaceCodeUnit(code: number): boolean { + return ( + (code >= 0x0009 && code <= 0x000d) || + code === 0x0020 || + code === 0x0085 || + code === 0x00a0 || + code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || + code === 0x2029 || + code === 0x202f || + code === 0x205f || + code === 0x3000 + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + const allowed = new Set(allowedKeys); + return Reflect.ownKeys(value).every((key) => typeof key === "string" && allowed.has(key)); +} + +function isSafeToken(value: unknown, maxBytes: number): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= maxBytes && + /^[A-Za-z0-9._:-]+$/.test(value) + ); +} + +function isNullableSafeToken(value: unknown): boolean { + return value === undefined || value === null || isSafeToken(value, 512); +} diff --git a/frontend/src/services/agentNativePortableRuntimeController.test.ts b/frontend/src/services/agentNativePortableRuntimeController.test.ts new file mode 100644 index 000000000..b04b25344 --- /dev/null +++ b/frontend/src/services/agentNativePortableRuntimeController.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, mock, test } from "bun:test"; +import { AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES } from "@/services/agentRemoteCapabilities"; +import { isClosedAgentRemoteReadOnlyClient } from "@/services/agentRemoteProviderBridge"; +import { AgentNativePortableRuntimeController } from "@/services/agentNativePortableRuntimeController"; +import type { + AgentNativePortableBridge, + AgentNativePortableReadBinding, + AgentNativePortableRefreshResult, + AgentNativePortableWireLease +} from "@/services/agentNativePortableBridge"; + +const TARGET_A = `target_${"a".repeat(48)}`; +const TARGET_B = `target_${"b".repeat(48)}`; +const RUNTIME_A = `runtime_${"1".repeat(48)}`; +const LEASE_A = `lease_${"2".repeat(48)}`; +const ACCOUNT_A = "11111111-1111-1111-1111-111111111111"; +const ACCOUNT_B = "22222222-2222-2222-2222-222222222222"; + +function refresh( + runtimeId = RUNTIME_A, + items: AgentNativePortableRefreshResult["items"] = [{ handle: TARGET_A, label: "Office Mac" }] +): AgentNativePortableRefreshResult { + return { + schemaVersion: 1, + runtimeId, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + items + }; +} + +function lease(targetHandle = TARGET_A): AgentNativePortableWireLease { + return { + leaseHandle: LEASE_A, + targetHandle, + hostEpoch: "9", + connectionGeneration: 3 + }; +} + +function bridge(overrides: Partial = {}): AgentNativePortableBridge { + return { + refreshTargets: async () => refresh(), + prepareTarget: async (_accountId, _runtimeId, targetHandle) => lease(targetHandle), + getRuntimeStatus: async () => ({ running: true, activeRunCount: 1 }), + listSessionsPage: async () => ({ + items: [ + { + id: "session-a", + title: "Task", + createdMs: 1, + updatedMs: 2, + pageSortMs: 2, + messageCount: 1 + } + ], + nextCursor: "session-cursor-b" + }), + listRecordsPage: async () => ({ + records: [], + historyRevision: "revision-a", + nextCursor: null + }), + ...overrides + }; +} + +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("native portable Agent runtime controller", () => { + test("is inert until an exact account subscribes", async () => { + const refreshTargets = mock(async () => refresh()); + const controller = new AgentNativePortableRuntimeController(bridge({ refreshTargets })); + expect(controller.getSnapshot(ACCOUNT_A)).toBeNull(); + await flush(); + expect(refreshTargets).not.toHaveBeenCalled(); + }); + + test("rejects noncanonical and nil account subscriptions before bridge work", async () => { + const refreshTargets = mock(async () => refresh()); + const controller = new AgentNativePortableRuntimeController(bridge({ refreshTargets })); + const invalidAccounts = [ + "00000000-0000-0000-0000-000000000000", + "11111111-1111-1111-1111-11111111111A", + "account-a" + ]; + for (const accountId of invalidAccounts) { + const unsubscribe = controller.subscribe(accountId, () => {}); + expect(controller.getSnapshot(accountId)).toBeNull(); + unsubscribe(); + } + await flush(); + expect(refreshTargets).not.toHaveBeenCalled(); + }); + + test("requires explicit target selection before publishing a branded read-only client", async () => { + const prepareCalls: Array<[string, string, string]> = []; + const readBindings: AgentNativePortableReadBinding[] = []; + const sessionPages: unknown[] = []; + const recordPages: unknown[] = []; + const provider = bridge({ + prepareTarget: async (accountId, runtimeId, targetHandle) => { + prepareCalls.push([accountId, runtimeId, targetHandle]); + return lease(targetHandle); + }, + getRuntimeStatus: async (binding) => { + readBindings.push(binding); + return { running: true, activeRunCount: 0 }; + }, + listSessionsPage: async (binding, page) => { + readBindings.push(binding); + sessionPages.push(page); + return { items: [], nextCursor: null }; + }, + listRecordsPage: async (binding, page) => { + readBindings.push(binding); + recordPages.push(page); + return { records: [], historyRevision: "revision-a" }; + } + }); + const controller = new AgentNativePortableRuntimeController(provider); + const unsubscribe = controller.subscribe(ACCOUNT_A, () => {}); + expect(controller.getSnapshot(ACCOUNT_A)).toEqual({ + accountId: ACCOUNT_A, + status: "loading" + }); + await flush(); + + const selection = controller.getSnapshot(ACCOUNT_A); + expect(selection?.status).toBe("selectionRequired"); + if (selection?.status !== "selectionRequired") throw new Error("expected target selection"); + expect(prepareCalls).toEqual([]); + selection.targets[0].select(); + expect(controller.getSnapshot(ACCOUNT_A)?.status).toBe("loading"); + await flush(); + + const ready = controller.getSnapshot(ACCOUNT_A); + expect(ready?.status).toBe("readOnlyReady"); + if (ready?.status !== "readOnlyReady") throw new Error("expected read-only readiness"); + expect(isClosedAgentRemoteReadOnlyClient(ready.client)).toBe(true); + expect(ready.client.binding).toEqual({ + accountId: ACCOUNT_A, + targetId: TARGET_A, + targetLabel: "Office Mac" + }); + expect(JSON.parse(ready.runtimeKey)).toEqual([RUNTIME_A, LEASE_A, TARGET_A, "9", 3]); + expect(prepareCalls).toEqual([[ACCOUNT_A, RUNTIME_A, TARGET_A]]); + + await ready.client.getRuntimeStatus(); + await ready.client.listSessionSummariesPage({ cursor: "session-cursor-a", limit: 5 }); + await ready.client.listPersistedRecordsPage({ + sessionId: "session-a", + cursor: "history-cursor-a", + limit: 6 + }); + expect(readBindings).toEqual([ + { + accountId: ACCOUNT_A, + runtimeId: RUNTIME_A, + lease: lease() + }, + { + accountId: ACCOUNT_A, + runtimeId: RUNTIME_A, + lease: lease() + }, + { + accountId: ACCOUNT_A, + runtimeId: RUNTIME_A, + lease: lease() + } + ]); + expect(sessionPages).toEqual([{ cursor: "session-cursor-a", limit: 5 }]); + expect(recordPages).toEqual([{ sessionId: "session-a", cursor: "history-cursor-a", limit: 6 }]); + unsubscribe(); + }); + + test("maps an empty verified roster and refresh failure to closed unavailable states", async () => { + const noTargets = new AgentNativePortableRuntimeController( + bridge({ refreshTargets: async () => refresh(RUNTIME_A, []) }) + ); + const stopNoTargets = noTargets.subscribe(ACCOUNT_A, () => {}); + await flush(); + expect(noTargets.getSnapshot(ACCOUNT_A)).toEqual({ + accountId: ACCOUNT_A, + status: "unavailable", + reason: "noPairedHost" + }); + stopNoTargets(); + + const failed = new AgentNativePortableRuntimeController( + bridge({ refreshTargets: async () => Promise.reject(new Error("native secret")) }) + ); + const stopFailed = failed.subscribe(ACCOUNT_A, () => {}); + await flush(); + expect(failed.getSnapshot(ACCOUNT_A)).toEqual({ + accountId: ACCOUNT_A, + status: "unavailable", + reason: "pairingUnavailable" + }); + stopFailed(); + }); + + test("ignores a late account-A refresh after account B replaces its lane", async () => { + let resolveA!: (value: AgentNativePortableRefreshResult) => void; + const pendingA = new Promise((resolve) => { + resolveA = resolve; + }); + const refreshTargets = mock(async (accountId: string) => { + if (accountId === ACCOUNT_A) return await pendingA; + return refresh(`runtime_${"4".repeat(48)}`, [{ handle: TARGET_B, label: "Laptop" }]); + }); + const controller = new AgentNativePortableRuntimeController(bridge({ refreshTargets })); + const stopA = controller.subscribe(ACCOUNT_A, () => {}); + await flush(); + const stopB = controller.subscribe(ACCOUNT_B, () => {}); + await flush(); + expect(controller.getSnapshot(ACCOUNT_A)).toBeNull(); + expect(controller.getSnapshot(ACCOUNT_B)?.status).toBe("selectionRequired"); + resolveA(refresh()); + await flush(); + expect(controller.getSnapshot(ACCOUNT_B)?.status).toBe("selectionRequired"); + stopA(); + stopB(); + }); + + test("A to signed-out to the same A requires a fresh refresh and fences the old client", async () => { + const status = mock(async () => ({ running: true, activeRunCount: 0 })); + const refreshTargets = mock(async () => refresh()); + const controller = new AgentNativePortableRuntimeController( + bridge({ refreshTargets, getRuntimeStatus: status }) + ); + const stopFirst = controller.subscribe(ACCOUNT_A, () => {}); + await flush(); + const firstSelection = controller.getSnapshot(ACCOUNT_A); + if (firstSelection?.status !== "selectionRequired") throw new Error("expected selection"); + firstSelection.targets[0].select(); + await flush(); + const firstReady = controller.getSnapshot(ACCOUNT_A); + if (firstReady?.status !== "readOnlyReady") throw new Error("expected readiness"); + + stopFirst(); + expect(controller.getSnapshot(ACCOUNT_A)).toBeNull(); + await expect(firstReady.client.getRuntimeStatus()).rejects.toThrow("no longer current"); + expect(status).not.toHaveBeenCalled(); + + const stopSecond = controller.subscribe(ACCOUNT_A, () => {}); + expect(controller.getSnapshot(ACCOUNT_A)?.status).toBe("loading"); + expect(refreshTargets).toHaveBeenCalledTimes(1); + await flush(); + expect(refreshTargets).toHaveBeenCalledTimes(2); + expect(controller.getSnapshot(ACCOUNT_A)?.status).toBe("selectionRequired"); + stopSecond(); + }); + + test("fails closed when prepare returns a lease for another target", async () => { + const controller = new AgentNativePortableRuntimeController( + bridge({ prepareTarget: async () => lease(TARGET_B) }) + ); + const stop = controller.subscribe(ACCOUNT_A, () => {}); + await flush(); + const selection = controller.getSnapshot(ACCOUNT_A); + if (selection?.status !== "selectionRequired") throw new Error("expected selection"); + selection.targets[0].select(); + await flush(); + expect(controller.getSnapshot(ACCOUNT_A)).toEqual({ + accountId: ACCOUNT_A, + status: "unavailable", + reason: "pairingUnavailable" + }); + stop(); + }); +}); diff --git a/frontend/src/services/agentNativePortableRuntimeController.ts b/frontend/src/services/agentNativePortableRuntimeController.ts new file mode 100644 index 000000000..f6670b39a --- /dev/null +++ b/frontend/src/services/agentNativePortableRuntimeController.ts @@ -0,0 +1,248 @@ +import type { AgentPortableRuntimeController } from "@/contexts/AgentPortableRuntimeControllerProvider"; +import { + createAgentRemoteReadOnlyClient, + type AgentRemoteReadOnlyClient +} from "@/services/agentRemoteProviderBridge"; +import type { AgentPortableRuntimeState } from "@/services/agentRouteRuntime"; +import { + createAgentNativePortableReadOnlySource, + isAgentNativePortableAccountId, + tauriAgentNativePortableBridge, + type AgentNativePortableBridge, + type AgentNativePortableRefreshResult, + type AgentNativePortableTarget, + type AgentNativePortableWireLease +} from "@/services/agentNativePortableBridge"; + +const MAX_RUNTIME_KEY_LENGTH = 256; + +interface PortableRuntimeLane { + readonly identity: object; + readonly accountId: string; + readonly listeners: Set<() => void>; + operationRevision: number; + activeBindingKey: string | null; + snapshot: AgentPortableRuntimeState | null; +} + +/** + * Account-scoped controller for the persisted-only native provider. Constructing + * it is inert: native work begins only after an authenticated account subscribes. + * Maple intentionally does not install this controller in App yet. + */ +export class AgentNativePortableRuntimeController implements AgentPortableRuntimeController { + private lane: PortableRuntimeLane | null = null; + + constructor( + private readonly bridge: AgentNativePortableBridge = tauriAgentNativePortableBridge + ) {} + + getSnapshot(accountId: string): AgentPortableRuntimeState | null { + const lane = this.lane; + return lane && lane.accountId === accountId && lane.listeners.size > 0 ? lane.snapshot : null; + } + + subscribe(accountId: string, listener: () => void): () => void { + if (!isAgentNativePortableAccountId(accountId) || typeof listener !== "function") { + return () => {}; + } + + let lane = this.lane; + if (!lane || lane.accountId !== accountId || lane.listeners.size === 0) { + const retiredListeners = lane ? [...lane.listeners] : []; + if (lane) { + lane.operationRevision += 1; + lane.activeBindingKey = null; + lane.snapshot = null; + } + lane = { + identity: Object.freeze({}), + accountId, + listeners: new Set(), + operationRevision: 1, + activeBindingKey: null, + snapshot: Object.freeze({ accountId, status: "loading" }) + }; + this.lane = lane; + for (const retired of retiredListeners) safeNotify(retired); + const identity = lane.identity; + const revision = lane.operationRevision; + queueMicrotask(() => void this.refreshTargets(identity, revision)); + } + + const subscribedLane = lane; + subscribedLane.listeners.add(listener); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + subscribedLane.listeners.delete(listener); + if (this.lane === subscribedLane && subscribedLane.listeners.size === 0) { + subscribedLane.operationRevision += 1; + subscribedLane.activeBindingKey = null; + subscribedLane.snapshot = null; + } + }; + } + + private async refreshTargets(identity: object, revision: number): Promise { + const lane = this.currentLane(identity, revision); + if (!lane) return; + try { + const refreshed = await this.bridge.refreshTargets(lane.accountId); + const current = this.currentLane(identity, revision); + if (!current) return; + if (refreshed.items.length === 0) { + this.publish(current, { + accountId: current.accountId, + status: "unavailable", + reason: "noPairedHost" + }); + return; + } + const targets = refreshed.items.map((target) => + Object.freeze({ + key: target.handle, + label: target.label, + select: () => this.selectTarget(identity, revision, refreshed, target) + }) + ); + this.publish(current, { + accountId: current.accountId, + status: "selectionRequired", + targets: Object.freeze(targets) + }); + } catch { + const current = this.currentLane(identity, revision); + if (current) { + this.publish(current, { + accountId: current.accountId, + status: "unavailable", + reason: "pairingUnavailable" + }); + } + } + } + + private selectTarget( + identity: object, + refreshRevision: number, + refreshed: AgentNativePortableRefreshResult, + target: AgentNativePortableTarget + ): void { + const lane = this.currentLane(identity, refreshRevision); + if (!lane || lane.snapshot?.status !== "selectionRequired") return; + if (!refreshed.items.some((candidate) => candidate.handle === target.handle)) return; + + lane.operationRevision += 1; + const prepareRevision = lane.operationRevision; + lane.activeBindingKey = null; + this.publish(lane, { accountId: lane.accountId, status: "loading" }); + void this.prepareTarget(identity, prepareRevision, refreshed, target); + } + + private async prepareTarget( + identity: object, + revision: number, + refreshed: AgentNativePortableRefreshResult, + target: AgentNativePortableTarget + ): Promise { + const lane = this.currentLane(identity, revision); + if (!lane) return; + try { + const lease = await this.bridge.prepareTarget( + lane.accountId, + refreshed.runtimeId, + target.handle + ); + const current = this.currentLane(identity, revision); + if (!current) return; + if (lease.targetHandle !== target.handle) { + throw new Error("Paired-host Agent lease belongs to another target"); + } + + const bindingKey = portableBindingKey(refreshed.runtimeId, lease); + current.activeBindingKey = bindingKey; + const assertCurrent = () => { + const active = this.currentLane(identity, revision); + if (!active || active.activeBindingKey !== bindingKey) { + throw new Error("Paired-host Agent binding is no longer current"); + } + }; + const source = createAgentNativePortableReadOnlySource( + this.bridge, + { + accountId: current.accountId, + runtimeId: refreshed.runtimeId, + lease + }, + assertCurrent + ); + const client: AgentRemoteReadOnlyClient = createAgentRemoteReadOnlyClient({ + accountId: current.accountId, + targetId: lease.targetHandle, + targetLabel: target.label, + capabilities: refreshed.capabilities, + source + }); + assertCurrent(); + this.publish(current, { + accountId: current.accountId, + status: "readOnlyReady", + client, + capabilities: refreshed.capabilities, + runtimeKey: bindingKey + }); + } catch { + const current = this.currentLane(identity, revision); + if (current) { + current.activeBindingKey = null; + this.publish(current, { + accountId: current.accountId, + status: "unavailable", + reason: "pairingUnavailable" + }); + } + } + } + + private currentLane(identity: object, revision: number): PortableRuntimeLane | null { + const lane = this.lane; + return lane && + lane.identity === identity && + lane.operationRevision === revision && + lane.listeners.size > 0 + ? lane + : null; + } + + private publish(lane: PortableRuntimeLane, snapshot: AgentPortableRuntimeState): void { + if (this.lane !== lane || snapshot.accountId !== lane.accountId || lane.listeners.size === 0) { + return; + } + lane.snapshot = Object.freeze(snapshot); + for (const listener of [...lane.listeners]) safeNotify(listener); + } +} + +function portableBindingKey(runtimeId: string, lease: AgentNativePortableWireLease): string { + const key = JSON.stringify([ + runtimeId, + lease.leaseHandle, + lease.targetHandle, + lease.hostEpoch, + lease.connectionGeneration + ]); + if (key.length === 0 || key.length > MAX_RUNTIME_KEY_LENGTH) { + throw new Error("Paired-host Agent lifecycle identity is invalid"); + } + return key; +} + +function safeNotify(listener: () => void): void { + try { + listener(); + } catch { + // A consumer cannot prevent other account-scoped observers from fencing. + } +} diff --git a/frontend/src/services/agentRemoteCapabilities.test.ts b/frontend/src/services/agentRemoteCapabilities.test.ts new file mode 100644 index 000000000..a3e566612 --- /dev/null +++ b/frontend/src/services/agentRemoteCapabilities.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + decodeAgentRemoteCapabilitySnapshot, + isAgentRemotePersistedTranscriptReady, + sameAgentRemoteCapabilitySnapshot +} from "@/services/agentRemoteCapabilities"; + +describe("Agent remote transcript capabilities", () => { + test("admits only the exact persisted-transcript grant", () => { + expect( + isAgentRemotePersistedTranscriptReady(AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES) + ).toBe(true); + expect( + isAgentRemotePersistedTranscriptReady({ + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + persistedRecordsPage: false + }) + ).toBe(false); + expect( + isAgentRemotePersistedTranscriptReady({ + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + synchronizedLiveTail: true + }) + ).toBe(false); + expect( + isAgentRemotePersistedTranscriptReady({ + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + mutations: true + }) + ).toBe(false); + }); + + test("rejects missing, non-boolean, and extension fields", () => { + const missing: Record = { + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES + }; + Reflect.deleteProperty(missing, "mutations"); + expect(decodeAgentRemoteCapabilitySnapshot(missing)).toBeNull(); + expect( + decodeAgentRemoteCapabilitySnapshot({ + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + runtimeStatus: "yes" + }) + ).toBeNull(); + expect( + decodeAgentRemoteCapabilitySnapshot({ + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + sendMessage: true + }) + ).toBeNull(); + + const hiddenExtension = { ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES }; + Object.defineProperty(hiddenExtension, "sendMessage", { value: true }); + expect(decodeAgentRemoteCapabilitySnapshot(hiddenExtension)).toBeNull(); + + const symbolExtension = { ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES }; + Object.defineProperty(symbolExtension, Symbol("sendMessage"), { value: true }); + expect(decodeAgentRemoteCapabilitySnapshot(symbolExtension)).toBeNull(); + }); + + test("returns a frozen closed copy instead of retaining provider data", () => { + const input = { ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES }; + const decoded = decodeAgentRemoteCapabilitySnapshot(input); + expect(decoded).not.toBe(input); + expect(Object.isFrozen(decoded)).toBe(true); + expect( + sameAgentRemoteCapabilitySnapshot(decoded!, AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES) + ).toBe(true); + }); +}); diff --git a/frontend/src/services/agentRemoteCapabilities.ts b/frontend/src/services/agentRemoteCapabilities.ts new file mode 100644 index 000000000..944e4e91e --- /dev/null +++ b/frontend/src/services/agentRemoteCapabilities.ts @@ -0,0 +1,85 @@ +/** + * Closed presentation grant for the first paired-host transcript browser. + * + * This is not a host feature inventory and is never authorization by itself. + * An authoritative paired-target provider may publish this snapshot only after + * it has authenticated the exact account and target binding. Keeping mutation + * and live-tail grants explicit prevents partial support from being mistaken + * for full remote Agent Mode. + */ +export interface AgentRemoteCapabilitySnapshot { + readonly runtimeStatus: boolean; + readonly sessionSummariesPage: boolean; + readonly persistedRecordsPage: boolean; + readonly synchronizedLiveTail: boolean; + readonly mutations: boolean; +} + +const AGENT_REMOTE_CAPABILITY_KEYS = [ + "runtimeStatus", + "sessionSummariesPage", + "persistedRecordsPage", + "synchronizedLiveTail", + "mutations" +] as const; + +export const AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES: AgentRemoteCapabilitySnapshot = + Object.freeze({ + runtimeStatus: true, + sessionSummariesPage: true, + persistedRecordsPage: true, + synchronizedLiveTail: false, + mutations: false + }); + +/** Decode a closed snapshot without retaining provider-owned extension data. */ +export function decodeAgentRemoteCapabilitySnapshot( + value: unknown +): AgentRemoteCapabilitySnapshot | null { + if (!isRecord(value)) return null; + if ( + Reflect.ownKeys(value).length !== AGENT_REMOTE_CAPABILITY_KEYS.length || + !AGENT_REMOTE_CAPABILITY_KEYS.every( + (key) => Object.prototype.hasOwnProperty.call(value, key) && typeof value[key] === "boolean" + ) + ) { + return null; + } + + return Object.freeze({ + runtimeStatus: value.runtimeStatus as boolean, + sessionSummariesPage: value.sessionSummariesPage as boolean, + persistedRecordsPage: value.persistedRecordsPage as boolean, + synchronizedLiveTail: value.synchronizedLiveTail as boolean, + mutations: value.mutations as boolean + }); +} + +/** + * Phase 1 is deliberately persisted-history-only. A live or mutation grant is + * rejected instead of being silently ignored or routed to full Agent Mode. + */ +export function isAgentRemotePersistedTranscriptReady( + value: unknown +): value is AgentRemoteCapabilitySnapshot { + const snapshot = decodeAgentRemoteCapabilitySnapshot(value); + return ( + snapshot !== null && + snapshot.runtimeStatus && + snapshot.sessionSummariesPage && + snapshot.persistedRecordsPage && + !snapshot.synchronizedLiveTail && + !snapshot.mutations + ); +} + +export function sameAgentRemoteCapabilitySnapshot( + left: AgentRemoteCapabilitySnapshot, + right: AgentRemoteCapabilitySnapshot +): boolean { + return AGENT_REMOTE_CAPABILITY_KEYS.every((key) => left[key] === right[key]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/frontend/src/services/agentRemoteProviderBridge.test.ts b/frontend/src/services/agentRemoteProviderBridge.test.ts new file mode 100644 index 000000000..d65f16af2 --- /dev/null +++ b/frontend/src/services/agentRemoteProviderBridge.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, mock, test } from "bun:test"; +import { AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES } from "@/services/agentRemoteCapabilities"; +import { + createAgentRemoteReadOnlyClient, + decodeAgentRemoteRecordsPage, + decodeAgentRemoteRuntimeSummary, + decodeAgentRemoteSessionPage, + isClosedAgentRemoteReadOnlyClient, + type AgentRemoteHistoryRecord, + type AgentRemotePageRequest, + type AgentRemoteRecordsPage, + type AgentRemoteReadOnlySource, + type AgentRemoteRecordsPageRequest +} from "@/services/agentRemoteProviderBridge"; + +const ACCOUNT_ID = "account-a"; +const TARGET_ID = `target_${"a".repeat(48)}`; +const MAX_REMOTE_HISTORY_RECORD_JSON_BYTES = 1_040_384; +const MAX_REMOTE_TIMELINE_TEXT_BYTES = 192 * 1_024; + +function sessionSummary(id = "session-a") { + return { + id, + title: "Paired task", + createdMs: 1, + updatedMs: 2, + pageSortMs: 2, + messageCount: 1 + }; +} + +function recordsPage(): AgentRemoteRecordsPage { + return { + records: [ + { + recordId: "record-a", + role: "assistant", + createdMs: 3, + items: [ + { + id: "message-a", + itemType: "message", + role: "assistant", + text: "Safe persisted text", + createdMs: 3, + merge: "replace" + } + ] + } + ], + nextCursor: "history-cursor-b", + historyRevision: "history-revision-a" + }; +} + +function historyRecordWithExactJsonBytes(targetBytes: number): AgentRemoteHistoryRecord { + const items = Array.from({ length: 6 }, (_, index) => ({ + id: `boundary-message-${index}`, + itemType: "message" as const, + role: "assistant" as const, + text: "", + createdMs: index, + merge: "replace" as const + })); + const record = { + recordId: "record-boundary", + role: "assistant", + createdMs: 0, + items + }; + let remaining = targetBytes - jsonByteLength(record); + if (remaining < 0) throw new Error("history record boundary fixture is too small"); + for (const item of items) { + const itemBytes = Math.min(remaining, MAX_REMOTE_TIMELINE_TEXT_BYTES); + item.text = "x".repeat(itemBytes); + remaining -= itemBytes; + } + if (remaining !== 0 || jsonByteLength(record) !== targetBytes) { + throw new Error("history record boundary fixture cannot reach the requested byte count"); + } + return record; +} + +function jsonByteLength(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + +function source(overrides: Partial = {}): AgentRemoteReadOnlySource { + return { + getRuntimeStatus: async () => ({ running: true, activeRunCount: 1 }), + listSessionSummariesPage: async () => ({ + items: [sessionSummary()], + nextCursor: "session-cursor-b" + }), + listPersistedRecordsPage: async () => recordsPage(), + ...overrides + }; +} + +function client(remoteSource: AgentRemoteReadOnlySource = source()) { + return createAgentRemoteReadOnlyClient({ + accountId: ACCOUNT_ID, + targetId: TARGET_ID, + targetLabel: "Office Mac", + source: remoteSource, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES + }); +} + +describe("Agent remote read-only provider bridge", () => { + test("exposes only the three authenticated read methods and sanitized binding", () => { + const readOnlyClient = client(); + expect(Object.keys(readOnlyClient).sort()).toEqual( + [ + "binding", + "capabilities", + "getRuntimeStatus", + "listPersistedRecordsPage", + "listSessionSummariesPage" + ].sort() + ); + expect(readOnlyClient.binding).toEqual({ + accountId: ACCOUNT_ID, + targetId: TARGET_ID, + targetLabel: "Office Mac" + }); + expect("invoke" in readOnlyClient).toBe(false); + expect("sendMessage" in readOnlyClient).toBe(false); + expect("startRuntime" in readOnlyClient).toBe(false); + expect("subscribeToEvents" in readOnlyClient).toBe(false); + expect(isClosedAgentRemoteReadOnlyClient(readOnlyClient)).toBe(true); + }); + + test("forwards only bounded requests and preserves opaque cursors", async () => { + const sessionCalls: AgentRemotePageRequest[] = []; + const recordCalls: AgentRemoteRecordsPageRequest[] = []; + const readOnlyClient = client( + source({ + listSessionSummariesPage: async (request) => { + sessionCalls.push(request); + return { items: [sessionSummary()], nextCursor: "session-cursor-b" }; + }, + listPersistedRecordsPage: async (request) => { + recordCalls.push(request); + return recordsPage(); + } + }) + ); + + expect(await readOnlyClient.getRuntimeStatus()).toEqual({ + running: true, + activeRunCount: 1 + }); + expect( + await readOnlyClient.listSessionSummariesPage({ cursor: "session-cursor-a", limit: 7 }) + ).toEqual({ items: [sessionSummary()], nextCursor: "session-cursor-b" }); + expect( + await readOnlyClient.listPersistedRecordsPage({ + sessionId: "session-a", + cursor: "history-cursor-a", + limit: 9 + }) + ).toEqual(recordsPage()); + expect(sessionCalls).toEqual([{ cursor: "session-cursor-a", limit: 7 }]); + expect(recordCalls).toEqual([{ sessionId: "session-a", cursor: "history-cursor-a", limit: 9 }]); + }); + + test("rejects invalid bindings and incomplete or widened capability grants", () => { + expect(() => + createAgentRemoteReadOnlyClient({ + accountId: ACCOUNT_ID, + targetId: "local", + source: source(), + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES + }) + ).toThrow("remote target binding"); + expect(() => + createAgentRemoteReadOnlyClient({ + accountId: ACCOUNT_ID, + targetId: TARGET_ID, + targetLabel: "Trusted\u202ehost", + source: source(), + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES + }) + ).toThrow("target label is invalid"); + expect(() => + createAgentRemoteReadOnlyClient({ + accountId: ACCOUNT_ID, + targetId: TARGET_ID, + source: source(), + capabilities: { + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + persistedRecordsPage: false + } + }) + ).toThrow("unavailable or too broad"); + expect(() => + createAgentRemoteReadOnlyClient({ + accountId: ACCOUNT_ID, + targetId: TARGET_ID, + source: source(), + capabilities: { + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + mutations: true + } + }) + ).toThrow("unavailable or too broad"); + }); + + test("rejects hidden request extensions before calling the source", async () => { + const list = mock(async () => ({ items: [] })); + const readOnlyClient = client(source({ listSessionSummariesPage: list })); + await expect( + readOnlyClient.listSessionSummariesPage({ + cursor: null, + limit: 5, + projectRoot: "/must-not-cross" + } as AgentRemotePageRequest) + ).rejects.toThrow("request is invalid"); + expect(list).not.toHaveBeenCalled(); + }); + + test("enforces closed runtime status and its consistency bound", async () => { + expect(decodeAgentRemoteRuntimeSummary({ running: false, activeRunCount: 0 })).toEqual({ + running: false, + activeRunCount: 0 + }); + expect(decodeAgentRemoteRuntimeSummary({ running: false, activeRunCount: 1 })).toBeNull(); + expect(decodeAgentRemoteRuntimeSummary({ running: true, activeRunCount: 65 })).toBeNull(); + expect( + decodeAgentRemoteRuntimeSummary({ + running: true, + activeRunCount: 1, + activeRuns: { secret: "run" } + }) + ).toBeNull(); + await expect( + client( + source({ getRuntimeStatus: async () => ({ running: true, activeRunCount: 65 }) }) + ).getRuntimeStatus() + ).rejects.toThrow("response is invalid"); + }); + + test("admits only the six-field session summary and rejects duplicates", () => { + const request = { cursor: null, limit: 5 }; + const decoded = decodeAgentRemoteSessionPage({ items: [sessionSummary()] }, request); + expect(decoded).toEqual({ items: [sessionSummary()] }); + expect("projectRoot" in decoded!.items[0]).toBe(false); + expect( + decodeAgentRemoteSessionPage( + { items: [{ ...sessionSummary(), projectRoot: "/remote/private" }] }, + request + ) + ).toBeNull(); + expect( + decodeAgentRemoteSessionPage({ items: [sessionSummary(), sessionSummary()] }, request) + ).toBeNull(); + }); + + test("rejects raw tool payloads and unknown record extensions", () => { + const request = { sessionId: "session-a", cursor: null, limit: 5 }; + const unsafeInput = recordsPage(); + unsafeInput.records[0].items[0] = { + ...unsafeInput.records[0].items[0], + input: { token: "secret" } + } as (typeof unsafeInput.records)[number]["items"][number]; + expect(decodeAgentRemoteRecordsPage(unsafeInput, request)).toBeNull(); + expect( + decodeAgentRemoteRecordsPage({ ...recordsPage(), providerPrivateField: "secret" }, request) + ).toBeNull(); + }); + + test("rejects duplicate history record IDs directly", () => { + const page = recordsPage(); + expect( + decodeAgentRemoteRecordsPage( + { ...page, records: [page.records[0], { ...page.records[0] }] }, + { sessionId: "session-a", cursor: null, limit: 5 } + ) + ).toBeNull(); + }); + + test("admits exactly N JSON bytes per record and rejects N plus one", () => { + const request = { sessionId: "session-a", cursor: null, limit: 5 }; + const atLimit = historyRecordWithExactJsonBytes(MAX_REMOTE_HISTORY_RECORD_JSON_BYTES); + const aboveLimit = historyRecordWithExactJsonBytes(MAX_REMOTE_HISTORY_RECORD_JSON_BYTES + 1); + expect(jsonByteLength(atLimit)).toBe(MAX_REMOTE_HISTORY_RECORD_JSON_BYTES); + expect(jsonByteLength(aboveLimit)).toBe(MAX_REMOTE_HISTORY_RECORD_JSON_BYTES + 1); + expect( + decodeAgentRemoteRecordsPage({ records: [atLimit], historyRevision: "revision-a" }, request) + ).not.toBeNull(); + expect( + decodeAgentRemoteRecordsPage( + { records: [aboveLimit], historyRevision: "revision-a" }, + request + ) + ).toBeNull(); + }); + + test("enforces safe remote tool, permission, and error projections", () => { + const request = { sessionId: "session-a", limit: 5 }; + const page: AgentRemoteRecordsPage = { + ...recordsPage(), + records: [ + { + ...recordsPage().records[0], + items: [ + { + id: "tool-a", + itemType: "tool", + role: "assistant", + title: "Tool activity", + status: "failed", + text: "The tool failed. Open the host for additional diagnostic details.", + createdMs: 3, + merge: "replace" + } + ] + } + ] + }; + expect(decodeAgentRemoteRecordsPage(page, request)).not.toBeNull(); + expect( + decodeAgentRemoteRecordsPage( + { + ...page, + records: [ + { + ...page.records[0], + items: [{ ...page.records[0].items[0], text: "raw stderr and credentials" }] + } + ] + }, + request + ) + ).toBeNull(); + }); + + test("rejects a valid-shape history page beyond the cumulative 8 MiB ledger", () => { + const text = "x".repeat(180 * 1_024); + const records = Array.from({ length: 47 }, (_, index) => ({ + recordId: `record-${index}`, + role: "assistant", + createdMs: index, + items: [ + { + id: `message-${index}`, + itemType: "message", + role: "assistant", + text, + createdMs: index, + merge: "replace" + } + ] + })); + expect( + decodeAgentRemoteRecordsPage( + { records, historyRevision: "revision-a" }, + { sessionId: "session-a", limit: 50 } + ) + ).toBeNull(); + }); + + test("rejects clients that retain extension or generic invoke fields", () => { + const readOnlyClient = client(); + expect(isClosedAgentRemoteReadOnlyClient({ ...readOnlyClient })).toBe(false); + expect(isClosedAgentRemoteReadOnlyClient({ ...readOnlyClient, invoke: () => {} })).toBe(false); + }); +}); diff --git a/frontend/src/services/agentRemoteProviderBridge.ts b/frontend/src/services/agentRemoteProviderBridge.ts new file mode 100644 index 000000000..8f7a69c61 --- /dev/null +++ b/frontend/src/services/agentRemoteProviderBridge.ts @@ -0,0 +1,622 @@ +import { + decodeAgentRemoteCapabilitySnapshot, + isAgentRemotePersistedTranscriptReady, + type AgentRemoteCapabilitySnapshot +} from "@/services/agentRemoteCapabilities"; + +const MAX_REMOTE_AGENT_ACTIVE_RUNS = 64; +const MAX_REMOTE_AGENT_PAGE_SIZE = 50; +const MAX_REMOTE_AGENT_CURSOR_BYTES = 512; +const MAX_REMOTE_AGENT_ID_BYTES = 128; +const MAX_REMOTE_AGENT_TITLE_BYTES = 1_024; +const MAX_REMOTE_AGENT_STATUS_BYTES = 64; +const MAX_REMOTE_AGENT_TEXT_BYTES = 192 * 1_024; +const MAX_REMOTE_AGENT_HISTORY_ITEMS_PER_RECORD = 200; +const MAX_REMOTE_AGENT_HISTORY_ROLE_BYTES = 128; +const MAX_REMOTE_AGENT_HISTORY_RECORD_BYTES = 1_040_384; +const MAX_REMOTE_AGENT_HISTORY_PAGE_BYTES = 8 * 1_024 * 1_024; +const SAFE_REMOTE_TOOL_TITLE = "Tool activity"; +const SAFE_REMOTE_TOOL_FAILED = "The tool failed. Open the host for additional diagnostic details."; +const SAFE_REMOTE_TOOL_CANCELLED = "The tool was cancelled."; +const SAFE_REMOTE_PERMISSION_TITLE = "Tool permission"; +const SAFE_REMOTE_AGENT_ERROR = + "The Agent task failed. Open the host for additional diagnostic details."; +const SAFE_REMOTE_TOKEN_PATTERN = /^[A-Za-z0-9._:-]+$/; +const agentRemoteReadOnlyClients = new WeakSet(); + +export interface AgentRemoteAuthenticatedBinding { + readonly accountId: string; + readonly targetId: string; + readonly targetLabel?: string; +} + +/** Closed runtime status projection; host run IDs and provider fields stay out. */ +export interface AgentRemoteRuntimeSummary { + readonly running: boolean; + readonly activeRunCount: number; +} + +/** Persisted task metadata admitted to the paired-host presentation. */ +export interface AgentRemoteSessionSummary { + readonly id: string; + readonly title: string; + readonly createdMs: number; + readonly updatedMs: number; + readonly pageSortMs: number; + readonly messageCount: number; +} + +export interface AgentRemoteTimelineItem { + readonly id: string; + readonly itemType: "message" | "thinking" | "tool" | "permission" | "system" | "error"; + readonly role?: "user" | "assistant" | "thought" | "system"; + readonly title?: string; + readonly text?: string; + readonly status?: string; + readonly createdMs: number; + readonly merge: "append" | "replace"; +} + +export interface AgentRemoteHistoryRecord { + readonly recordId: string; + readonly role: string; + readonly createdMs: number; + readonly items: AgentRemoteTimelineItem[]; +} + +export interface AgentRemotePageRequest { + readonly cursor?: string | null; + readonly limit: number; +} + +export interface AgentRemoteRecordsPageRequest extends AgentRemotePageRequest { + readonly sessionId: string; +} + +export interface AgentRemoteSessionPage { + readonly items: AgentRemoteSessionSummary[]; + readonly nextCursor?: string | null; +} + +export interface AgentRemoteRecordsPage { + readonly records: AgentRemoteHistoryRecord[]; + readonly historyRevision: string; + readonly nextCursor?: string | null; +} + +/** + * Named data source captured by the branded presentation client. This is not a + * generic command surface and accepts neither an account nor an execution + * operation from component input. + */ +export interface AgentRemoteReadOnlySource { + getRuntimeStatus(): Promise; + listSessionSummariesPage(request: AgentRemotePageRequest): Promise; + listPersistedRecordsPage(request: AgentRemoteRecordsPageRequest): Promise; +} + +/** + * The only remote operations exposed to the Phase 1 presentation. There is no + * generic invoke, composer, mutation, permission, administration, or live API. + */ +export interface AgentRemoteReadOnlyClient { + readonly binding: AgentRemoteAuthenticatedBinding; + readonly capabilities: AgentRemoteCapabilitySnapshot; + getRuntimeStatus(): Promise; + listSessionSummariesPage(request: AgentRemotePageRequest): Promise; + listPersistedRecordsPage(request: AgentRemoteRecordsPageRequest): Promise; +} + +export interface CreateAgentRemoteReadOnlyClientOptions { + readonly accountId: string; + readonly targetId: string; + readonly targetLabel?: string; + readonly source: AgentRemoteReadOnlySource; + readonly capabilities: unknown; +} + +const AGENT_REMOTE_READ_ONLY_CLIENT_KEYS = [ + "binding", + "capabilities", + "getRuntimeStatus", + "listSessionSummariesPage", + "listPersistedRecordsPage" +] as const; + +export function isClosedAgentRemoteReadOnlyClient( + value: unknown +): value is AgentRemoteReadOnlyClient { + if ( + !isRecord(value) || + !agentRemoteReadOnlyClients.has(value) || + !hasOnlyKeys(value, AGENT_REMOTE_READ_ONLY_CLIENT_KEYS) + ) { + return false; + } + if ( + typeof value.getRuntimeStatus !== "function" || + typeof value.listSessionSummariesPage !== "function" || + typeof value.listPersistedRecordsPage !== "function" || + !isAgentRemotePersistedTranscriptReady(value.capabilities) || + !isRecord(value.binding) || + !hasOnlyKeys(value.binding, ["accountId", "targetId", "targetLabel"]) + ) { + return false; + } + return ( + typeof value.binding.accountId === "string" && + isBoundedOwnerId(value.binding.accountId) && + typeof value.binding.targetId === "string" && + isRemoteTargetId(value.binding.targetId) && + (value.binding.targetLabel === undefined || + (typeof value.binding.targetLabel === "string" && + isSafeDisplayText(value.binding.targetLabel, 256, false))) + ); +} + +/** + * Brand a provider-owned, account/target-bound source as the closed Phase 1 + * client. Every request and result is reconstructed through the sanitized DTO + * boundary; source-owned objects and extension fields never escape. + */ +export function createAgentRemoteReadOnlyClient({ + accountId, + targetId, + targetLabel, + source, + capabilities +}: CreateAgentRemoteReadOnlyClientOptions): AgentRemoteReadOnlyClient { + if (!isBoundedOwnerId(accountId)) { + throw new Error("Remote Agent transcript access requires a bounded account binding"); + } + if (!isRemoteTargetId(targetId)) { + throw new Error("Remote Agent transcript access requires a bounded remote target binding"); + } + if (targetLabel !== undefined && !isSafeDisplayText(targetLabel, 256, false)) { + throw new Error("Remote Agent transcript target label is invalid"); + } + if ( + !source || + typeof source.getRuntimeStatus !== "function" || + typeof source.listSessionSummariesPage !== "function" || + typeof source.listPersistedRecordsPage !== "function" + ) { + throw new Error("Remote Agent transcript source is incomplete"); + } + if (!isAgentRemotePersistedTranscriptReady(capabilities)) { + throw new Error("Remote Agent transcript capabilities are unavailable or too broad"); + } + + const decodedCapabilities = decodeAgentRemoteCapabilitySnapshot(capabilities)!; + const binding: AgentRemoteAuthenticatedBinding = Object.freeze({ + accountId, + targetId, + ...(targetLabel ? { targetLabel } : {}) + }); + + const client: AgentRemoteReadOnlyClient = Object.freeze({ + binding, + capabilities: decodedCapabilities, + getRuntimeStatus: async () => { + const status = decodeAgentRemoteRuntimeSummary(await source.getRuntimeStatus()); + if (!status) throw invalidRemoteResult("runtime status"); + return status; + }, + listSessionSummariesPage: async (request: AgentRemotePageRequest) => { + const decodedRequest = decodeAgentRemotePageRequest(request); + if (!decodedRequest) throw invalidRemoteRequest("task page"); + const page = decodeAgentRemoteSessionPage( + await source.listSessionSummariesPage(decodedRequest), + decodedRequest + ); + if (!page) throw invalidRemoteResult("task page"); + return page; + }, + listPersistedRecordsPage: async (request: AgentRemoteRecordsPageRequest) => { + const decodedRequest = decodeAgentRemoteRecordsPageRequest(request); + if (!decodedRequest) throw invalidRemoteRequest("history page"); + const page = decodeAgentRemoteRecordsPage( + await source.listPersistedRecordsPage(decodedRequest), + decodedRequest + ); + if (!page) throw invalidRemoteResult("history page"); + return page; + } + }); + agentRemoteReadOnlyClients.add(client); + return client; +} + +export function decodeAgentRemoteRuntimeSummary(value: unknown): AgentRemoteRuntimeSummary | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["running", "activeRunCount"]) || + typeof value.running !== "boolean" || + !isNonnegativeSafeInteger(value.activeRunCount) || + value.activeRunCount > MAX_REMOTE_AGENT_ACTIVE_RUNS || + (!value.running && value.activeRunCount !== 0) + ) { + return null; + } + return Object.freeze({ running: value.running, activeRunCount: value.activeRunCount }); +} + +export function decodeAgentRemotePageRequest(value: unknown): AgentRemotePageRequest | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["cursor", "limit"]) || + !Number.isSafeInteger(value.limit) || + (value.limit as number) < 1 || + (value.limit as number) > MAX_REMOTE_AGENT_PAGE_SIZE || + !isNullableSafeToken(value.cursor, MAX_REMOTE_AGENT_CURSOR_BYTES) + ) { + return null; + } + return Object.freeze({ + ...(value.cursor === undefined ? {} : { cursor: value.cursor as string | null }), + limit: value.limit as number + }); +} + +export function decodeAgentRemoteRecordsPageRequest( + value: unknown +): AgentRemoteRecordsPageRequest | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["sessionId", "cursor", "limit"]) || + !isSafeToken(value.sessionId, MAX_REMOTE_AGENT_ID_BYTES) + ) { + return null; + } + const page = decodeAgentRemotePageRequest({ + ...(value.cursor === undefined ? {} : { cursor: value.cursor }), + limit: value.limit + }); + if (!page) return null; + return Object.freeze({ sessionId: value.sessionId, ...page }); +} + +export function decodeAgentRemoteSessionPage( + value: unknown, + request: AgentRemotePageRequest +): AgentRemoteSessionPage | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["items", "nextCursor"]) || + !Array.isArray(value.items) || + !isReturnedPageShape(value.items.length, value.nextCursor, request) + ) { + return null; + } + const items: AgentRemoteSessionSummary[] = []; + const ids = new Set(); + for (const item of value.items) { + const decoded = decodeAgentRemoteSessionSummary(item); + if (!decoded || ids.has(decoded.id)) return null; + ids.add(decoded.id); + items.push(decoded); + } + return Object.freeze({ + items, + ...(value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor as string | null }) + }); +} + +export function decodeAgentRemoteRecordsPage( + value: unknown, + request: AgentRemoteRecordsPageRequest +): AgentRemoteRecordsPage | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["records", "historyRevision", "nextCursor"]) || + !Array.isArray(value.records) || + !isSafeToken(value.historyRevision, MAX_REMOTE_AGENT_CURSOR_BYTES) || + !isReturnedPageShape(value.records.length, value.nextCursor, request) + ) { + return null; + } + + const records: AgentRemoteHistoryRecord[] = []; + const recordIds = new Set(); + let pageBytes = + 1_024 + + utf8ByteLength(value.historyRevision) + + (typeof value.nextCursor === "string" ? utf8ByteLength(value.nextCursor) : 0); + for (const record of value.records) { + const decoded = decodeAgentRemoteHistoryRecord(record); + if (!decoded || recordIds.has(decoded.recordId)) return null; + recordIds.add(decoded.recordId); + const encoded = JSON.stringify(decoded); + const recordBytes = utf8ByteLength(encoded); + if (recordBytes > MAX_REMOTE_AGENT_HISTORY_RECORD_BYTES) return null; + pageBytes += recordBytes + 1; + if (pageBytes > MAX_REMOTE_AGENT_HISTORY_PAGE_BYTES) return null; + records.push(decoded); + } + return Object.freeze({ + records, + historyRevision: value.historyRevision, + ...(value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor as string | null }) + }); +} + +function decodeAgentRemoteSessionSummary(value: unknown): AgentRemoteSessionSummary | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["id", "title", "createdMs", "updatedMs", "pageSortMs", "messageCount"]) || + !isSafeToken(value.id, MAX_REMOTE_AGENT_ID_BYTES) || + !isSafeDisplayText(value.title, MAX_REMOTE_AGENT_TITLE_BYTES, false) || + !isNonnegativeSafeInteger(value.createdMs) || + !isNonnegativeSafeInteger(value.updatedMs) || + !isNonnegativeSafeInteger(value.pageSortMs) || + !isNonnegativeSafeInteger(value.messageCount) + ) { + return null; + } + return Object.freeze({ + id: value.id, + title: value.title, + createdMs: value.createdMs, + updatedMs: value.updatedMs, + pageSortMs: value.pageSortMs, + messageCount: value.messageCount + }); +} + +function decodeAgentRemoteHistoryRecord(value: unknown): AgentRemoteHistoryRecord | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["recordId", "role", "createdMs", "items"]) || + !isSafeToken(value.recordId, MAX_REMOTE_AGENT_CURSOR_BYTES) || + typeof value.role !== "string" || + value.role.length < 1 || + value.role.length > MAX_REMOTE_AGENT_HISTORY_ROLE_BYTES || + !isPrintableAscii(value.role) || + !isNonnegativeSafeInteger(value.createdMs) || + !Array.isArray(value.items) || + value.items.length > MAX_REMOTE_AGENT_HISTORY_ITEMS_PER_RECORD + ) { + return null; + } + const items: AgentRemoteTimelineItem[] = []; + for (const item of value.items) { + const decoded = decodeAgentRemoteTimelineItem(item); + if (!decoded) return null; + items.push(decoded); + } + return Object.freeze({ + recordId: value.recordId, + role: value.role, + createdMs: value.createdMs, + items + }); +} + +function decodeAgentRemoteTimelineItem(value: unknown): AgentRemoteTimelineItem | null { + if ( + !isRecord(value) || + !hasOnlyKeys(value, [ + "id", + "itemType", + "role", + "title", + "text", + "status", + "createdMs", + "merge" + ]) || + !isSafeToken(value.id, MAX_REMOTE_AGENT_ID_BYTES) || + (value.itemType !== "message" && + value.itemType !== "thinking" && + value.itemType !== "tool" && + value.itemType !== "permission" && + value.itemType !== "system" && + value.itemType !== "error") || + (value.role !== undefined && + value.role !== "user" && + value.role !== "assistant" && + value.role !== "thought" && + value.role !== "system") || + !isOptionalSafeDisplayText(value.title, MAX_REMOTE_AGENT_TITLE_BYTES) || + !isOptionalTimelineText(value.text) || + !isOptionalSafeDisplayText(value.status, MAX_REMOTE_AGENT_STATUS_BYTES) || + !isNonnegativeSafeInteger(value.createdMs) || + (value.merge !== "append" && value.merge !== "replace") + ) { + return null; + } + + if (value.itemType === "tool") { + let expectedText: string | undefined; + switch (value.status) { + case undefined: + case "pending": + case "running": + case "completed": + expectedText = undefined; + break; + case "failed": + case "error": + expectedText = SAFE_REMOTE_TOOL_FAILED; + break; + case "cancelled": + expectedText = SAFE_REMOTE_TOOL_CANCELLED; + break; + default: + return null; + } + if ( + value.role !== "assistant" || + value.title !== SAFE_REMOTE_TOOL_TITLE || + value.text !== expectedText + ) { + return null; + } + } else if (value.itemType === "permission") { + if ( + value.role !== "system" || + value.title !== SAFE_REMOTE_PERMISSION_TITLE || + value.text !== undefined || + (value.status !== "allow_once" && + value.status !== "deny_once" && + value.status !== "completed" && + value.status !== "cancelled") + ) { + return null; + } + } else if (value.itemType === "error") { + if ( + value.role !== "system" || + value.title !== "Agent error" || + value.text !== SAFE_REMOTE_AGENT_ERROR || + value.status !== "failed" + ) { + return null; + } + } + + return Object.freeze({ + id: value.id, + itemType: value.itemType, + ...(value.role === undefined ? {} : { role: value.role }), + ...(value.title === undefined ? {} : { title: value.title }), + ...(value.text === undefined ? {} : { text: value.text }), + ...(value.status === undefined ? {} : { status: value.status }), + createdMs: value.createdMs, + merge: value.merge + }) as AgentRemoteTimelineItem; +} + +function isReturnedPageShape( + itemCount: number, + nextCursor: unknown, + request: AgentRemotePageRequest +): boolean { + return ( + itemCount <= request.limit && + isNullableSafeToken(nextCursor, MAX_REMOTE_AGENT_CURSOR_BYTES) && + !(itemCount === 0 && nextCursor !== undefined && nextCursor !== null) && + !(typeof nextCursor === "string" && nextCursor === request.cursor) + ); +} + +function invalidRemoteRequest(kind: string): Error { + return new Error(`Remote Agent ${kind} request is invalid`); +} + +function invalidRemoteResult(kind: string): Error { + return new Error(`Remote Agent ${kind} response is invalid`); +} + +function isBoundedOwnerId(value: string): boolean { + return value.length > 0 && value.length <= 256 && !hasControlCharacter(value); +} + +function isRemoteTargetId(value: string): boolean { + return value !== "local" && isSafeToken(value, MAX_REMOTE_AGENT_ID_BYTES); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + const allowed = new Set(allowedKeys); + return Reflect.ownKeys(value).every((key) => typeof key === "string" && allowed.has(key)); +} + +function isNonnegativeSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function isSafeToken(value: unknown, maxBytes: number): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= maxBytes && + SAFE_REMOTE_TOKEN_PATTERN.test(value) + ); +} + +function isNullableSafeToken(value: unknown, maxBytes: number): boolean { + return value === undefined || value === null || isSafeToken(value, maxBytes); +} + +function isPrintableAscii(value: string): boolean { + return [...value].every((character) => { + const code = character.charCodeAt(0); + return code >= 0x20 && code <= 0x7e; + }); +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function isOptionalSafeDisplayText(value: unknown, maxBytes: number): boolean { + return value === undefined || isSafeDisplayText(value, maxBytes, true); +} + +function isOptionalTimelineText(value: unknown): boolean { + return ( + value === undefined || + (typeof value === "string" && + utf8ByteLength(value) <= MAX_REMOTE_AGENT_TEXT_BYTES && + !value.includes("\0") && + hasValidSurrogates(value)) + ); +} + +function isSafeDisplayText(value: unknown, maxBytes: number, allowEmpty: boolean): value is string { + if ( + typeof value !== "string" || + (!allowEmpty && value.length === 0) || + utf8ByteLength(value) > maxBytes + ) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + code === 0x061c || + code === 0x200e || + code === 0x200f || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ) { + return false; + } + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function hasValidSurrogates(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return false; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return false; + } + } + return true; +} + +function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} diff --git a/frontend/src/services/agentRemoteSessionPagination.test.ts b/frontend/src/services/agentRemoteSessionPagination.test.ts new file mode 100644 index 000000000..9bdb49ca3 --- /dev/null +++ b/frontend/src/services/agentRemoteSessionPagination.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { + AgentRemoteSessionPaginationCache, + AgentRemoteSessionWindowLimitError +} from "@/services/agentRemoteSessionPagination"; +import type { AgentRemoteSessionSummary } from "@/services/agentRemoteProviderBridge"; + +function session( + id: string, + pageSortMs: number, + overrides: Partial = {} +): AgentRemoteSessionSummary { + return { + id, + title: id, + createdMs: pageSortMs, + updatedMs: pageSortMs, + pageSortMs, + messageCount: 1, + ...overrides + }; +} + +describe("remote Agent session pagination", () => { + test("loads one head and explicit older pages in stable newest-first order", () => { + const cache = new AgentRemoteSessionPaginationCache(5); + const head = cache.beginHead(); + expect(cache.commit(head, { items: [session("b", 20)], nextCursor: "cursor-b" })).toBe( + "applied" + ); + const older = cache.beginOlder()!; + expect(older.cursor).toBe("cursor-b"); + expect( + cache.commit(older, { + items: [session("a", 10), session("c", 30)], + nextCursor: null + }) + ).toBe("applied"); + expect(cache.snapshot()).toEqual({ + items: [session("c", 30), session("b", 20), session("a", 10)], + nextCursor: null, + headLoaded: true, + isLoading: false, + hasMore: false + }); + }); + + test("deduplicates an overlapping older page without mutation semantics", () => { + const cache = new AgentRemoteSessionPaginationCache(5); + cache.commit(cache.beginHead(), { + items: [session("b", 20)], + nextCursor: "cursor-b" + }); + cache.commit(cache.beginOlder()!, { + items: [session("b", 999), session("a", 10)] + }); + expect(cache.snapshot().items).toEqual([session("b", 20), session("a", 10)]); + }); + + test("rejects an over-window page atomically and settles loading", () => { + const cache = new AgentRemoteSessionPaginationCache(2); + cache.commit(cache.beginHead(), { + items: [session("b", 20)], + nextCursor: "cursor-b" + }); + const before = cache.snapshot(); + const token = cache.beginOlder()!; + expect(() => + cache.commit(token, { + items: [session("a", 10), session("c", 5)], + nextCursor: "cursor-c" + }) + ).toThrow(AgentRemoteSessionWindowLimitError); + expect(cache.snapshot()).toEqual({ ...before, isLoading: false }); + }); + + test("clear makes an in-flight response stale and starts an empty lifecycle", () => { + const cache = new AgentRemoteSessionPaginationCache(); + const token = cache.beginHead(); + cache.clear(); + expect(cache.commit(token, { items: [session("stale", 1)] })).toBe("stale"); + expect(cache.snapshot()).toEqual({ + items: [], + nextCursor: null, + headLoaded: false, + isLoading: false, + hasMore: false + }); + }); + + test("does not begin an older page before a head or while another page is active", () => { + const cache = new AgentRemoteSessionPaginationCache(); + expect(cache.beginOlder()).toBeNull(); + cache.commit(cache.beginHead(), { items: [], nextCursor: "cursor-a" }); + const older = cache.beginOlder(); + expect(older).not.toBeNull(); + expect(cache.beginOlder()).toBeNull(); + cache.fail(older!); + expect(cache.snapshot().isLoading).toBe(false); + }); +}); diff --git a/frontend/src/services/agentRemoteSessionPagination.ts b/frontend/src/services/agentRemoteSessionPagination.ts new file mode 100644 index 000000000..aa4083199 --- /dev/null +++ b/frontend/src/services/agentRemoteSessionPagination.ts @@ -0,0 +1,126 @@ +import type { + AgentRemoteSessionPage, + AgentRemoteSessionSummary +} from "@/services/agentRemoteProviderBridge"; + +export interface AgentRemoteSessionPageToken { + readonly kind: "head" | "older"; + readonly cursor: string | null; + readonly requestId: number; + readonly cacheEpoch: number; +} + +export interface AgentRemoteSessionPageSnapshot { + readonly items: readonly AgentRemoteSessionSummary[]; + readonly nextCursor: string | null; + readonly headLoaded: boolean; + readonly isLoading: boolean; + readonly hasMore: boolean; +} + +export type AgentRemoteSessionPageCommitResult = "applied" | "stale"; + +export class AgentRemoteSessionWindowLimitError extends Error { + constructor() { + super("Remote Agent task window reached its presentation bound"); + this.name = "AgentRemoteSessionWindowLimitError"; + } +} + +/** Mutation-free, bounded pager for the paired-host task browser. */ +export class AgentRemoteSessionPaginationCache { + private items: AgentRemoteSessionSummary[] = []; + private nextCursor: string | null = null; + private headLoaded = false; + private nextRequestId = 0; + private activeRequestId: number | null = null; + private cacheEpoch = 0; + + constructor(private readonly maxItems = 200) { + if (!Number.isSafeInteger(maxItems) || maxItems < 1) { + throw new Error("Remote Agent task window must be a positive safe integer"); + } + } + + beginHead(): AgentRemoteSessionPageToken { + return this.begin("head", null); + } + + beginOlder(): AgentRemoteSessionPageToken | null { + if (!this.headLoaded || !this.nextCursor || this.activeRequestId !== null) return null; + return this.begin("older", this.nextCursor); + } + + commit( + token: AgentRemoteSessionPageToken, + page: AgentRemoteSessionPage + ): AgentRemoteSessionPageCommitResult { + if (token.cacheEpoch !== this.cacheEpoch || this.activeRequestId !== token.requestId) { + return "stale"; + } + this.activeRequestId = null; + + if (token.kind === "head") { + if (page.items.length > this.maxItems) throw new AgentRemoteSessionWindowLimitError(); + this.items = [...page.items].sort(newestSessionFirst); + this.nextCursor = page.nextCursor ?? null; + this.headLoaded = true; + return "applied"; + } + + const existingIds = new Set(this.items.map((item) => item.id)); + const incoming = page.items.filter((item) => !existingIds.has(item.id)); + if (this.items.length + incoming.length > this.maxItems) { + throw new AgentRemoteSessionWindowLimitError(); + } + this.items = [...this.items, ...incoming].sort(newestSessionFirst); + this.nextCursor = page.nextCursor ?? null; + return "applied"; + } + + fail(token: AgentRemoteSessionPageToken): void { + if (token.cacheEpoch === this.cacheEpoch && this.activeRequestId === token.requestId) { + this.activeRequestId = null; + } + } + + snapshot(): AgentRemoteSessionPageSnapshot { + return { + items: this.items, + nextCursor: this.nextCursor, + headLoaded: this.headLoaded, + isLoading: this.activeRequestId !== null, + hasMore: Boolean(this.nextCursor) + }; + } + + clear(): void { + this.items = []; + this.nextCursor = null; + this.headLoaded = false; + this.nextRequestId += 1; + this.activeRequestId = null; + this.cacheEpoch += 1; + } + + private begin(kind: "head" | "older", cursor: string | null): AgentRemoteSessionPageToken { + this.nextRequestId += 1; + this.activeRequestId = this.nextRequestId; + return Object.freeze({ + kind, + cursor, + requestId: this.nextRequestId, + cacheEpoch: this.cacheEpoch + }); + } +} + +function newestSessionFirst( + left: AgentRemoteSessionSummary, + right: AgentRemoteSessionSummary +): number { + const pageOrder = right.pageSortMs - left.pageSortMs; + if (pageOrder !== 0) return pageOrder; + if (left.id === right.id) return 0; + return left.id < right.id ? 1 : -1; +} diff --git a/frontend/src/services/agentRouteRuntime.test.ts b/frontend/src/services/agentRouteRuntime.test.ts new file mode 100644 index 000000000..a4f5b4096 --- /dev/null +++ b/frontend/src/services/agentRouteRuntime.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, test } from "bun:test"; +import { + AgentRuntimeService, + LOCAL_AGENT_EXECUTION_TARGET, + createRemoteAgentExecutionTarget, + type AgentRuntimeBridge +} from "@/services/agentRuntimeService"; +import { AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES } from "@/services/agentRemoteCapabilities"; +import { createAgentRemoteReadOnlyClient } from "@/services/agentRemoteProviderBridge"; +import { + agentRemoteReadOnlyProjectionKey, + agentRouteProjectionKey, + resolveAgentRouteRuntime, + type AgentPortableRuntimeState +} from "@/services/agentRouteRuntime"; + +const DESKTOP = { + isTauri: true, + isTauriDesktop: true, + isTauriMobile: false +} as const; +const PORTABLE = { + isTauri: true, + isTauriDesktop: false, + isTauriMobile: true +} as const; +const WEB = { + isTauri: false, + isTauriDesktop: false, + isTauriMobile: false +} as const; + +function localService(): AgentRuntimeService { + return new AgentRuntimeService(undefined, LOCAL_AGENT_EXECUTION_TARGET); +} + +function remoteService(id = "6ef8cbe0-57dd-4750-a51b-9dc900d51659"): AgentRuntimeService { + const bridge: AgentRuntimeBridge = { + runForUser: async (_userId, operation) => await operation(), + prepareTarget: async (_userId, target) => ({ + targetId: target.id, + hostEpoch: "1", + connectionGeneration: 1 + }), + invokeTarget: async () => { + throw new Error("not invoked by route resolution"); + } + }; + return new AgentRuntimeService(bridge, createRemoteAgentExecutionTarget(id, "MacBook")); +} + +function readOnlyRuntime( + accountId = "account-a", + targetId = "6ef8cbe0-57dd-4750-a51b-9dc900d51659" +): Extract { + const client = createAgentRemoteReadOnlyClient({ + accountId, + targetId, + targetLabel: "MacBook", + source: { + getRuntimeStatus: async () => ({ running: false, activeRunCount: 0 }), + listSessionSummariesPage: async () => ({ items: [] }), + listPersistedRecordsPage: async () => ({ + records: [], + historyRevision: "revision-a" + }) + }, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES + }); + return { + accountId, + status: "readOnlyReady", + client, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + runtimeKey: "binding-a" + }; +} + +describe("resolveAgentRouteRuntime", () => { + test("preserves the embedded local runtime on Tauri Desktop", () => { + const local = localService(); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: DESKTOP, + portableRuntime: null, + localService: local + }) + ).toEqual({ status: "ready", service: local, runtimeKey: "embedded" }); + }); + + test("does not replace Desktop local behavior with a portable read-only selection", () => { + const local = localService(); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: DESKTOP, + portableRuntime: readOnlyRuntime(), + localService: local + }) + ).toEqual({ status: "ready", service: local, runtimeKey: "embedded" }); + }); + + test("never falls back to the local service on a portable Tauri client", () => { + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: null, + localService: localService() + }) + ).toEqual({ status: "unavailable", reason: "remoteProviderUnavailable" }); + }); + + test("keeps a portable route pending while the verified provider loads", () => { + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { accountId: "account-a", status: "loading" } + }) + ).toEqual({ status: "loading" }); + }); + + test("mounts only the narrow read-only client supplied by the portable provider", () => { + const portableRuntime = readOnlyRuntime(); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime + }) + ).toEqual({ + status: "readOnlyReady", + client: portableRuntime.client, + capabilities: AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + runtimeKey: "binding-a" + }); + }); + + test("rejects an incomplete or widened capability snapshot before any bridge call", () => { + const portableRuntime = readOnlyRuntime(); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { + ...portableRuntime, + capabilities: { + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + persistedRecordsPage: false + } + } + }) + ).toEqual({ status: "unavailable", reason: "invalidPortableRuntime" }); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { + ...portableRuntime, + capabilities: { + ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES, + mutations: true + } + } + }) + ).toEqual({ status: "unavailable", reason: "invalidPortableRuntime" }); + + const hiddenExtension = { ...AGENT_REMOTE_PERSISTED_TRANSCRIPT_CAPABILITIES }; + Object.defineProperty(hiddenExtension, "sendMessage", { value: true }); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { ...portableRuntime, capabilities: hiddenExtension } + }) + ).toEqual({ status: "unavailable", reason: "invalidPortableRuntime" }); + }); + + test("rejects a narrow client carrying a generic invoke extension", () => { + const portableRuntime = readOnlyRuntime(); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { + ...portableRuntime, + client: { ...portableRuntime.client, invoke: () => {} } + } as AgentPortableRuntimeState + }) + ).toEqual({ status: "unavailable", reason: "invalidPortableRuntime" }); + }); + + test("rejects a missing provider runtime lifecycle fence", () => { + const portableRuntime = readOnlyRuntime(); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { ...portableRuntime, runtimeKey: "" } + }) + ).toEqual({ status: "unavailable", reason: "invalidPortableRuntime" }); + }); + + test("rejects paired-host state retained for another account", () => { + expect( + resolveAgentRouteRuntime({ + accountId: "account-b", + platform: PORTABLE, + portableRuntime: readOnlyRuntime("account-a") + }) + ).toEqual({ status: "unavailable", reason: "pairingUnavailable" }); + }); + + test("rejects a client whose authenticated binding differs from provider state", () => { + const accountA = readOnlyRuntime("account-a"); + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { ...accountA, client: readOnlyRuntime("account-b").client } + }) + ).toEqual({ status: "unavailable", reason: "invalidPortableRuntime" }); + }); + + test("passes through a verified provider's target-selection state", () => { + const target = { + key: "choice-a", + label: "Office Mac", + select: () => {} + }; + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { + accountId: "account-a", + status: "selectionRequired", + targets: [target] + } + }) + ).toEqual({ status: "selectionRequired", targets: [target] }); + }); + + test("treats an empty selection as no paired host", () => { + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { + accountId: "account-a", + status: "selectionRequired", + targets: [] + } + }) + ).toEqual({ status: "unavailable", reason: "noPairedHost" }); + }); + + test("keeps browser clients unavailable even if handed a read-only client", () => { + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: WEB, + portableRuntime: readOnlyRuntime() + }) + ).toEqual({ status: "unavailable", reason: "requiresTauri" }); + }); + + test("rejects ambiguous Tauri platform classification", () => { + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: { + isTauri: true, + isTauriDesktop: true, + isTauriMobile: true + }, + portableRuntime: null + }) + ).toEqual({ status: "unavailable", reason: "unsupportedTauriClient" }); + }); + + test("rejects a forged portable full-ready state", () => { + expect( + resolveAgentRouteRuntime({ + accountId: "account-a", + platform: PORTABLE, + portableRuntime: { + accountId: "account-a", + status: "ready", + service: remoteService(), + runtimeKey: "binding-a" + } as unknown as AgentPortableRuntimeState + }) + ).toEqual({ status: "unavailable", reason: "invalidPortableRuntime" }); + }); + + test("namespaces mounted projections by account, target, lifecycle, and mode", () => { + const targetA = readOnlyRuntime("account-a", "6ef8cbe0-57dd-4750-a51b-9dc900d51659").client; + const targetB = readOnlyRuntime("account-a", "0ad3f0e4-2aa3-4583-87e7-d400465738db").client; + expect(agentRemoteReadOnlyProjectionKey("account-a", targetA, "binding-a")).not.toBe( + agentRemoteReadOnlyProjectionKey("account-a", targetB, "binding-a") + ); + expect(agentRemoteReadOnlyProjectionKey("account-a", targetA, "binding-a")).not.toBe( + agentRemoteReadOnlyProjectionKey("account-b", targetA, "binding-a") + ); + expect(agentRemoteReadOnlyProjectionKey("account-a", targetA, "binding-a")).not.toBe( + agentRemoteReadOnlyProjectionKey("account-a", targetA, "binding-b") + ); + expect(agentRemoteReadOnlyProjectionKey("account-a", targetA, "binding-a")).not.toBe( + agentRouteProjectionKey("account-a", localService(), "embedded") + ); + }); +}); diff --git a/frontend/src/services/agentRouteRuntime.ts b/frontend/src/services/agentRouteRuntime.ts new file mode 100644 index 000000000..fdbe7c6ad --- /dev/null +++ b/frontend/src/services/agentRouteRuntime.ts @@ -0,0 +1,201 @@ +import { + LOCAL_AGENT_EXECUTION_TARGET, + agentRuntimeService, + type AgentRuntimeService +} from "@/services/agentRuntimeService"; +import { + isClosedAgentRemoteReadOnlyClient, + type AgentRemoteReadOnlyClient +} from "@/services/agentRemoteProviderBridge"; +import { + decodeAgentRemoteCapabilitySnapshot, + isAgentRemotePersistedTranscriptReady, + sameAgentRemoteCapabilitySnapshot, + type AgentRemoteCapabilitySnapshot +} from "@/services/agentRemoteCapabilities"; + +/** + * Presentation-only choice supplied by the account-scoped paired-target + * provider. The route never turns the key or label into execution authority; + * the provider owns selection and AgentRuntimeService still obtains a verified + * native execution lease before every remote operation. + */ +export interface AgentPortableTargetChoice { + readonly key: string; + readonly label: string; + readonly description?: string; + select(): void; +} + +export type AgentPortableRuntimeUnavailableReason = "noPairedHost" | "pairingUnavailable"; + +/** + * Account-scoped portable-client state. A future pairing provider may publish + * this only after consulting the authoritative native paired-target registry. + * Raw route params, local storage, and display labels must never construct it. + */ +export type AgentPortableRuntimeState = + | { + readonly accountId: string; + readonly status: "loading"; + } + | { + readonly accountId: string; + readonly status: "selectionRequired"; + readonly targets: readonly AgentPortableTargetChoice[]; + } + | { + readonly accountId: string; + readonly status: "readOnlyReady"; + readonly client: AgentRemoteReadOnlyClient; + readonly capabilities: AgentRemoteCapabilitySnapshot; + /** + * Opaque provider lifecycle identity. It is a UI/request fence, never an + * authorization token; native still issues and validates the lease. + */ + readonly runtimeKey: string; + } + | { + readonly accountId: string; + readonly status: "unavailable"; + readonly reason: AgentPortableRuntimeUnavailableReason; + }; + +export type AgentRouteUnavailableReason = + | AgentPortableRuntimeUnavailableReason + | "requiresTauri" + | "unsupportedTauriClient" + | "remoteProviderUnavailable" + | "invalidPortableRuntime"; + +export type AgentRouteRuntimeState = + | { + readonly status: "ready"; + readonly service: AgentRuntimeService; + readonly runtimeKey: string; + } + | { + readonly status: "readOnlyReady"; + readonly client: AgentRemoteReadOnlyClient; + readonly capabilities: AgentRemoteCapabilitySnapshot; + readonly runtimeKey: string; + } + | { + readonly status: "loading"; + } + | { + readonly status: "selectionRequired"; + readonly targets: readonly AgentPortableTargetChoice[]; + } + | { + readonly status: "unavailable"; + readonly reason: AgentRouteUnavailableReason; + }; + +export interface AgentRoutePlatform { + readonly isTauri: boolean; + readonly isTauriDesktop: boolean; + readonly isTauriMobile: boolean; +} + +interface ResolveAgentRouteRuntimeOptions { + readonly accountId: string; + readonly platform: AgentRoutePlatform; + readonly portableRuntime: AgentPortableRuntimeState | null; + readonly localService?: AgentRuntimeService; +} + +/** JSON tuple encoding avoids account/target delimiter collisions. */ +export function agentRouteProjectionKey( + accountId: string, + service: AgentRuntimeService, + runtimeKey = "embedded" +): string { + return JSON.stringify([accountId, String(service.target.id), runtimeKey]); +} + +export function agentRemoteReadOnlyProjectionKey( + accountId: string, + client: AgentRemoteReadOnlyClient, + runtimeKey: string +): string { + return JSON.stringify([accountId, client.binding.targetId, runtimeKey, "persisted-transcript"]); +} + +/** + * Keep Desktop on its embedded runtime. A portable Tauri client can use only a + * remote service supplied by the verified paired-target provider; all missing, + * stale-account, or local-service states fail closed without a local fallback. + */ +export function resolveAgentRouteRuntime({ + accountId, + platform, + portableRuntime, + localService = agentRuntimeService +}: ResolveAgentRouteRuntimeOptions): AgentRouteRuntimeState { + if (platform.isTauri && platform.isTauriDesktop && !platform.isTauriMobile) { + if ( + localService.target.kind !== "local" || + localService.target.id !== LOCAL_AGENT_EXECUTION_TARGET.id + ) { + return { status: "unavailable", reason: "invalidPortableRuntime" }; + } + return { status: "ready", service: localService, runtimeKey: "embedded" }; + } + + if (!platform.isTauri) { + return { status: "unavailable", reason: "requiresTauri" }; + } + + if (!platform.isTauriMobile || platform.isTauriDesktop) { + return { status: "unavailable", reason: "unsupportedTauriClient" }; + } + + if (!portableRuntime) { + return { status: "unavailable", reason: "remoteProviderUnavailable" }; + } + + // Do not reveal or reuse another account's paired-host state during an auth + // transition. The native lease check remains the final authority boundary. + if (portableRuntime.accountId !== accountId) { + return { status: "unavailable", reason: "pairingUnavailable" }; + } + + switch (portableRuntime.status) { + case "loading": + return { status: "loading" }; + case "selectionRequired": + if (portableRuntime.targets.length === 0) { + return { status: "unavailable", reason: "noPairedHost" }; + } + return { status: "selectionRequired", targets: portableRuntime.targets }; + case "unavailable": + return { status: "unavailable", reason: portableRuntime.reason }; + case "readOnlyReady": { + const capabilities = decodeAgentRemoteCapabilitySnapshot(portableRuntime.capabilities); + if ( + !isClosedAgentRemoteReadOnlyClient(portableRuntime.client) || + portableRuntime.client.binding.accountId !== accountId || + !isAgentRemotePersistedTranscriptReady(capabilities) || + !sameAgentRemoteCapabilitySnapshot(capabilities, portableRuntime.client.capabilities) + ) { + return { status: "unavailable", reason: "invalidPortableRuntime" }; + } + if (!isBoundedRuntimeKey(portableRuntime.runtimeKey)) { + return { status: "unavailable", reason: "invalidPortableRuntime" }; + } + return { + status: "readOnlyReady", + client: portableRuntime.client, + capabilities, + runtimeKey: portableRuntime.runtimeKey + }; + } + default: + return { status: "unavailable", reason: "invalidPortableRuntime" }; + } +} + +function isBoundedRuntimeKey(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 256; +} diff --git a/frontend/src/utils/platform.test.ts b/frontend/src/utils/platform.test.ts new file mode 100644 index 000000000..0dd351468 --- /dev/null +++ b/frontend/src/utils/platform.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { unclassifiedTauriPlatformInfo } from "@/utils/platform"; + +describe("unclassifiedTauriPlatformInfo", () => { + test("never guesses Desktop or Mobile when the Tauri OS probe fails", () => { + expect(unclassifiedTauriPlatformInfo()).toEqual({ + platform: "unknown", + isTauri: true, + isIOS: false, + isAndroid: false, + isMobile: false, + isDesktop: false, + isMacOS: false, + isWindows: false, + isLinux: false, + isWeb: false, + isTauriDesktop: false, + isTauriMobile: false + }); + }); +}); diff --git a/frontend/src/utils/platform.ts b/frontend/src/utils/platform.ts index e26807193..b5e689579 100644 --- a/frontend/src/utils/platform.ts +++ b/frontend/src/utils/platform.ts @@ -12,7 +12,7 @@ /** * Platform types supported by the application */ -export type PlatformType = "ios" | "android" | "macos" | "windows" | "linux" | "web"; +export type PlatformType = "ios" | "android" | "macos" | "windows" | "linux" | "web" | "unknown"; /** * Comprehensive platform information @@ -44,6 +44,24 @@ export interface PlatformInfo { isTauriMobile: boolean; } +/** Fail-closed state used when Tauri is present but its OS cannot be proven. */ +export function unclassifiedTauriPlatformInfo(): PlatformInfo { + return { + platform: "unknown", + isTauri: true, + isIOS: false, + isAndroid: false, + isMobile: false, + isDesktop: false, + isMacOS: false, + isWindows: false, + isLinux: false, + isWeb: false, + isTauriDesktop: false, + isTauriMobile: false + }; +} + /** * Platform info singleton - ALWAYS set before app renders * Never null after initialization @@ -89,21 +107,10 @@ const platformReady = (async () => { // This shouldn't happen in practice, but we handle it gracefully console.error("[Platform] Failed to get Tauri platform type:", error); - // Default to desktop Linux as a safe fallback for Tauri environments - platformInfo = { - platform: "linux", - isTauri: true, - isIOS: false, - isAndroid: false, - isMobile: false, - isDesktop: true, - isMacOS: false, - isWindows: false, - isLinux: true, - isWeb: false, - isTauriDesktop: true, - isTauriMobile: false - }; + // Keep an unknown Tauri platform unclassified. Guessing Desktop here + // could expose local-only capabilities on a portable client when the + // OS plugin is temporarily unavailable. + platformInfo = unclassifiedTauriPlatformInfo(); } } else { // We're in a web browser