Skip to main content

agentmux_srv/server/
http_viewer.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The viewer routes a paired device calls, served only on the viewer
5//! listener (TLS, `backend::lan_listeners`), never on the loopback or LAN
6//! routers (agentmux-mobile's SPEC_AGENT_STATUS_AND_LIVE_PANE_FEED_2026_10_07
7//! §13.2, §13.3):
8//!
9//! - `POST /agentmux/viewer/pair`: a pairing code for a viewer token. The
10//!   only route without a token.
11//! - `GET /agentmux/viewer/hello`, `GET /agentmux/viewer/agents`,
12//!   `GET /agentmux/viewer/agents/:name/feed`, `GET /agentmux/viewer/panes`,
13//!   `GET /agentmux/viewer/terminals/:block_id/feed`
14//!   (`server::http_viewer_terminal`), `GET /agentmux/viewer/procs`, and
15//!   `GET /agentmux/viewer/sysinfo` and `…/sysinfo/feed`
16//!   (`server::http_viewer_sysinfo`):
17//!   `Authorization: Bearer amxv_…` only. Neither the `lan_key` nor the instance `auth_key` opens
18//!   them, and the token opens nothing else: no other router knows it.
19//!
20//! Read-only throughout. An agent hidden from paired devices is left out of
21//! the list and its feed is 404; agents of other channels on this machine
22//! are 404 too for now (they are not in this channel's registry).
23
24use std::convert::Infallible;
25use std::net::SocketAddr;
26use std::time::Duration;
27
28use axum::body::Bytes;
29use axum::extract::{ConnectInfo, Path, Query};
30use futures_util::Stream;
31use serde::Deserialize;
32
33use super::*;
34use crate::backend::rpc_types::CommandBlockfileReadRangeData;
35use crate::backend::viewer::feed::{self, AppendStep, Cursor, HubEvent};
36use crate::backend::viewer::{pairing, FeedLease, FeedRefused};
37
38/// Heartbeat comment on a quiet feed.
39pub(crate) const FEED_HEARTBEAT: Duration = Duration::from_secs(15);
40/// How often a feed looks at its agent's state. One controller lookup and one
41/// block read, so a second is cheap and keeps the device's chip current.
42pub(crate) const STATUS_POLL: Duration = Duration::from_secs(1);
43/// How often a feed checks that its agent hasn't moved to another block, and
44/// reads lines of the agent's shared transcript that no event of this block
45/// brought (another block, or another srv instance, running the same agent).
46/// The desktop pane polls the shared transcript as often
47/// (`useAgentStream.ts`, `SHARED_ZONE_POLL_MS`).
48pub(crate) const FOLLOW_POLL: Duration = Duration::from_secs(5);
49/// The snapshot: the last 50 turns or 256 KB, whichever is smaller.
50const SNAPSHOT_TAIL_TURNS: u32 = 50;
51const SNAPSHOT_TAIL_BYTES: u64 = 256 * 1024;
52/// The most lines one bounded read returns (`blockfile:read_range`'s cap).
53const READ_LIMIT: u64 = 10_000;
54pub(crate) const RETRY_PREAMBLE: &[u8] = b"retry: 3000\n\n";
55pub(crate) const HEARTBEAT: &[u8] = b": hb\n\n";
56
57/// The paired device a request authenticated as.
58#[derive(Debug, Clone)]
59pub(crate) struct ViewerCaller {
60    pub device_id: String,
61}
62
63/// The viewer router: pairing, plus the token-gated read-only routes.
64pub fn build_viewer_router(state: AppState) -> Router {
65    let authed = Router::new()
66        .route("/agentmux/viewer/hello", get(handle_viewer_hello))
67        .route("/agentmux/viewer/agents", get(handle_viewer_agents))
68        .route("/agentmux/viewer/agents/:name/feed", get(handle_viewer_feed))
69        .route("/agentmux/viewer/procs", get(handle_viewer_procs))
70        .route("/agentmux/viewer/panes", get(handle_viewer_panes))
71        .route("/agentmux/viewer/sysinfo", get(super::http_viewer_sysinfo::handle_viewer_sysinfo))
72        .route("/agentmux/viewer/sysinfo/feed", get(super::http_viewer_sysinfo::handle_viewer_sysinfo_feed))
73        .route(
74            "/agentmux/viewer/terminals/:block_id/feed",
75            get(super::http_viewer_terminal::handle_viewer_terminal_feed),
76        )
77        .route_layer(middleware::from_fn_with_state(state.clone(), viewer_auth_middleware));
78    Router::new()
79        .route("/agentmux/viewer/pair", post(handle_viewer_pair))
80        .merge(authed)
81        .with_state(state)
82}
83
84/// Admit a request carrying a paired device's viewer token: its SHA-256 is
85/// compared, in constant time, with every paired device's. Stamps the
86/// device's `last_seen_ms` at most once a minute.
87async fn viewer_auth_middleware(State(state): State<AppState>, mut req: Request<Body>, next: Next) -> Response {
88    let token = req
89        .headers()
90        .get(header::AUTHORIZATION)
91        .and_then(|v| v.to_str().ok())
92        .and_then(|v| v.strip_prefix("Bearer "))
93        .map(str::trim)
94        .filter(|t| t.starts_with(pairing::TOKEN_PREFIX));
95    let Some(token) = token else {
96        return unauthorized();
97    };
98    let hash = pairing::token_hash(token);
99    let devices = match state.mstore.viewer_device_token_hashes() {
100        Ok(d) => d,
101        Err(e) => {
102            tracing::warn!(error = %e, "viewer: could not read paired devices");
103            return unauthorized();
104        }
105    };
106    let mut device_id = None;
107    for (id, stored) in devices {
108        if secret_eq(stored.as_bytes(), hash.as_bytes()) {
109            device_id = Some(id);
110        }
111    }
112    let Some(device_id) = device_id else {
113        return unauthorized();
114    };
115    let now = agentmux_common::time::now_ms();
116    if state.viewer.last_seen_due(&device_id, now) {
117        if let Err(e) = state.mstore.viewer_device_touch(&device_id, now) {
118            tracing::debug!(error = %e, "viewer: could not stamp last_seen");
119        }
120    }
121    req.extensions_mut().insert(ViewerCaller { device_id });
122    next.run(req).await
123}
124
125#[derive(Deserialize)]
126pub(crate) struct PairBody {
127    code: String,
128    device_name: String,
129    #[serde(default)]
130    device_key: Option<String>,
131}
132
133fn bad_request(msg: &str) -> Response {
134    (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
135}
136
137/// `POST /agentmux/viewer/pair`: `{code, device_name, device_key?}` for
138/// `{token, device_id, hostname, channel, version, install_id?}`; 401 for an
139/// unknown or expired code, 429 after too many wrong ones.
140async fn handle_viewer_pair(
141    State(state): State<AppState>,
142    peer: Option<Extension<ConnectInfo<SocketAddr>>>,
143    body: Result<Json<PairBody>, axum::extract::rejection::JsonRejection>,
144) -> Response {
145    let Ok(Json(body)) = body else {
146        return bad_request("expected {code, device_name, device_key?}");
147    };
148    let device_name = body.device_name.trim().to_string();
149    let name_len = device_name.chars().count();
150    if !(1..=64).contains(&name_len) || device_name.chars().any(char::is_control) {
151        return bad_request("device_name must be 1 to 64 characters");
152    }
153    let device_key = match body.device_key.as_deref().map(str::trim).filter(|k| !k.is_empty()) {
154        None => String::new(),
155        Some(key) => {
156            use base64::Engine as _;
157            match base64::engine::general_purpose::STANDARD.decode(key) {
158                Ok(bytes) if bytes.len() == 32 => key.to_string(),
159                _ => return bad_request("device_key must be standard base64 of a 32-byte public key"),
160            }
161        }
162    };
163    let from = peer.map(|Extension(ConnectInfo(addr))| addr.ip());
164    match state.viewer.pairing.redeem(&body.code, from) {
165        pairing::Redeem::Accepted => {}
166        pairing::Redeem::Refused => return unauthorized(),
167        pairing::Redeem::RateLimited => {
168            return (StatusCode::TOO_MANY_REQUESTS, Json(json!({ "error": "too many wrong codes; wait a minute" })))
169                .into_response()
170        }
171    }
172    let token = pairing::new_token();
173    let device_id = uuid::Uuid::new_v4().to_string();
174    let now = agentmux_common::time::now_ms();
175    if let Err(e) =
176        state.mstore.viewer_device_insert(&device_id, &pairing::token_hash(&token), &device_name, &device_key, now)
177    {
178        tracing::warn!(error = %e, "viewer: could not record a paired device");
179        return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "could not record the device" })))
180            .into_response();
181    }
182    tracing::info!(%device_id, %device_name, "viewer: device paired");
183    // The desktop shows a toast and closes the QR (frontend `viewer:paired`).
184    let paired = crate::backend::mps_events::ViewerPairedEvent { device_id: device_id.to_string(), device_name: device_name.to_string() };
185    state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::ViewerPaired>(&paired, String::new()));
186    let mut out = json!({
187        "token": token,
188        "device_id": device_id,
189        "hostname": state.hostname,
190        "channel": crate::backend::reactive::registry::local_channel_id(),
191        "version": state.version,
192    });
193    if let Some(id) = crate::backend::fleet_source::install_id() {
194        out["install_id"] = json!(id);
195    }
196    Json(out).into_response()
197}
198
199/// `GET /agentmux/viewer/hello`: who this is, and which device is asking.
200async fn handle_viewer_hello(State(state): State<AppState>, Extension(caller): Extension<ViewerCaller>) -> Response {
201    let mut out = json!({
202        "hostname": state.hostname,
203        "channel": crate::backend::reactive::registry::local_channel_id(),
204        "version": state.version,
205        "device_id": caller.device_id,
206    });
207    if let Some(id) = crate::backend::fleet_source::install_id() {
208        out["install_id"] = json!(id);
209    }
210    Json(out).into_response()
211}
212
213/// Whether the agent in `block_id` is hidden from paired devices: its
214/// definition (the block's `agentId`) has `hide_from_devices` set.
215pub(crate) fn agent_hidden(state: &AppState, block_id: &str) -> bool {
216    let Ok(Some(block)) = state.mstore.get::<crate::backend::obj::Block>(block_id) else {
217        return false;
218    };
219    let def_id = crate::backend::obj::meta_get_string(&block.meta, "agentId", "");
220    !def_id.is_empty() && state.mstore.agent_hide_from_devices_get(&def_id).unwrap_or(false)
221}
222
223/// The agent's provider id (`claude`, `codex`, …), '' when unknown.
224fn provider_of(state: &AppState, block_id: &str) -> String {
225    let Ok(Some(block)) = state.mstore.get::<crate::backend::obj::Block>(block_id) else {
226        return String::new();
227    };
228    let raw = crate::backend::obj::meta_get_string(&block.meta, "agentProvider", "");
229    match crate::backend::providers::resolve_provider_alias(&raw) {
230        "" => raw,
231        id => id.to_string(),
232    }
233}
234
235/// `GET /agentmux/viewer/agents`: this channel's agents, hidden ones left
236/// out, sorted case-insensitively.
237async fn handle_viewer_agents(State(state): State<AppState>) -> Response {
238    let mut agents: Vec<(String, String)> = state
239        .reactive_handler
240        .list_agents()
241        .into_iter()
242        .filter(|a| !agent_hidden(&state, &a.block_id))
243        .map(|a| (a.agent_id, a.block_id))
244        .collect();
245    agents.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then(a.0.cmp(&b.0)));
246    agents.dedup_by(|later, earlier| later.0.eq_ignore_ascii_case(&earlier.0));
247    let list: Vec<serde_json::Value> = agents
248        .into_iter()
249        .map(|(name, block_id)| {
250            let mut agent = json!({ "name": name });
251            if let Some(kind) = crate::backend::operator_config_seed::agent_kind_of_block(&state.mstore, &block_id) {
252                agent["kind"] = json!(kind);
253            }
254            // The state the LAN feed reports, from the same tracker, so both
255            // give the same `since_ms`. An agent with no known state has neither.
256            if let Some(status) = crate::backend::agent_state::agent_status_of_block(&state.mstore, &block_id) {
257                agent["state"] = json!(status.state);
258                agent["since_ms"] = json!(status.since_ms);
259            }
260            agent
261        })
262        .collect();
263    // Read after the states, so no `since_ms` is later than it.
264    Json(json!({ "now_ms": agentmux_common::time::now_ms_u64(), "agents": list })).into_response()
265}
266
267/// Settings key: let paired devices see this computer's processes in their
268/// Tower (`GET /agentmux/viewer/procs`). Off unless set.
269pub(crate) const SETTING_TOWER_SHARE: &str = "tower:sharewithpaired";
270
271#[derive(Deserialize)]
272pub(crate) struct ProcsQuery {
273    #[serde(default)]
274    filter: String,
275}
276
277/// `GET /agentmux/viewer/procs?filter=`: this computer's Tower snapshot for a
278/// paired device (SPEC_TOWER_TASK_MANAGER_PANE_2026_10_08.md §8.3): its
279/// tasks and the busiest and largest of its processes, names only, never a
280/// command line. 403 unless the user turned sharing on; an agent hidden from
281/// paired devices, and its processes, are left out.
282async fn handle_viewer_procs(State(state): State<AppState>, Query(q): Query<ProcsQuery>) -> Response {
283    let sharing = state
284        .config_watcher
285        .get_settings()
286        .extra
287        .get(SETTING_TOWER_SHARE)
288        .and_then(|v| v.as_bool())
289        .unwrap_or(false);
290    if !sharing {
291        return (StatusCode::FORBIDDEN, Json(json!({ "error": "this computer doesn't share its processes" })))
292            .into_response();
293    }
294    let st = state.clone();
295    let snap = tokio::task::spawn_blocking(move || {
296        let mut snap = crate::backend::tower_sampler::Tower::global().sample(
297            true,
298            &st.hostname,
299            || crate::server::app_api::tower_pane::inputs(&st.process_tracker),
300            |id| crate::backend::tower_sampler::block_label(&st.mstore, id),
301            // What each renderer serves names this computer's windows and
302            // pages; a paired viewer sees only that it is a renderer.
303            |_| None,
304        )?;
305        crate::backend::tower_sampler::share(&mut snap, crate::backend::tower_sampler::SHARED_TOP, &q.filter, &|id| {
306            agent_hidden(&st, id)
307        });
308        Ok::<_, std::io::Error>(snap)
309    })
310    .await;
311    match snap {
312        Ok(Ok(snap)) => Json(snap).into_response(),
313        Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))).into_response(),
314        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))).into_response(),
315    }
316}
317
318/// `GET /agentmux/viewer/panes`: every pane on this computer, window by window
319/// and tab by tab, with each agent's shell drawer (`backend::viewer::panes`):
320/// names, types, the agent name a feed opens by, and whether a terminal runs.
321/// No content, command line or working directory; agents hidden from paired
322/// devices, and their shells, are left out.
323async fn handle_viewer_panes(State(state): State<AppState>) -> Response {
324    let st = state.clone();
325    let panes = tokio::task::spawn_blocking(move || {
326        use crate::backend::obj::{Block, Tab, Window, Workspace};
327        // A table that can't be read fails the request: an empty or partial
328        // list would read as "nothing open".
329        let windows = st.mstore.get_all::<Window>().map_err(|e| e.to_string())?;
330        let workspaces = st.mstore.get_all::<Workspace>().map_err(|e| e.to_string())?;
331        let tabs = st.mstore.get_all::<Tab>().map_err(|e| e.to_string())?;
332        let blocks = st.mstore.get_all::<Block>().map_err(|e| e.to_string())?;
333        let label = |b: &Block| {
334            crate::backend::tower_sampler::block_label(&st.mstore, &b.oid)
335                .map(|l| (l.label, l.agent))
336                .unwrap_or_default()
337        };
338        let agent_name = |id: &str| st.reactive_handler.get_agent_by_block(id).map(|a| a.agent_id.clone());
339        let running = |id: &str| {
340            crate::backend::blockcontroller::get_block_controller_status(id)
341                .map(|s| s.shellprocstatus == crate::backend::blockcontroller::STATUS_RUNNING)
342        };
343        let hidden = |id: &str| agent_hidden(&st, id);
344        let facts = crate::backend::viewer::panes::PaneFacts {
345            label: &label,
346            agent_name: &agent_name,
347            running: &running,
348            hidden: &hidden,
349        };
350        Ok::<_, String>(crate::backend::viewer::panes::list(&windows, &workspaces, &tabs, &blocks, &facts))
351    })
352    .await
353    .map_err(|e| e.to_string())
354    .and_then(|r| r);
355    match panes {
356        Ok(panes) => Json(json!({
357            "now_ms": agentmux_common::time::now_ms_u64(),
358            "terminals_shared": super::http_viewer_terminal::terminals_shared(&state),
359            "panes": panes,
360        }))
361        .into_response(),
362        Err(error) => {
363            tracing::warn!(%error, "viewer: could not list panes");
364            (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "could not list panes" }))).into_response()
365        }
366    }
367}
368
369/// `GET /agentmux/viewer/agents/:name/feed`: the agent's transcript as
370/// Server-Sent Events (see [`feed_stream`]). 404 for an agent that isn't
371/// this channel's or is hidden; 503 past the feed caps.
372async fn handle_viewer_feed(
373    State(state): State<AppState>,
374    Extension(caller): Extension<ViewerCaller>,
375    Path(name): Path<String>,
376    headers: axum::http::HeaderMap,
377) -> Response {
378    let Some(agent) = state.reactive_handler.get_agent(&name) else {
379        return not_found();
380    };
381    if agent_hidden(&state, &agent.block_id) {
382        return not_found();
383    }
384    let lease = match state.viewer.open_feed(&caller.device_id) {
385        Ok(lease) => lease,
386        Err(refused) => {
387            let error = match refused {
388                FeedRefused::PerDevice => "too many open feeds for this device",
389                FeedRefused::Total => "too many open feeds",
390            };
391            return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "error": error }))).into_response();
392        }
393    };
394    let last_event_id = headers.get("last-event-id").and_then(|v| v.to_str().ok()).map(str::to_string);
395    let provider = provider_of(&state, &agent.block_id);
396    tracing::info!(
397        device = %caller.device_id,
398        agent = %name,
399        block = %agent.block_id,
400        resume = last_event_id.is_some(),
401        "viewer feed: opened"
402    );
403    let stream = feed_stream(
404        FeedSource::of(&state, &agent.block_id),
405        provider,
406        last_event_id,
407        lease,
408        FeedTiming::default(),
409        AgentProbe::of(&state, &name),
410    );
411    (
412        [(header::CONTENT_TYPE, "text/event-stream"), (header::CACHE_CONTROL, "no-cache")],
413        Body::from_stream(stream),
414    )
415        .into_response()
416}
417
418fn not_found() -> Response {
419    (StatusCode::NOT_FOUND, Json(json!({ "error": "no such agent" }))).into_response()
420}
421
422/// A feed's clocks.
423#[derive(Debug, Clone, Copy)]
424pub(crate) struct FeedTiming {
425    pub heartbeat: Duration,
426    pub status_poll: Duration,
427    pub follow_poll: Duration,
428}
429
430impl Default for FeedTiming {
431    fn default() -> Self {
432        Self { heartbeat: FEED_HEARTBEAT, status_poll: STATUS_POLL, follow_poll: FOLLOW_POLL }
433    }
434}
435
436/// The state of the agent in a block, if it has one.
437type StatusProbe = Box<dyn Fn(&str) -> Option<crate::backend::agent_state::AgentStatus> + Send>;
438
439/// What a feed asks about its agent while it runs: the block it runs in now,
440/// whether it has been hidden from paired devices, its state, and the
441/// provider of a block it moves to.
442pub(crate) struct AgentProbe {
443    pub block: Box<dyn Fn() -> Option<String> + Send>,
444    pub hidden: Box<dyn Fn(&str) -> bool + Send>,
445    pub status: StatusProbe,
446    pub provider: Box<dyn Fn(&str) -> String + Send>,
447}
448
449impl AgentProbe {
450    /// The agent registered as `name`: its block from the registry (it moves
451    /// on a respawn), hidden per its definition, its state from the tracker
452    /// the LAN feed uses (`agent_state::agent_status_of_block`), and the
453    /// provider per the block's meta.
454    pub(crate) fn of(state: &AppState, name: &str) -> Self {
455        let (registry, name) = (state.reactive_handler, name.to_string());
456        let hidden_state = state.clone();
457        let provider_state = state.clone();
458        let mstore = state.mstore.clone();
459        Self {
460            block: Box::new(move || registry.get_agent(&name).map(|a| a.block_id.clone())),
461            hidden: Box::new(move |block_id| agent_hidden(&hidden_state, block_id)),
462            status: Box::new(move |block_id| crate::backend::agent_state::agent_status_of_block(&mstore, block_id)),
463            provider: Box::new(move |block_id| provider_of(&provider_state, block_id)),
464        }
465    }
466}
467
468/// Where a feed reads its agent's transcript from.
469pub(crate) struct FeedSource {
470    broker: Arc<Broker>,
471    filestore: Arc<FileStore>,
472    global_store: Option<Arc<FileStore>>,
473    mstore: Arc<Store>,
474    hub: Arc<crate::backend::viewer::feed::FeedHub>,
475    block_id: String,
476}
477
478/// A snapshot as read: where the feed stands after it, and its lines from
479/// `from_line`.
480struct Snapshot {
481    cursor: Cursor,
482    from_line: u64,
483    lines: Vec<String>,
484}
485
486impl FeedSource {
487    pub(crate) fn of(state: &AppState, block_id: &str) -> Self {
488        Self {
489            broker: state.broker.clone(),
490            filestore: state.filestore.clone(),
491            global_store: state.global_transcript_store.clone(),
492            mstore: state.mstore.clone(),
493            hub: state.viewer.hub.clone(),
494            block_id: block_id.to_string(),
495        }
496    }
497
498    async fn read(&self, offset: u64, expect_gen: &str, tail: bool) -> Option<crate::backend::rpc_types::BlockfileReadRangeResult> {
499        let cmd = CommandBlockfileReadRangeData {
500            block_id: self.block_id.clone(),
501            filename: crate::backend::agent_session::OUTPUT_FILE.to_string(),
502            offset,
503            limit: READ_LIMIT,
504            expect_gen: Some(expect_gen.to_string()),
505            tail_turns: tail.then_some(SNAPSHOT_TAIL_TURNS),
506            tail_bytes: tail.then_some(SNAPSHOT_TAIL_BYTES),
507        };
508        let result = super::app_api::blockfile::read_range(
509            self.broker.clone(),
510            self.filestore.clone(),
511            self.global_store.clone(),
512            self.mstore.clone(),
513            cmd,
514        )
515        .await
516        .ok()?;
517        (result.gen_mismatch != Some(true)).then_some(result)
518    }
519
520    async fn position(&self) -> Option<(String, String, u64)> {
521        super::app_api::blockfile::transcript_position(&self.filestore, &self.global_store, &self.mstore, &self.block_id)
522            .await
523    }
524
525    /// The tail, through the bounded range read. Retried when the transcript
526    /// is replaced between the count and the read; `None` if it keeps moving
527    /// or can't be read.
528    async fn snapshot(&self) -> Option<Snapshot> {
529        for _ in 0..3 {
530            let Some((stream, gen, total)) = self.position().await else {
531                // No transcript yet: an empty snapshot; its first append
532                // starts the stream at line 0.
533                let cursor = Cursor { stream: format!("b:{}", self.block_id), gen: String::new(), next_line: 0 };
534                return Some(Snapshot { cursor, from_line: 0, lines: Vec::new() });
535            };
536            let offset = total.saturating_sub(READ_LIMIT);
537            let Some(result) = self.read(offset, &gen, true).await else { continue };
538            let from_line = result.offset.unwrap_or(offset);
539            let next_line = from_line + result.lines.len() as u64;
540            let cursor = Cursor { stream: result.stream.unwrap_or(stream), gen, next_line };
541            return Some(Snapshot { cursor, from_line, lines: result.lines });
542        }
543        None
544    }
545
546    /// The lines from `line` on, for a reconnect that sent `gen:line`: `None`
547    /// when the transcript has another generation now, or more has been
548    /// written since than a stream may fall behind by (a snapshot then).
549    async fn resume(&self, gen: &str, line: u64) -> Option<(Cursor, Vec<String>)> {
550        let (stream, current, total) = self.position().await?;
551        if current != gen || line > total || total - line > READ_LIMIT {
552            return None;
553        }
554        let result = self.read(line, gen, false).await?;
555        let bytes: usize = result.lines.iter().map(|l| feed::wire_size(l.as_bytes())).sum();
556        if bytes > feed::MAX_BEHIND_BYTES {
557            return None;
558        }
559        let cursor = Cursor { stream: result.stream.unwrap_or(stream), gen: gen.to_string(), next_line: line + result.lines.len() as u64 };
560        Some((cursor, result.lines))
561    }
562
563    /// Lines of the agent's shared transcript past `cursor` that no event of
564    /// this block brought: another block, or another srv instance, running
565    /// the same agent wrote them, or this block's append didn't say where it
566    /// landed in the shared stream. Moves the cursor past what it returns.
567    /// Lines an event brings later are then skipped by line number
568    /// ([`feed::apply_append`]), so nothing is sent twice.
569    ///
570    /// A cursor on the block's own transcript is left to its events, unless
571    /// the transcript is now read from another stream: the agent's shared
572    /// transcript started after the snapshot (another srv instance may have
573    /// begun it, and its appends reach no event here).
574    async fn catch_up(&self, cursor: &mut Cursor) -> CatchUp {
575        let shared = cursor.stream.starts_with("g:");
576        let Some((stream, gen, total)) = self.position().await else {
577            return CatchUp::Nothing;
578        };
579        if stream != cursor.stream {
580            return CatchUp::Resync("stream");
581        }
582        if !shared {
583            return CatchUp::Nothing;
584        }
585        if gen != cursor.gen {
586            return CatchUp::Resync("gen");
587        }
588        if total <= cursor.next_line {
589            return CatchUp::Nothing;
590        }
591        if total - cursor.next_line > READ_LIMIT {
592            return CatchUp::Resync("behind");
593        }
594        // Replaced since the count: the next poll, or the event that
595        // announces it, resyncs.
596        let Some(result) = self.read(cursor.next_line, &gen, false).await else {
597            return CatchUp::Nothing;
598        };
599        let bytes: usize = result.lines.iter().map(|l| feed::wire_size(l.as_bytes())).sum();
600        if bytes > feed::MAX_BEHIND_BYTES {
601            return CatchUp::Resync("behind");
602        }
603        if result.lines.is_empty() {
604            return CatchUp::Nothing;
605        }
606        let line = cursor.next_line;
607        cursor.next_line += result.lines.len() as u64;
608        CatchUp::Lines { line, lines: result.lines }
609    }
610}
611
612/// What [`FeedSource::catch_up`] found.
613enum CatchUp {
614    Nothing,
615    /// Lines that follow on from the cursor, starting at `line`.
616    Lines { line: u64, lines: Vec<String> },
617    /// The cursor can't continue (another stream or generation, or too far
618    /// behind): reset and send a fresh snapshot.
619    Resync(&'static str),
620}
621
622fn snapshot_event(snapshot: Snapshot, provider: &str) -> Bytes {
623    let id = snapshot.cursor.event_id();
624    feed::sse_event(
625        "snapshot",
626        Some(&id),
627        &json!({
628            "provider": provider,
629            "gen": snapshot.cursor.gen,
630            "from_line": snapshot.from_line,
631            "next_line": snapshot.cursor.next_line,
632            "lines": feed::device_frames(snapshot.lines),
633        }),
634    )
635}
636
637fn append_event(cursor: &Cursor, line: u64, lines: Vec<String>) -> Bytes {
638    feed::sse_event(
639        "append",
640        Some(&cursor.event_id()),
641        &json!({ "gen": cursor.gen, "line": line, "lines": feed::device_frames(lines) }),
642    )
643}
644
645fn reset_event(reason: &str) -> Bytes {
646    feed::sse_event("reset", None, &json!({ "reason": reason }))
647}
648
649/// `status` `{state, since_ms, now_ms}`; `state` and `since_ms` are null when
650/// a state the device was shown has gone (it drops its chip).
651fn status_event(status: Option<crate::backend::agent_state::AgentStatus>) -> Bytes {
652    feed::sse_event(
653        "status",
654        None,
655        &json!({
656            "state": status.map(|s| s.state),
657            "since_ms": status.map(|s| s.since_ms),
658            "now_ms": agentmux_common::time::now_ms_u64(),
659        }),
660    )
661}
662
663/// One feed: `retry`, then a `snapshot` (or, for a reconnect whose
664/// `Last-Event-ID` still holds, the lines it missed as one `append`), then an
665/// `append` per published transcript append, `reset` + `snapshot` when the
666/// transcript is rewritten or the feed loses its place, and a heartbeat on a
667/// quiet stream. A `status` follows each `snapshot` and every change of the
668/// agent's state ([`feed::status_step`]). Ends with `reset` when the device falls more than 1 MB
669/// behind or the agent is hidden, and silently when the device is revoked
670/// (its lease's token) or goes away. The lease is held until the stream is
671/// dropped.
672///
673/// The feed follows the agent, not the block it opened on: every
674/// [`FOLLOW_POLL`] it asks the registry where the agent runs now and moves
675/// its subscription there, and reads lines of the agent's shared transcript
676/// that no event brought ([`FeedSource::catch_up`]).
677pub(crate) fn feed_stream(
678    source: FeedSource,
679    provider: String,
680    last_event_id: Option<String>,
681    lease: FeedLease,
682    timing: FeedTiming,
683    probe: AgentProbe,
684) -> impl Stream<Item = Result<Bytes, Infallible>> + Send + 'static {
685    async_stream::stream! {
686        let lease = lease;
687        let mut source = source;
688        let mut provider = provider;
689        // The state last sent to the device; `None` until one exists.
690        let mut shown = None;
691        // Subscribed before anything is read, so nothing published between
692        // the read and the first event is missed.
693        let mut sub = source.hub.subscribe(&source.block_id);
694        yield Ok(Bytes::from_static(RETRY_PREAMBLE));
695
696        let resumed = match last_event_id.as_deref().and_then(feed::parse_event_id) {
697            Some((gen, line)) => source.resume(&gen, line).await,
698            None => None,
699        };
700        let mut cursor = match resumed {
701            Some((cursor, lines)) => {
702                if !lines.is_empty() {
703                    let line = cursor.next_line - lines.len() as u64;
704                    yield Ok(append_event(&cursor, line, lines));
705                }
706                Some(cursor)
707            }
708            None => match source.snapshot().await {
709                Some(snapshot) => {
710                    let cursor = snapshot.cursor.clone();
711                    yield Ok(snapshot_event(snapshot, &provider));
712                    if let Some(status) = feed::status_step(&mut shown, (probe.status)(&source.block_id), true) {
713                        yield Ok(status_event(status));
714                    }
715                    Some(cursor)
716                }
717                None => None,
718            },
719        };
720        if cursor.is_none() {
721            yield Ok(reset_event("unavailable"));
722        }
723
724        let mut tick = tokio::time::interval_at(tokio::time::Instant::now() + timing.heartbeat, timing.heartbeat);
725        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
726        let mut status_tick = tokio::time::interval_at(tokio::time::Instant::now() + timing.status_poll, timing.status_poll);
727        status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
728        let mut follow_tick = tokio::time::interval_at(tokio::time::Instant::now() + timing.follow_poll, timing.follow_poll);
729        follow_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
730        while let Some(current) = cursor.as_mut() {
731            // What to do after this event: keep going, resync from a fresh
732            // snapshot after a `reset`, or stop.
733            let mut resync: Option<String> = None;
734            tokio::select! {
735                biased;
736                _ = lease.token.cancelled() => break,
737                event = sub.recv() => match event {
738                    None => break,
739                    Some(HubEvent::Behind) => {
740                        yield Ok(reset_event("behind"));
741                        break;
742                    }
743                    Some(HubEvent::Rewritten { fileop }) => resync = Some(fileop),
744                    Some(HubEvent::Append { pos, records, .. }) => match feed::apply_append(current, &pos, &records) {
745                        AppendStep::Send { line, lines } => yield Ok(append_event(current, line, lines)),
746                        AppendStep::Skip => {
747                            if !pos.iter().any(|p| p.stream == current.stream) {
748                                // Not placed in the feed's stream; the follow
749                                // poll brings these lines if they reached it.
750                                tracing::debug!(block = %source.block_id, stream = %current.stream, "viewer feed: an append without a position in the feed's stream");
751                            }
752                        }
753                        AppendStep::Resync(reason) => resync = Some(reason.to_string()),
754                    },
755                },
756                _ = status_tick.tick() => {
757                    if let Some(status) = feed::status_step(&mut shown, (probe.status)(&source.block_id), false) {
758                        yield Ok(status_event(status));
759                    }
760                }
761                _ = follow_tick.tick() => {
762                    let moved = (probe.block)().filter(|b| *b != source.block_id);
763                    // Checked before anything is subscribed to or read: the
764                    // block followed to, or the shared transcript read below,
765                    // may belong to an agent hidden from paired devices.
766                    if (probe.hidden)(moved.as_deref().unwrap_or(&source.block_id)) {
767                        yield Ok(reset_event("hidden"));
768                        break;
769                    }
770                    if let Some(block) = moved {
771                        tracing::info!(from = %source.block_id, to = %block, "viewer feed: the agent moved to another block; following it");
772                        // Subscribed before the catch-up read below, as at
773                        // the start, so nothing published in between is missed.
774                        sub = source.hub.subscribe(&block);
775                        // Its snapshots are labelled with its own provider.
776                        provider = (probe.provider)(&block);
777                        source.block_id = block;
778                        if current.stream.starts_with("b:") {
779                            // The old block's own transcript: the new one is
780                            // another stream.
781                            resync = Some("moved".to_string());
782                        }
783                    }
784                    if resync.is_none() {
785                        match source.catch_up(current).await {
786                            CatchUp::Nothing => {}
787                            CatchUp::Lines { line, lines } => yield Ok(append_event(current, line, lines)),
788                            CatchUp::Resync(reason) => resync = Some(reason.to_string()),
789                        }
790                    }
791                }
792                _ = tick.tick() => {
793                    if (probe.hidden)(&source.block_id) {
794                        yield Ok(reset_event("hidden"));
795                        break;
796                    }
797                    yield Ok(Bytes::from_static(HEARTBEAT));
798                }
799            }
800            if let Some(reason) = resync {
801                tracing::info!(block = %source.block_id, %reason, "viewer feed: reset; sending a fresh snapshot");
802                yield Ok(reset_event(&reason));
803                match source.snapshot().await {
804                    Some(snapshot) => {
805                        cursor = Some(snapshot.cursor.clone());
806                        yield Ok(snapshot_event(snapshot, &provider));
807                        if let Some(status) = feed::status_step(&mut shown, (probe.status)(&source.block_id), true) {
808                            yield Ok(status_event(status));
809                        }
810                    }
811                    None => {
812                        yield Ok(reset_event("unavailable"));
813                        break;
814                    }
815                }
816            }
817        }
818    }
819}
820
821#[cfg(test)]
822#[path = "tests/viewer/viewer_tests.rs"]
823mod tests;