Skip to main content

agentmux_srv/backend/viewer/
feed.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The server-side half of the live feed: a [`FeedHub`] that watches every
5//! published `blockfile` event in-process (a broker observer, so no
6//! WebSocket route is involved) and hands each open feed the changes of the
7//! one file it follows: an agent's transcript (`output`), or a terminal's
8//! output (`term`, for the terminal feed in [`super::terminal`]); plus the
9//! pure pieces of the feed's wire (cursor, frame truncation, SSE framing)
10//! the route assembles.
11//!
12//! Nothing is buffered without bound for a slow device: a subscription counts
13//! the bytes queued for it, and once more than [`MAX_BEHIND_BYTES`] wait it
14//! stops queueing and receives one [`HubEvent::Behind`], on which the feed
15//! sends `reset` and closes (the device reconnects and gets a snapshot).
16
17use std::collections::HashMap;
18use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
19use std::sync::Arc;
20
21use axum::body::Bytes;
22use base64::Engine as _;
23use serde::Serialize;
24use tokio::sync::mpsc;
25
26use crate::backend::agent_state::AgentStatus;
27use crate::backend::mps::{self, MuxEvent, StreamPos};
28
29/// A stream more than this far behind is reset and closed.
30pub const MAX_BEHIND_BYTES: usize = 1024 * 1024;
31/// A terminal block's output file: raw PTY bytes.
32pub const TERM_FILE: &str = "term";
33/// A frame longer than this is replaced by an `amx_truncated` frame.
34pub const MAX_FRAME_BYTES: usize = 64 * 1024;
35/// How much of a truncated frame is kept, in characters.
36pub const TRUNCATED_HEAD_CHARS: usize = 2048;
37
38/// About how many bytes `records` cost on the wire: each line as the device
39/// gets it, so at most [`MAX_FRAME_BYTES`] however large it is written. One
40/// 5 MB tool result is 64 KB behind, not 5 MB.
41pub fn wire_size(records: &[u8]) -> usize {
42    records.split(|b| *b == b'\n').map(|line| line.len().min(MAX_FRAME_BYTES) + 1).sum()
43}
44
45/// What `records` of `filename` cost a subscriber: a transcript's lines as
46/// the device gets them ([`wire_size`]); a terminal's bytes as they are.
47fn queued_size(filename: &str, records: &[u8]) -> usize {
48    if filename == TERM_FILE {
49        records.len()
50    } else {
51        wire_size(records)
52    }
53}
54
55/// What a subscription receives.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum HubEvent {
58    /// Bytes appended to the file, as published: where they landed per
59    /// stream (transcripts), the byte offset they were written at when the
60    /// writer said (terminals), and the bytes themselves.
61    Append { pos: Vec<StreamPos>, offset: Option<u64>, records: Vec<u8> },
62    /// The transcript was replaced, deleted or otherwise rewritten as a
63    /// whole (`fileop`).
64    Rewritten { fileop: String },
65    /// Too much was waiting; nothing more will be queued.
66    Behind,
67}
68
69struct Subscriber {
70    id: u64,
71    tx: mpsc::UnboundedSender<HubEvent>,
72    /// Bytes queued and not yet received. With `behind`, what bounds `tx`.
73    pending: Arc<AtomicUsize>,
74    behind: Arc<AtomicBool>,
75}
76
77/// Every open feed's subscription, by the block and file it follows.
78pub struct FeedHub {
79    subs: parking_lot::RwLock<HashMap<(String, String), Vec<Subscriber>>>,
80    next_id: AtomicU64,
81    max_behind: usize,
82}
83
84impl Default for FeedHub {
85    fn default() -> Self {
86        Self::with_max_behind(MAX_BEHIND_BYTES)
87    }
88}
89
90impl FeedHub {
91    pub fn with_max_behind(max_behind: usize) -> Self {
92        Self {
93            subs: parking_lot::RwLock::new(HashMap::new()),
94            next_id: AtomicU64::new(1),
95            max_behind,
96        }
97    }
98
99    /// Watch `broker` for transcript events. Called once per broker.
100    pub fn install(self: &Arc<Self>, broker: &mps::Broker) {
101        let hub = Arc::clone(self);
102        broker.add_observer(Arc::new(move |event: &MuxEvent| hub.observe(event)));
103    }
104
105    /// Start receiving `block_id`'s transcript changes. Subscribe before
106    /// reading the snapshot, so nothing published in between is missed; the
107    /// feed drops what the snapshot already holds by line number.
108    pub fn subscribe(self: &Arc<Self>, block_id: &str) -> FeedSubscription {
109        self.subscribe_file(block_id, crate::backend::agent_session::OUTPUT_FILE)
110    }
111
112    /// Start receiving the changes of `block_id`'s `filename` (the
113    /// transcript, or [`TERM_FILE`]).
114    pub fn subscribe_file(self: &Arc<Self>, block_id: &str, filename: &str) -> FeedSubscription {
115        let (tx, rx) = mpsc::unbounded_channel();
116        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
117        let pending = Arc::new(AtomicUsize::new(0));
118        let behind = Arc::new(AtomicBool::new(false));
119        let key = (block_id.to_string(), filename.to_string());
120        self.subs.write().entry(key.clone()).or_default().push(Subscriber {
121            id,
122            tx,
123            pending: Arc::clone(&pending),
124            behind,
125        });
126        FeedSubscription { hub: Arc::clone(self), key, id, rx, pending }
127    }
128
129    #[cfg(test)]
130    pub fn subscribers(&self, block_id: &str) -> usize {
131        let key = (block_id.to_string(), crate::backend::agent_session::OUTPUT_FILE.to_string());
132        self.subs.read().get(&key).map_or(0, Vec::len)
133    }
134
135    fn unsubscribe(&self, key: &(String, String), id: u64) {
136        let mut subs = self.subs.write();
137        if let Some(list) = subs.get_mut(key) {
138            list.retain(|s| s.id != id);
139            if list.is_empty() {
140                subs.remove(key);
141            }
142        }
143    }
144
145    /// The broker observer. Runs on every publish, so it returns at once for
146    /// anything but an event of a followed block's followed file, and never
147    /// blocks.
148    pub fn observe(&self, event: &MuxEvent) {
149        if event.event != mps::EVENT_BLOCK_FILE {
150            return;
151        }
152        let Some(data) = event.data.as_ref() else { return };
153        let Some(filename) = data.get("filename").and_then(|v| v.as_str()) else { return };
154        if filename != crate::backend::agent_session::OUTPUT_FILE && filename != TERM_FILE {
155            return;
156        }
157        let Some(block_id) = data.get("zoneid").and_then(|v| v.as_str()) else { return };
158        let subs = self.subs.read();
159        let Some(list) = subs.get(&(block_id.to_string(), filename.to_string())) else { return };
160        let Ok(file) = serde_json::from_value::<mps::WSFileEventData>(data.clone()) else { return };
161        let event = if file.fileop == mps::FILE_OP_APPEND {
162            let Ok(records) = base64::engine::general_purpose::STANDARD.decode(file.data64.as_bytes()) else {
163                return;
164            };
165            HubEvent::Append { pos: file.pos, offset: file.offset, records }
166        } else {
167            HubEvent::Rewritten { fileop: file.fileop }
168        };
169        let size = match &event {
170            HubEvent::Append { records, .. } => queued_size(filename, records),
171            _ => 0,
172        };
173        for sub in list {
174            if sub.behind.load(Ordering::Relaxed) {
175                continue;
176            }
177            if sub.pending.load(Ordering::Relaxed) + size > self.max_behind {
178                sub.behind.store(true, Ordering::Relaxed);
179                let _ = sub.tx.send(HubEvent::Behind);
180                continue;
181            }
182            sub.pending.fetch_add(size, Ordering::Relaxed);
183            let _ = sub.tx.send(event.clone());
184        }
185    }
186}
187
188/// One feed's subscription; unsubscribes when dropped.
189pub struct FeedSubscription {
190    hub: Arc<FeedHub>,
191    /// The block and file it follows.
192    key: (String, String),
193    id: u64,
194    rx: mpsc::UnboundedReceiver<HubEvent>,
195    pending: Arc<AtomicUsize>,
196}
197
198impl FeedSubscription {
199    pub async fn recv(&mut self) -> Option<HubEvent> {
200        let event = self.rx.recv().await?;
201        if let HubEvent::Append { records, .. } = &event {
202            self.pending.fetch_sub(queued_size(&self.key.1, records), Ordering::Relaxed);
203        }
204        Some(event)
205    }
206}
207
208impl Drop for FeedSubscription {
209    fn drop(&mut self) {
210        self.hub.unsubscribe(&self.key, self.id);
211    }
212}
213
214/// Where a feed stands in its stream: the generation it is reading and the
215/// next line it has not sent.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Cursor {
218    /// `b:<blockId>`, or `g:<zone>` when the transcript is read from the
219    /// agent's global zone.
220    pub stream: String,
221    /// '' while the transcript does not exist yet.
222    pub gen: String,
223    pub next_line: u64,
224}
225
226impl Cursor {
227    /// `<gen>:<next line>`, the SSE `id` a reconnect sends back.
228    pub fn event_id(&self) -> String {
229        format!("{}:{}", self.gen, self.next_line)
230    }
231}
232
233/// `Last-Event-ID` read back: `(gen, next line)`.
234pub fn parse_event_id(id: &str) -> Option<(String, u64)> {
235    let (gen, line) = id.trim().rsplit_once(':')?;
236    if gen.is_empty() {
237        return None;
238    }
239    Some((gen.to_string(), line.parse().ok()?))
240}
241
242/// What to do with one published append.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum AppendStep {
245    /// Send these lines, starting at `line`; the cursor has moved past them.
246    Send { line: u64, lines: Vec<String> },
247    /// Nothing new for this feed (another stream's event, or lines already sent).
248    Skip,
249    /// The feed can't continue from here (another generation, a gap, a count
250    /// that doesn't match): reset and send a fresh snapshot.
251    Resync(&'static str),
252}
253
254/// The lines in published `records`, split the way `blockfile:read_range`
255/// splits the file, so the counts agree.
256pub fn split_records(records: &[u8]) -> Vec<String> {
257    String::from_utf8_lossy(records)
258        .lines()
259        .filter(|l| !l.trim().is_empty())
260        .map(str::to_string)
261        .collect()
262}
263
264/// Apply one published append to `cursor`.
265pub fn apply_append(cursor: &mut Cursor, pos: &[StreamPos], records: &[u8]) -> AppendStep {
266    let Some(pos) = pos.iter().find(|p| p.stream == cursor.stream) else {
267        return AppendStep::Skip;
268    };
269    if pos.gen != cursor.gen {
270        // A transcript that didn't exist at the snapshot: its first
271        // generation simply continues from line 0.
272        if cursor.gen.is_empty() && cursor.next_line == 0 {
273            cursor.gen = pos.gen.clone();
274        } else {
275            return AppendStep::Resync("gen");
276        }
277    }
278    if pos.lines <= cursor.next_line {
279        return AppendStep::Skip;
280    }
281    if pos.line > cursor.next_line {
282        return AppendStep::Resync("gap");
283    }
284    let mut lines = split_records(records);
285    if lines.len() as u64 != pos.lines - pos.line {
286        return AppendStep::Resync("count");
287    }
288    let skip = (cursor.next_line - pos.line) as usize;
289    lines.drain(..skip);
290    let line = cursor.next_line;
291    cursor.next_line = pos.lines;
292    AppendStep::Send { line, lines }
293}
294
295/// Whether a feed sends a `status` event now, given the state it last sent
296/// (`shown`, updated here) and the state now: `Some(Some(_))` sends a state,
297/// `Some(None)` sends `state: null` so the device drops a chip it shows, `None`
298/// sends nothing. Right after a snapshot (`after_snapshot`) a known state is
299/// always sent; otherwise only a change of state or `since_ms` is. An agent
300/// that has never had a state gets no event at all.
301pub fn status_step(
302    shown: &mut Option<AgentStatus>,
303    now: Option<AgentStatus>,
304    after_snapshot: bool,
305) -> Option<Option<AgentStatus>> {
306    let send = match (&*shown, &now) {
307        (_, Some(now)) => after_snapshot || shown.as_ref() != Some(now),
308        (Some(_), None) => true,
309        (None, None) => false,
310    };
311    *shown = now;
312    send.then_some(now)
313}
314
315/// A frame as the device gets it: as written, or, over
316/// [`MAX_FRAME_BYTES`], an `amx_truncated` frame with its size and head.
317pub fn device_frame(line: String) -> String {
318    if line.len() <= MAX_FRAME_BYTES {
319        return line;
320    }
321    let head: String = line.chars().take(TRUNCATED_HEAD_CHARS).collect();
322    serde_json::json!({ "type": "amx_truncated", "bytes": line.len(), "head": head }).to_string()
323}
324
325/// [`device_frame`] over a batch.
326pub fn device_frames(lines: Vec<String>) -> Vec<String> {
327    lines.into_iter().map(device_frame).collect()
328}
329
330/// One SSE event. `data` is serde output on one line, so no raw newline can
331/// end the `data:` field early.
332pub fn sse_event(name: &str, id: Option<&str>, data: &impl Serialize) -> Bytes {
333    let json = serde_json::to_string(data).unwrap_or_else(|_| "{}".to_string());
334    match id {
335        Some(id) => Bytes::from(format!("event: {name}\nid: {id}\ndata: {json}\n\n")),
336        None => Bytes::from(format!("event: {name}\ndata: {json}\n\n")),
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn pos(stream: &str, gen: &str, line: u64, lines: u64) -> Vec<StreamPos> {
345        vec![StreamPos { stream: stream.into(), gen: gen.into(), line, lines }]
346    }
347
348    fn cursor(gen: &str, next: u64) -> Cursor {
349        Cursor { stream: "b:blk".into(), gen: gen.into(), next_line: next }
350    }
351
352    #[test]
353    fn appends_continue_the_cursor_and_drop_what_was_sent() {
354        let mut c = cursor("g1", 3);
355        let step = apply_append(&mut c, &pos("b:blk", "g1", 3, 5), b"{\"a\":1}\n{\"a\":2}\n");
356        assert_eq!(step, AppendStep::Send { line: 3, lines: vec!["{\"a\":1}".into(), "{\"a\":2}".into()] });
357        assert_eq!(c.next_line, 5);
358
359        // Already sent (the snapshot held it).
360        assert_eq!(apply_append(&mut c, &pos("b:blk", "g1", 4, 5), b"x\n"), AppendStep::Skip);
361        // Half sent: only the new part goes out.
362        let step = apply_append(&mut c, &pos("b:blk", "g1", 4, 7), b"x\ny\nz\n");
363        assert_eq!(step, AppendStep::Send { line: 5, lines: vec!["y".into(), "z".into()] });
364        assert_eq!(c.event_id(), "g1:7");
365    }
366
367    #[test]
368    fn another_generation_a_gap_or_a_bad_count_resyncs() {
369        let mut c = cursor("g1", 3);
370        assert_eq!(apply_append(&mut c, &pos("b:blk", "g2", 0, 1), b"x\n"), AppendStep::Resync("gen"));
371        assert_eq!(apply_append(&mut c, &pos("b:blk", "g1", 4, 5), b"x\n"), AppendStep::Resync("gap"));
372        assert_eq!(apply_append(&mut c, &pos("b:blk", "g1", 3, 5), b"x\n"), AppendStep::Resync("count"));
373        assert_eq!(c.next_line, 3, "nothing moved");
374    }
375
376    #[test]
377    fn other_streams_are_ignored_and_a_new_transcript_is_adopted() {
378        let mut c = cursor("g1", 3);
379        assert_eq!(apply_append(&mut c, &pos("g:agent:x:current", "zz", 0, 9), b"x\n"), AppendStep::Skip);
380        let mut fresh = cursor("", 0);
381        let step = apply_append(&mut fresh, &pos("b:blk", "g9", 0, 1), b"first\n");
382        assert_eq!(step, AppendStep::Send { line: 0, lines: vec!["first".into()] });
383        assert_eq!(fresh.gen, "g9");
384    }
385
386    #[test]
387    fn event_ids_round_trip() {
388        assert_eq!(parse_event_id("ab12:40"), Some(("ab12".into(), 40)));
389        assert_eq!(parse_event_id(" ab12:0 "), Some(("ab12".into(), 0)));
390        for bad in ["", ":4", "ab12", "ab12:x", "ab12:-1"] {
391            assert_eq!(parse_event_id(bad), None, "{bad}");
392        }
393    }
394
395    #[test]
396    fn a_frame_over_64_kb_is_replaced_by_its_head() {
397        let small = "x".repeat(MAX_FRAME_BYTES);
398        assert_eq!(device_frame(small.clone()), small);
399        let big = format!("{{\"type\":\"user\",\"text\":\"{}\"}}", "é".repeat(5 * 1024 * 1024 / 2));
400        let frame = device_frame(big.clone());
401        assert!(frame.len() < MAX_FRAME_BYTES);
402        let v: serde_json::Value = serde_json::from_str(&frame).unwrap();
403        assert_eq!(v["type"], "amx_truncated");
404        assert_eq!(v["bytes"], big.len());
405        let head = v["head"].as_str().unwrap();
406        assert_eq!(head.chars().count(), TRUNCATED_HEAD_CHARS);
407        assert!(big.starts_with(head));
408    }
409
410    #[test]
411    fn a_huge_line_counts_as_the_frame_the_device_gets() {
412        let huge = format!("{}\n", "x".repeat(5 * 1024 * 1024));
413        assert!(wire_size(huge.as_bytes()) <= MAX_FRAME_BYTES + 2);
414        assert_eq!(wire_size(b"ab\ncd\n"), 3 + 3 + 1);
415    }
416
417    #[test]
418    fn status_is_sent_after_a_snapshot_on_change_and_once_when_it_goes() {
419        use crate::backend::agent_state::AgentState;
420        let working = AgentStatus { state: AgentState::Working, since_ms: 100 };
421        let idle = AgentStatus { state: AgentState::Idle, since_ms: 200 };
422        let mut shown = None;
423        assert_eq!(status_step(&mut shown, None, true), None, "no state yet: no event, even after a snapshot");
424        assert_eq!(status_step(&mut shown, None, false), None);
425        assert_eq!(status_step(&mut shown, Some(working), true), Some(Some(working)));
426        assert_eq!(status_step(&mut shown, Some(working), false), None, "unchanged");
427        assert_eq!(status_step(&mut shown, Some(working), true), Some(Some(working)), "every snapshot gets one");
428        assert_eq!(status_step(&mut shown, Some(idle), false), Some(Some(idle)));
429        let restarted = AgentStatus { state: AgentState::Idle, since_ms: 900 };
430        assert_eq!(status_step(&mut shown, Some(restarted), false), Some(Some(restarted)), "since_ms alone counts");
431        assert_eq!(status_step(&mut shown, None, false), Some(None), "gone: null, once");
432        assert_eq!(status_step(&mut shown, None, false), None);
433        assert_eq!(status_step(&mut shown, None, true), None);
434    }
435
436    #[test]
437    fn sse_events_are_one_data_line() {
438        let ev = sse_event("append", Some("g:3"), &serde_json::json!({"lines": ["a\nb"]}));
439        let text = String::from_utf8(ev.to_vec()).unwrap();
440        assert_eq!(text, "event: append\nid: g:3\ndata: {\"lines\":[\"a\\nb\"]}\n\n");
441        let reset = sse_event("reset", None, &serde_json::json!({"reason": "gen"}));
442        assert_eq!(String::from_utf8(reset.to_vec()).unwrap(), "event: reset\ndata: {\"reason\":\"gen\"}\n\n");
443    }
444
445    fn blockfile(block: &str, fileop: &str, records: &[u8], pos: Vec<StreamPos>) -> MuxEvent {
446        let data = mps::WSFileEventData {
447            zoneid: block.into(),
448            filename: "output".into(),
449            fileop: fileop.into(),
450            data64: base64::engine::general_purpose::STANDARD.encode(records),
451            offset: None,
452            pos,
453            echo: None,
454        };
455        MuxEvent {
456            event: mps::EVENT_BLOCK_FILE.into(),
457            scopes: vec![format!("block:{block}")],
458            sender: String::new(),
459            persist: 0,
460            data: serde_json::to_value(&data).ok(),
461        }
462    }
463
464    #[tokio::test]
465    async fn the_hub_delivers_only_the_followed_blocks_transcript() {
466        let broker = mps::Broker::new();
467        let hub = Arc::new(FeedHub::default());
468        hub.install(&broker);
469        let mut sub = hub.subscribe("b1");
470        broker.publish(blockfile("b2", "append", b"other\n", pos("b:b2", "g", 0, 1)));
471        let mut term = blockfile("b1", "append", b"tty", vec![]);
472        if let Some(d) = term.data.as_mut() {
473            d["filename"] = "term".into();
474        }
475        broker.publish(term);
476        broker.publish(blockfile("b1", "append", b"mine\n", pos("b:b1", "g", 0, 1)));
477        broker.publish(blockfile("b1", "replace", b"", vec![]));
478        assert_eq!(
479            sub.recv().await,
480            Some(HubEvent::Append { pos: pos("b:b1", "g", 0, 1), offset: None, records: b"mine\n".to_vec() })
481        );
482        assert_eq!(sub.recv().await, Some(HubEvent::Rewritten { fileop: "replace".into() }));
483        assert_eq!(hub.subscribers("b1"), 1);
484        drop(sub);
485        assert_eq!(hub.subscribers("b1"), 0, "dropping the subscription unsubscribes");
486    }
487
488    #[tokio::test]
489    async fn a_subscription_that_falls_behind_gets_behind_once_and_nothing_more() {
490        let broker = mps::Broker::new();
491        let hub = Arc::new(FeedHub::with_max_behind(10));
492        hub.install(&broker);
493        let mut sub = hub.subscribe("b1");
494        for i in 0..5u64 {
495            broker.publish(blockfile("b1", "append", b"12345\n", pos("b:b1", "g", i, i + 1)));
496        }
497        assert!(matches!(sub.recv().await, Some(HubEvent::Append { .. })));
498        assert_eq!(sub.recv().await, Some(HubEvent::Behind), "the second append would pass 10 bytes");
499        broker.publish(blockfile("b1", "append", b"x\n", pos("b:b1", "g", 5, 6)));
500        assert!(
501            tokio::time::timeout(std::time::Duration::from_millis(100), sub.recv()).await.is_err(),
502            "nothing is queued after Behind"
503        );
504    }
505}