1use 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
38pub(crate) const FEED_HEARTBEAT: Duration = Duration::from_secs(15);
40pub(crate) const STATUS_POLL: Duration = Duration::from_secs(1);
43pub(crate) const FOLLOW_POLL: Duration = Duration::from_secs(5);
49const SNAPSHOT_TAIL_TURNS: u32 = 50;
51const SNAPSHOT_TAIL_BYTES: u64 = 256 * 1024;
52const 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#[derive(Debug, Clone)]
59pub(crate) struct ViewerCaller {
60 pub device_id: String,
61}
62
63pub 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
84async 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
137async 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 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
199async 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
213pub(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
223fn 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
235async 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 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 Json(json!({ "now_ms": agentmux_common::time::now_ms_u64(), "agents": list })).into_response()
265}
266
267pub(crate) const SETTING_TOWER_SHARE: &str = "tower:sharewithpaired";
270
271#[derive(Deserialize)]
272pub(crate) struct ProcsQuery {
273 #[serde(default)]
274 filter: String,
275}
276
277async 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 |_| 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
318async 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 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
369async 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#[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
436type StatusProbe = Box<dyn Fn(&str) -> Option<crate::backend::agent_state::AgentStatus> + Send>;
438
439pub(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 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
468pub(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
478struct 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 async fn snapshot(&self) -> Option<Snapshot> {
529 for _ in 0..3 {
530 let Some((stream, gen, total)) = self.position().await else {
531 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 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 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 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
612enum CatchUp {
614 Nothing,
615 Lines { line: u64, lines: Vec<String> },
617 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
649fn 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
663pub(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 let mut shown = None;
691 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 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 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 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 sub = source.hub.subscribe(&block);
775 provider = (probe.provider)(&block);
777 source.block_id = block;
778 if current.stream.starts_with("b:") {
779 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;