1use 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
29pub const MAX_BEHIND_BYTES: usize = 1024 * 1024;
31pub const TERM_FILE: &str = "term";
33pub const MAX_FRAME_BYTES: usize = 64 * 1024;
35pub const TRUNCATED_HEAD_CHARS: usize = 2048;
37
38pub 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
45fn 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#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum HubEvent {
58 Append { pos: Vec<StreamPos>, offset: Option<u64>, records: Vec<u8> },
62 Rewritten { fileop: String },
65 Behind,
67}
68
69struct Subscriber {
70 id: u64,
71 tx: mpsc::UnboundedSender<HubEvent>,
72 pending: Arc<AtomicUsize>,
74 behind: Arc<AtomicBool>,
75}
76
77pub 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 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 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 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 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
188pub struct FeedSubscription {
190 hub: Arc<FeedHub>,
191 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#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Cursor {
218 pub stream: String,
221 pub gen: String,
223 pub next_line: u64,
224}
225
226impl Cursor {
227 pub fn event_id(&self) -> String {
229 format!("{}:{}", self.gen, self.next_line)
230 }
231}
232
233pub 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#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum AppendStep {
245 Send { line: u64, lines: Vec<String> },
247 Skip,
249 Resync(&'static str),
252}
253
254pub 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
264pub 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 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
295pub 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
315pub 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
325pub fn device_frames(lines: Vec<String>) -> Vec<String> {
327 lines.into_iter().map(device_frame).collect()
328}
329
330pub 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 assert_eq!(apply_append(&mut c, &pos("b:blk", "g1", 4, 5), b"x\n"), AppendStep::Skip);
361 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}