1use axum::extract::{Query, State};
22use axum::http::StatusCode;
23use axum::response::{IntoResponse, Json};
24use serde_json::json;
25
26use crate::backend::storage::filestore::FileStore;
27
28use super::AppState;
29
30const LAST_ERROR_TAIL_BYTES: i64 = 8192;
38
39#[derive(serde::Serialize)]
60pub struct LastErrorFrame {
61 pub message: String,
64 pub source: &'static str,
71 pub written_ms: u64,
78}
79
80fn classify_last_error_source(message: &str) -> &'static str {
103 if message.starts_with("no credentials for")
104 || message.starts_with("credential injection could not run")
105 || message.starts_with("this agent's")
106 || message.starts_with("could not isolate this agent's")
112 {
113 "identity"
114 } else if message.starts_with("container exec failed")
115 || message.starts_with("container ensure_running failed")
116 || message.starts_with("Couldn't start the container")
117 {
118 "container_spawn"
119 } else if message.starts_with("queued message could not be sent") {
120 "host_spawn"
121 } else {
122 "unknown"
123 }
124}
125
126fn last_error_frame(filestore: &FileStore, block_id: &str) -> Option<LastErrorFrame> {
133 let file = filestore.stat(block_id, "output").ok().flatten()?;
134 if file.size <= 0 {
135 return None;
136 }
137 let start = (file.size - LAST_ERROR_TAIL_BYTES).max(0);
138 let (_, bytes) = filestore
139 .read_at(block_id, "output", start, file.size - start)
140 .ok()?;
141 let text = String::from_utf8_lossy(&bytes);
142 let last_line = text.lines().rev().find(|l| !l.trim().is_empty())?;
143
144 let value: serde_json::Value = serde_json::from_str(last_line).ok()?;
145 if value.get("type").and_then(|v| v.as_str()) != Some("result")
146 || value.get("is_error").and_then(|v| v.as_bool()) != Some(true)
147 || value.get("subtype").and_then(|v| v.as_str()) != Some("error_during_execution")
148 {
149 return None;
150 }
151 let message = value.get("error")?.get("message")?.as_str()?.to_string();
152 let source = classify_last_error_source(message.strip_prefix("[AgentMux] ").unwrap_or(&message));
153
154 Some(LastErrorFrame {
155 message,
156 source,
157 written_ms: file.modts.max(0) as u64,
158 })
159}
160
161pub async fn handle_muxspect_list(State(state): State<AppState>) -> impl IntoResponse {
171 let blocks: Vec<serde_json::Value> = state
172 .process_broker
173 .list()
174 .into_iter()
175 .map(|status| {
176 let is_agent = status.is_agent();
177 let last_error = last_error_frame(&state.filestore, &status.block_id);
178 let mut value = serde_json::to_value(&status).unwrap_or_default();
179 if let Some(obj) = value.as_object_mut() {
180 obj.insert("is_agent".to_string(), json!(is_agent));
181 obj.insert("last_error".to_string(), json!(last_error));
182 }
183 value
184 })
185 .collect();
186 Json(json!({ "blocks": blocks })).into_response()
187}
188
189#[derive(serde::Deserialize)]
190pub struct MuxspectDescribeQuery {
191 pub block_id: String,
192}
193
194pub async fn handle_muxspect_describe(
202 State(state): State<AppState>,
203 Query(q): Query<MuxspectDescribeQuery>,
204) -> impl IntoResponse {
205 if q.block_id.is_empty() {
206 return (
207 StatusCode::BAD_REQUEST,
208 Json(json!({ "error": "missing block_id" })),
209 )
210 .into_response();
211 }
212
213 let process_status = state.process_broker.status(&q.block_id);
223 let is_agent = process_status.is_agent();
224 let last_error = last_error_frame(&state.filestore, &q.block_id);
231
232 Json(json!({
233 "block_id": q.block_id,
234 "process_status": &process_status,
235 "is_agent": is_agent,
236 "controller_status": &process_status.controller_status,
237 "processes": &process_status.processes,
238 "tracking_confidence": process_status.liveness_confidence,
239 "last_error": last_error,
240 }))
241 .into_response()
242}
243
244#[derive(serde::Deserialize)]
245pub struct MuxspectFindQuery {
246 pub block_id: Option<String>,
247 pub agent: Option<String>,
248}
249
250fn describe_lifecycle_is_unknown(describe: Option<&serde_json::Value>) -> bool {
304 describe
305 .and_then(|d| d.get("process_status"))
306 .and_then(|ps| ps.get("lifecycle"))
307 .and_then(|l| l.as_str())
308 == Some("unknown")
309}
310
311pub async fn handle_muxspect_find(
312 State(state): State<AppState>,
313 Query(q): Query<MuxspectFindQuery>,
314) -> impl IntoResponse {
315 if q.block_id.as_deref().unwrap_or("").is_empty() && q.agent.as_deref().unwrap_or("").is_empty() {
316 return (
317 StatusCode::BAD_REQUEST,
318 Json(json!({ "error": "provide block_id or agent" })),
319 )
320 .into_response();
321 }
322
323 let mut results: Vec<serde_json::Value> = Vec::new();
324 let own_channel = std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string());
325
326 let agents = state.reactive_handler.list_agents();
334 for status in state.process_broker.list() {
335 let block_matches = q.block_id.as_deref().is_some_and(|b| b == status.block_id);
336 let agent_matches = q.agent.as_deref().is_some_and(|name| {
337 agents
338 .iter()
339 .any(|r| r.block_id == status.block_id && r.agent_id.eq_ignore_ascii_case(name))
340 });
341 if !block_matches && !agent_matches {
342 continue;
343 }
344 results.push(json!({
345 "tier": "host",
346 "channel": own_channel,
347 "block_id": status.block_id,
348 "found": true,
349 "process_status": &status,
350 }));
351 }
352
353 if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
373 let local_url = state.local_web_url.clone();
374 let matches: Vec<_> = crate::backend::reactive::registry::list_all_shared(&shared_dir)
375 .into_iter()
376 .filter(|e| e.channel != own_channel && e.local_url != local_url)
377 .filter(|e| {
378 q.block_id.as_deref().is_some_and(|b| b == e.block_id)
379 || q.agent.as_deref().is_some_and(|name| e.agent_id.eq_ignore_ascii_case(name))
380 })
381 .filter(|e| !crate::backend::reactive::registry::should_evict_on_forward_failure(e))
382 .collect();
383
384 let mut join_set = tokio::task::JoinSet::new();
385 for entry in matches {
386 let http_client = state.http_client.clone();
387 join_set.spawn(async move {
388 let fetch = http_client
389 .get(format!("{}/api/v1/muxspect/describe", entry.local_url))
390 .header(agentmux_common::AUTH_KEY_HEADER, &entry.auth_key)
391 .query(&[("block_id", entry.block_id.as_str())])
392 .send();
393
394 let describe = match tokio::time::timeout(
395 std::time::Duration::from_millis(CROSS_CHANNEL_PREVIEW_TIMEOUT_MS),
396 fetch,
397 )
398 .await
399 {
400 Ok(Ok(resp)) if resp.status().is_success() => resp.json::<serde_json::Value>().await.ok(),
401 _ => None, };
403
404 let lifecycle_unknown = describe_lifecycle_is_unknown(describe.as_ref());
405
406 json!({
407 "tier": "cross-channel",
408 "channel": entry.channel,
409 "block_id": entry.block_id,
410 "agent_id": entry.agent_id,
411 "found": !lifecycle_unknown,
412 "describe": describe,
413 })
414 });
415 }
416 while let Some(res) = join_set.join_next().await {
417 if let Ok(value) = res {
418 results.push(value);
419 }
420 }
421 }
422
423 Json(json!({
424 "query": { "block_id": q.block_id, "agent": q.agent },
425 "results": results,
426 }))
427 .into_response()
428}
429
430const DOCK_STUCK_THRESHOLD_MS: i64 = 30_000;
437
438#[derive(serde::Deserialize)]
439pub struct MuxspectDockQuery {
440 pub block_id: String,
441}
442
443#[derive(serde::Serialize)]
444pub struct DockNodeView {
445 pub node_id: String,
446 pub tool_name: String,
447 pub status: String,
448 pub age_ms: i64,
449 pub stuck: bool,
470 #[serde(skip_serializing_if = "Option::is_none")]
475 pub run_in_background: Option<bool>,
476}
477
478pub async fn handle_muxspect_dock(
483 State(state): State<AppState>,
484 Query(q): Query<MuxspectDockQuery>,
485) -> impl IntoResponse {
486 if q.block_id.is_empty() {
487 return (
488 StatusCode::BAD_REQUEST,
489 Json(json!({ "error": "missing block_id" })),
490 )
491 .into_response();
492 }
493
494 let now_ms = agentmux_common::time::now_ms();
495
496 let lifecycle = state.process_broker.status(&q.block_id).lifecycle;
500 let backed = lifecycle == crate::broker::process::Lifecycle::Running;
501
502 let nodes = dock_node_views(state.dock_snapshots.get(&q.block_id, now_ms), now_ms, backed);
503
504 let background_tasks = state.mstore.background_task_list_for_block(&q.block_id).unwrap_or_default();
515 let nodes = merge_background_tasks(nodes, background_tasks, now_ms);
516
517 Json(json!({ "block_id": q.block_id, "nodes": nodes })).into_response()
518}
519
520fn merge_background_tasks(
528 mut nodes: Vec<DockNodeView>,
529 background_tasks: Vec<crate::backend::storage::background_tasks::BackgroundTask>,
530 now_ms: i64,
531) -> Vec<DockNodeView> {
532 use std::collections::HashSet;
533 let existing: HashSet<String> = nodes.iter().map(|n| n.node_id.clone()).collect();
534 for task in background_tasks {
535 if task.status != crate::backend::storage::background_tasks::BackgroundTaskStatus::Running {
536 continue;
537 }
538 if existing.contains(&task.id) {
539 continue;
540 }
541 nodes.push(DockNodeView {
542 node_id: task.id,
543 tool_name: task.label,
544 status: "running".to_string(),
545 age_ms: (now_ms - task.started_at_ms).max(0),
546 stuck: false,
552 run_in_background: Some(true),
553 });
554 }
555 nodes
556}
557
558fn dock_node_views(
563 nodes: Vec<crate::backend::dock_snapshot::DockNodeSnapshot>,
564 now_ms: i64,
565 backed: bool,
566) -> Vec<DockNodeView> {
567 nodes
568 .into_iter()
569 .map(|n| {
570 let age_ms = (now_ms - n.observed_at).max(0);
571 let stuck = n.status == "running" && age_ms >= DOCK_STUCK_THRESHOLD_MS && !backed;
572 DockNodeView {
573 node_id: n.node_id,
574 tool_name: n.tool_name,
575 status: n.status,
576 age_ms,
577 stuck,
578 run_in_background: n.run_in_background,
579 }
580 })
581 .collect()
582}
583
584#[derive(serde::Deserialize, Default)]
585pub struct MuxspectBackgroundTasksQuery {
586 #[serde(default)]
591 pub block_id: Option<String>,
592}
593
594#[derive(serde::Serialize, ts_rs::TS)]
595#[ts(export, export_to = "../../../frontend/types/rpc/")]
596pub struct BackgroundTaskView {
597 pub id: String,
598 pub block_id: String,
599 pub label: String,
600 #[ts(type = "number | null")]
602 pub pid: Option<i64>,
603 #[ts(type = "number")]
604 pub started_at_ms: i64,
605 #[ts(type = "\"running\" | \"done\" | \"error\" | \"stopped\"")]
609 pub status: &'static str,
610 #[ts(type = "number")]
611 pub last_seen_ms: i64,
612 #[ts(type = "number | null")]
613 pub ended_at_ms: Option<i64>,
614 #[ts(type = "string | null")]
618 pub owner_tool_use_id: Option<String>,
619}
620
621impl From<crate::backend::storage::background_tasks::BackgroundTask> for BackgroundTaskView {
622 fn from(t: crate::backend::storage::background_tasks::BackgroundTask) -> Self {
623 BackgroundTaskView {
624 id: t.id,
625 block_id: t.block_id,
626 label: t.label,
627 pid: t.pid,
628 started_at_ms: t.started_at_ms,
629 status: t.status.as_str(),
630 last_seen_ms: t.last_seen_ms,
631 ended_at_ms: t.ended_at_ms,
632 owner_tool_use_id: t.owner_tool_use_id,
633 }
634 }
635}
636
637pub async fn handle_muxspect_migrations() -> impl IntoResponse {
655 let data_dir = crate::backend::base::get_mux_data_dir();
656 let report = tokio::task::spawn_blocking(move || crate::migrations::doctor_report_for_instance(&data_dir))
657 .await
658 .map_err(|e| format!("doctor task panicked: {e}"))
659 .and_then(|r| r);
660 match report {
661 Ok(report) => Json(report).into_response(),
662 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e }))).into_response(),
663 }
664}
665
666pub async fn handle_muxspect_background_tasks(
674 State(state): State<AppState>,
675 Query(q): Query<MuxspectBackgroundTasksQuery>,
676) -> impl IntoResponse {
677 let result = match q.block_id.as_deref() {
678 Some(block_id) if !block_id.is_empty() => state.mstore.background_task_list_for_block(block_id),
679 _ => state.mstore.background_task_list_running(),
680 };
681 match result {
682 Ok(tasks) => {
683 let views: Vec<BackgroundTaskView> = tasks.into_iter().map(Into::into).collect();
684 Json(json!({ "tasks": views })).into_response()
685 }
686 Err(e) => (
687 StatusCode::INTERNAL_SERVER_ERROR,
688 Json(json!({ "error": e.to_string() })),
689 )
690 .into_response(),
691 }
692}
693
694#[derive(serde::Deserialize)]
695pub struct MuxspectDockClearRequest {
696 pub block_id: String,
697 pub node_id: String,
698}
699
700pub async fn handle_muxspect_dock_clear(
711 State(state): State<AppState>,
712 Json(req): Json<MuxspectDockClearRequest>,
713) -> impl IntoResponse {
714 if req.block_id.is_empty() || req.node_id.is_empty() {
715 return (
716 StatusCode::BAD_REQUEST,
717 Json(json!({ "error": "missing block_id or node_id" })),
718 )
719 .into_response();
720 }
721
722 if !state.dock_snapshots.has_node(&req.block_id, &req.node_id) {
723 return (
724 StatusCode::NOT_FOUND,
725 Json(json!({ "error": "no such node in this block's dock snapshot", "cleared": false })),
726 )
727 .into_response();
728 }
729
730 state.broker.publish_event::<crate::backend::mps_events::DockClear>(&crate::backend::mps_events::DockClearEvent { node_id: req.node_id.clone() }, vec![format!("block:{}", req.block_id)], 0);
731 state.dock_snapshots.remove_node(&req.block_id, &req.node_id);
732
733 Json(json!({ "cleared": true, "block_id": req.block_id, "node_id": req.node_id })).into_response()
734}
735
736#[derive(serde::Deserialize)]
737pub struct MuxspectVerifySenderQuery {
738 pub name: String,
739}
740
741#[derive(serde::Serialize, Debug, PartialEq, Eq)]
757pub struct VerifySenderVerdict {
758 pub name: String,
759 pub status: &'static str,
761 #[serde(skip_serializing_if = "Option::is_none")]
762 pub tier: Option<&'static str>,
763 #[serde(skip_serializing_if = "Option::is_none")]
764 pub last_seen_ms: Option<i64>,
765 #[serde(skip_serializing_if = "Option::is_none")]
766 pub channel: Option<String>,
767 #[serde(skip_serializing_if = "Option::is_none")]
768 pub local_url: Option<String>,
769}
770
771struct SenderCandidate {
777 name: String,
778 tier: &'static str,
784 last_seen_ms: Option<i64>,
785 stale_eligible: bool,
801 channel: Option<String>,
802 local_url: Option<String>,
803}
804
805fn classify_sender(name: &str, candidates: &[SenderCandidate], now_ms: i64) -> VerifySenderVerdict {
819 let needle = name.to_lowercase();
820 let is_stale = |c: &&SenderCandidate| {
821 c.stale_eligible
822 && c.last_seen_ms
823 .is_some_and(|ls| now_ms - ls >= crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64)
824 };
825 let matches: Vec<&SenderCandidate> = candidates.iter().filter(|c| c.name.to_lowercase() == needle).collect();
826 let chosen = matches.iter().find(|c| !is_stale(c)).or_else(|| matches.first());
827
828 match chosen {
829 None => VerifySenderVerdict {
830 name: name.to_string(),
831 status: "not_found",
832 tier: None,
833 last_seen_ms: None,
834 channel: None,
835 local_url: None,
836 },
837 Some(c) => VerifySenderVerdict {
838 name: name.to_string(),
839 status: if is_stale(c) { "stale" } else { "found" },
840 tier: Some(c.tier),
841 last_seen_ms: c.last_seen_ms,
842 channel: c.channel.clone(),
843 local_url: c.local_url.clone(),
844 },
845 }
846}
847
848pub async fn handle_muxspect_verify_sender(
870 State(state): State<AppState>,
871 Query(q): Query<MuxspectVerifySenderQuery>,
872) -> impl IntoResponse {
873 if q.name.is_empty() {
874 return (StatusCode::BAD_REQUEST, Json(json!({ "error": "missing name" }))).into_response();
875 }
876
877 let mut candidates: Vec<SenderCandidate> = Vec::new();
878
879 candidates.extend(state.reactive_handler.list_agents().into_iter().map(|a| SenderCandidate {
880 name: a.agent_id,
881 tier: "host",
882 last_seen_ms: Some(a.last_seen as i64),
883 stale_eligible: false,
884 channel: None,
885 local_url: None,
886 }));
887
888 let own_channel = std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string());
894 let local_url = state.local_web_url.clone();
895 if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
896 candidates.extend(
897 crate::backend::reactive::registry::list_all_shared(&shared_dir)
898 .into_iter()
899 .filter(|e| e.channel != own_channel && e.local_url != local_url)
900 .map(|e| SenderCandidate {
901 name: e.agent_id,
902 tier: "cross-channel",
903 last_seen_ms: Some(e.updated_at as i64),
904 stale_eligible: true,
905 channel: Some(e.channel),
906 local_url: Some(e.local_url),
907 }),
908 );
909 }
910
911 for lan_instance in state.lan_discovery.get_instances() {
912 let peer_url = format!("{}:{}", lan_instance.address, lan_instance.port);
913 candidates.extend(lan_instance.agents.iter().map(|agent_name| SenderCandidate {
919 name: agent_name.clone(),
920 tier: "lan",
921 last_seen_ms: Some((lan_instance.last_seen as i64).saturating_mul(1000)),
922 stale_eligible: true,
923 channel: None,
924 local_url: Some(peer_url.clone()),
925 }));
926 }
927
928 if let Some(subscriber) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
929 candidates.extend(subscriber.subscribed_agents().into_iter().map(|agent_name| SenderCandidate {
930 name: agent_name,
931 tier: "wan",
932 last_seen_ms: None,
933 stale_eligible: false,
934 channel: None,
935 local_url: None,
936 }));
937 }
938
939 let now_ms = agentmux_common::time::now_ms();
940
941 Json(classify_sender(&q.name, &candidates, now_ms)).into_response()
942}
943
944const PREVIEW_MAX_CHARS: usize = 200;
953
954const PREVIEW_TAIL_BYTES: i64 = 4096;
960
961const CROSS_CHANNEL_PREVIEW_TIMEOUT_MS: u64 = 1500;
966
967fn truncate_preview(line: &str) -> String {
972 if line.chars().count() > PREVIEW_MAX_CHARS {
973 line.chars().take(PREVIEW_MAX_CHARS).collect::<String>() + "…"
974 } else {
975 line.to_string()
976 }
977}
978
979fn last_line_preview_and_activity(
1002 mstore: &std::sync::Arc<crate::backend::storage::store::Store>,
1003 filestore: &std::sync::Arc<FileStore>,
1004 block_id: &str,
1005) -> (Option<String>, Option<u64>) {
1006 if let Ok(Some(file)) = filestore.stat(block_id, "output") {
1007 if file.size > 0 {
1008 let start = (file.size - PREVIEW_TAIL_BYTES).max(0);
1009 if let Ok((_, bytes)) = filestore.read_at(block_id, "output", start, file.size - start) {
1010 let text = String::from_utf8_lossy(&bytes);
1011 if let Some(last) = text.lines().rev().find(|l| !l.trim().is_empty()) {
1012 return (Some(truncate_preview(last)), Some(file.modts.max(0) as u64));
1013 }
1014 }
1015 }
1016 }
1017
1018 match crate::backend::session_archive::read_session_output(mstore, filestore, block_id) {
1019 Ok((raw_bytes, _)) => {
1020 let text = String::from_utf8_lossy(&raw_bytes);
1021 let preview = text
1022 .lines()
1023 .rev()
1024 .find(|l| !l.trim().is_empty())
1025 .map(truncate_preview);
1026 (preview, None)
1027 }
1028 Err(_) => (None, None),
1029 }
1030}
1031
1032pub async fn handle_muxspect_conversations(State(state): State<AppState>) -> impl IntoResponse {
1052 let mut agents: Vec<serde_json::Value> = Vec::new();
1053 let work_facts = super::work_facts_handlers::start_conversation_facts(&state); for reg in state.reactive_handler.list_agents() {
1057 let (preview, activity_ms) =
1058 last_line_preview_and_activity(&state.mstore, &state.filestore, ®.block_id);
1059 let turn_active = crate::backend::blockcontroller::get_block_controller_status(®.block_id)
1060 .map(|s| s.turn_active)
1061 .unwrap_or(false);
1062 agents.push(json!({
1063 "name": reg.agent_id,
1064 "tier": "host",
1065 "block_id": reg.block_id,
1066 "turn_active": turn_active,
1067 "last_activity_ms": activity_ms.unwrap_or(reg.last_seen),
1070 "last_message_preview": preview,
1071 }));
1072 }
1073
1074 let own_channel = std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string());
1083 let local_url = state.local_web_url.clone();
1084 if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
1085 let cross_channel_entries: Vec<_> =
1086 crate::backend::reactive::registry::list_all_shared(&shared_dir)
1087 .into_iter()
1088 .filter(|e| e.channel != own_channel && e.local_url != local_url)
1089 .collect();
1090
1091 let mut join_set = tokio::task::JoinSet::new();
1092 for entry in cross_channel_entries {
1093 let http_client = state.http_client.clone();
1094 join_set.spawn(async move {
1095 let mut preview: Option<String> = None;
1096 let mut turn_active: Option<bool> = None;
1097
1098 let fetch = http_client
1099 .get(format!("{}/agentmux/reactive/transcript", entry.local_url))
1100 .header(agentmux_common::AUTH_KEY_HEADER, &entry.auth_key)
1101 .query(&[
1102 ("agent", entry.agent_id.as_str()),
1103 ("max_lines", "1"),
1104 ("forwarded", "true"),
1105 ])
1106 .send();
1107
1108 if let Ok(Ok(resp)) = tokio::time::timeout(
1109 std::time::Duration::from_millis(CROSS_CHANNEL_PREVIEW_TIMEOUT_MS),
1110 fetch,
1111 )
1112 .await
1113 {
1114 if resp.status().is_success() {
1115 if let Ok(body) = resp.json::<serde_json::Value>().await {
1116 turn_active = body.get("turn_active").and_then(|v| v.as_bool());
1117 preview = body
1118 .get("lines")
1119 .and_then(|v| v.as_array())
1120 .and_then(|arr| arr.last())
1121 .and_then(|v| v.as_str())
1122 .map(truncate_preview);
1123 }
1124 }
1125 }
1126 json!({
1131 "name": entry.agent_id,
1132 "tier": "cross-channel",
1133 "channel": entry.channel,
1134 "block_id": entry.block_id,
1135 "turn_active": turn_active,
1136 "last_activity_ms": entry.updated_at,
1137 "last_message_preview": preview,
1138 })
1139 });
1140 }
1141 while let Some(res) = join_set.join_next().await {
1142 if let Ok(value) = res {
1145 agents.push(value);
1146 }
1147 }
1148 }
1149
1150 for lan_instance in state.lan_discovery.get_instances() {
1152 for agent_name in &lan_instance.agents {
1153 agents.push(json!({
1154 "name": agent_name,
1155 "tier": "lan",
1156 "host": format!("{}:{}", lan_instance.address, lan_instance.port),
1157 "remote_fetch_required": true,
1158 }));
1159 }
1160 }
1161
1162 if let Some(subscriber) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
1164 for agent_name in subscriber.subscribed_agents() {
1165 agents.push(json!({
1166 "name": agent_name,
1167 "tier": "wan",
1168 "remote_fetch_required": true,
1169 }));
1170 }
1171 }
1172
1173 super::work_facts_handlers::annotate_conversations(work_facts, &mut agents).await; Json(json!({ "agents": agents })).into_response()
1175}
1176
1177fn flatten_layout_nodes(
1183 node: &agentmux_common::LayoutNode,
1184 depth: usize,
1185 index_path: &str,
1186 out: &mut Vec<serde_json::Value>,
1187) {
1188 let is_branch = !node.children.is_empty();
1189 out.push(json!({
1190 "id": node.id,
1191 "depth": depth,
1192 "path": index_path,
1193 "kind": if is_branch { "branch" } else { "leaf" },
1194 "flex_direction": match node.flex_direction {
1195 agentmux_common::FlexDirection::Row => "row",
1196 agentmux_common::FlexDirection::Column => "column",
1197 },
1198 "size": node.size,
1199 "locked": crate::backend::layout::is_node_locked(node),
1206 "effectively_minimized": crate::backend::layout::is_effectively_minimized(node),
1210 "block_id": node.data.as_ref().map(|d| d.block_id.clone()),
1211 "child_count": node.children.len(),
1212 }));
1213 for (i, child) in node.children.iter().enumerate() {
1214 let child_path = if index_path.is_empty() {
1215 i.to_string()
1216 } else {
1217 format!("{index_path}.{i}")
1218 };
1219 flatten_layout_nodes(child, depth + 1, &child_path, out);
1220 }
1221}
1222
1223pub async fn handle_muxspect_layout(
1240 State(state): State<AppState>,
1241 Query(q): Query<MuxspectLayoutQuery>,
1242) -> impl IntoResponse {
1243 let tabs = match state.mstore.get_all::<crate::backend::obj::Tab>() {
1244 Ok(t) => t,
1245 Err(e) => {
1246 return Json(json!({ "error": format!("failed to list tabs: {e}") })).into_response();
1251 }
1252 };
1253
1254 let mut layouts = Vec::new();
1255 for tab in tabs {
1256 if let Some(want) = q.tab_id.as_deref() {
1257 if tab.oid != want {
1258 continue;
1259 }
1260 }
1261 let ls = match state
1262 .mstore
1263 .must_get::<crate::backend::obj::LayoutState>(&tab.layoutstate)
1264 {
1265 Ok(ls) => ls,
1266 Err(e) => {
1267 layouts.push(json!({
1270 "tab_id": tab.oid,
1271 "tab_name": tab.name,
1272 "layoutstate_oid": tab.layoutstate,
1273 "error": format!("layoutstate unreadable: {e}"),
1274 }));
1275 continue;
1276 }
1277 };
1278
1279 let violations = crate::backend::layout::validate_layout_invariants(&ls.rootnode);
1280 let mut nodes = Vec::new();
1281 if let Some(root) = ls.rootnode.as_ref() {
1282 flatten_layout_nodes(root, 0, "", &mut nodes);
1283 }
1284 let leaf_count = nodes.iter().filter(|n| n["kind"] == "leaf").count();
1285 let minimized_leaves = nodes
1286 .iter()
1287 .filter(|n| n["kind"] == "leaf" && n["locked"] == true)
1288 .count();
1289
1290 layouts.push(json!({
1291 "tab_id": tab.oid,
1292 "tab_name": tab.name,
1293 "layoutstate_oid": ls.oid,
1294 "magnified_node_id": ls.magnifiednodeid,
1295 "node_count": nodes.len(),
1296 "leaf_count": leaf_count,
1297 "minimized_leaf_count": minimized_leaves,
1298 "violations": violations,
1299 "healthy": violations.is_empty(),
1300 "nodes": nodes,
1301 }));
1302 }
1303
1304 if let Some(want) = q.tab_id.as_deref() {
1313 if layouts.is_empty() {
1314 return Json(json!({ "error": format!("no tab with id '{want}' in this instance") }))
1315 .into_response();
1316 }
1317 }
1318
1319 Json(json!({ "layouts": layouts })).into_response()
1320}
1321
1322#[derive(serde::Deserialize)]
1323pub struct MuxspectLayoutQuery {
1324 pub tab_id: Option<String>,
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330 use super::*;
1331 use crate::backend::storage::filestore::{FileMeta, FileOpts};
1332
1333 fn error_frame_line(message: &str) -> String {
1334 json!({
1335 "type": "result",
1336 "is_error": true,
1337 "subtype": "error_during_execution",
1338 "error": {"message": message}
1339 })
1340 .to_string()
1341 }
1342
1343 #[test]
1347 fn describe_lifecycle_is_unknown_true_for_lifecycle_unknown() {
1348 let describe = json!({ "process_status": { "lifecycle": "unknown" } });
1349 assert!(describe_lifecycle_is_unknown(Some(&describe)));
1350 }
1351
1352 #[test]
1353 fn describe_lifecycle_is_unknown_false_for_a_real_lifecycle() {
1354 for lifecycle in ["running", "idle", "done", "error"] {
1355 let describe = json!({ "process_status": { "lifecycle": lifecycle } });
1356 assert!(!describe_lifecycle_is_unknown(Some(&describe)), "lifecycle={lifecycle}");
1357 }
1358 }
1359
1360 #[test]
1361 fn describe_lifecycle_is_unknown_false_when_the_forward_itself_failed() {
1362 assert!(!describe_lifecycle_is_unknown(None));
1365 }
1366
1367 #[test]
1368 fn describe_lifecycle_is_unknown_false_for_malformed_or_missing_fields() {
1369 assert!(!describe_lifecycle_is_unknown(Some(&json!({}))));
1370 assert!(!describe_lifecycle_is_unknown(Some(&json!({ "process_status": {} }))));
1371 assert!(!describe_lifecycle_is_unknown(Some(&json!({ "process_status": { "lifecycle": 123 } }))));
1372 }
1373
1374 #[test]
1375 fn classify_last_error_source_matches_every_known_construction_site() {
1376 assert_eq!(
1381 classify_last_error_source("no credentials for claude: the bound account was deleted or is unresolvable. Bind an account for this provider in Connectors → Accounts."),
1382 "identity"
1383 );
1384 assert_eq!(
1385 classify_last_error_source("credential injection could not run (task join failed); the spawn was refused rather than falling back to the global CLI login. Retry, and check `muxlog auth` if it persists."),
1386 "identity"
1387 );
1388 assert_eq!(
1390 classify_last_error_source("this agent's claude identity points directly at your personal claude config directory (C:\\Users\\user\\.claude) instead of an isolated AgentMux account — AgentMux no longer allows spawning an agent against your own global CLI login. Re-bind this identity to an isolated account in Connectors \u{2192} Accounts (delete the current claude account and log in again to create a fresh, isolated one), then retry."),
1391 "identity"
1392 );
1393 assert_eq!(
1395 classify_last_error_source("could not isolate this agent's claude config directory (C:\\Users\\user\\.agentmux\\shared\\identities\\id-1\\claude): permission denied. Refusing to spawn with an unprotected config dir — retry, and check the directory's permissions if it persists."),
1396 "identity"
1397 );
1398 assert_eq!(
1399 classify_last_error_source("container exec failed: no such container"),
1400 "container_spawn"
1401 );
1402 assert_eq!(
1403 classify_last_error_source("container ensure_running failed: image not found"),
1404 "container_spawn"
1405 );
1406 assert_eq!(
1407 classify_last_error_source("Couldn't start the container for this agent: the registry refused access to x."),
1408 "container_spawn"
1409 );
1410 assert_eq!(
1411 classify_last_error_source("queued message could not be sent: lease held by another process"),
1412 "host_spawn"
1413 );
1414 assert_eq!(classify_last_error_source("something nobody wrote yet"), "unknown");
1415 assert_eq!(
1419 classify_last_error_source(
1420 &crate::identity::resolver::SpawnGateError::AgentDeleted {
1421 agent_id: "uid-x".to_string()
1422 }
1423 .to_string()
1424 ),
1425 "identity"
1426 );
1427 }
1428
1429 #[test]
1430 fn last_error_frame_none_for_missing_block() {
1431 let filestore = FileStore::open_in_memory().unwrap();
1432 assert!(last_error_frame(&filestore, "no-such-block").is_none());
1433 }
1434
1435 #[test]
1436 fn last_error_frame_none_for_empty_output() {
1437 let filestore = FileStore::open_in_memory().unwrap();
1438 filestore
1439 .make_file("block-1", "output", FileMeta::new(), FileOpts::default())
1440 .unwrap();
1441 assert!(last_error_frame(&filestore, "block-1").is_none());
1442 }
1443
1444 #[test]
1445 fn last_error_frame_found_when_last_line_is_the_frame() {
1446 let filestore = FileStore::open_in_memory().unwrap();
1447 filestore
1448 .make_file("block-1", "output", FileMeta::new(), FileOpts::default())
1449 .unwrap();
1450 let mut content = String::new();
1451 content.push_str("{\"type\":\"assistant\",\"text\":\"normal turn output\"}\n");
1452 content.push_str(&error_frame_line(
1453 "[AgentMux] no credentials for claude: bind an account in Connectors → Accounts.",
1454 ));
1455 content.push('\n');
1456 filestore.write_file("block-1", "output", content.as_bytes()).unwrap();
1457
1458 let found = last_error_frame(&filestore, "block-1").expect("last line is an error frame");
1459 assert_eq!(
1460 found.message,
1461 "[AgentMux] no credentials for claude: bind an account in Connectors → Accounts."
1462 );
1463 assert_eq!(found.source, "identity");
1464 assert!(found.written_ms > 0);
1465 }
1466
1467 #[test]
1473 fn last_error_frame_none_when_normal_output_follows_an_earlier_error() {
1474 let filestore = FileStore::open_in_memory().unwrap();
1475 filestore
1476 .make_file("block-1", "output", FileMeta::new(), FileOpts::default())
1477 .unwrap();
1478 let mut content = String::new();
1479 content.push_str(&error_frame_line("[AgentMux] container exec failed: transient"));
1480 content.push('\n');
1481 content.push_str("{\"type\":\"assistant\",\"text\":\"turn resumed fine after the retry\"}\n");
1482 filestore.write_file("block-1", "output", content.as_bytes()).unwrap();
1483
1484 assert!(last_error_frame(&filestore, "block-1").is_none());
1485 }
1486
1487 #[test]
1488 fn last_error_frame_ignores_a_result_frame_that_is_not_an_error() {
1489 let filestore = FileStore::open_in_memory().unwrap();
1490 filestore
1491 .make_file("block-1", "output", FileMeta::new(), FileOpts::default())
1492 .unwrap();
1493 let normal_result = json!({"type": "result", "is_error": false, "subtype": "success"}).to_string();
1494 filestore
1495 .write_file("block-1", "output", format!("{normal_result}\n").as_bytes())
1496 .unwrap();
1497
1498 assert!(last_error_frame(&filestore, "block-1").is_none());
1499 }
1500
1501 fn dock_node(id: &str, status: &str, observed_at: i64) -> crate::backend::dock_snapshot::DockNodeSnapshot {
1502 crate::backend::dock_snapshot::DockNodeSnapshot {
1503 node_id: id.to_string(),
1504 tool_name: "Bash".to_string(),
1505 status: status.to_string(),
1506 timestamp: Some(observed_at),
1507 observed_at,
1508 run_in_background: None,
1509 }
1510 }
1511
1512 #[test]
1513 fn dock_node_views_flags_stuck_running_node_past_threshold_when_unbacked() {
1514 let now_ms = 100_000;
1515 let views = dock_node_views(vec![dock_node("n1", "running", 50_000)], now_ms, false);
1516 assert_eq!(views.len(), 1);
1517 assert_eq!(views[0].age_ms, 50_000);
1518 assert!(views[0].stuck, "running, 50s old, unbacked — must be flagged stuck");
1519 }
1520
1521 #[test]
1522 fn dock_node_views_does_not_flag_running_node_within_threshold() {
1523 let now_ms = 100_000;
1524 let views = dock_node_views(vec![dock_node("n1", "running", 90_000)], now_ms, false);
1526 assert!(!views[0].stuck, "a tool merely taking a while must not be flagged instantly");
1527 }
1528
1529 #[test]
1530 fn dock_node_views_does_not_flag_running_node_when_backed_by_a_live_process() {
1531 let now_ms = 100_000;
1532 let views = dock_node_views(vec![dock_node("n1", "running", 50_000)], now_ms, true);
1535 assert!(!views[0].stuck, "backed by a live process — not stuck, just slow");
1536 }
1537
1538 #[test]
1539 fn dock_node_views_does_not_flag_non_running_nodes_regardless_of_age() {
1540 let now_ms = 100_000;
1541 for status in ["success", "failed", "canceled", "denied"] {
1542 let views = dock_node_views(vec![dock_node("n1", status, 0)], now_ms, false);
1543 assert!(!views[0].stuck, "status={status} already resolved, must never be flagged stuck");
1544 }
1545 }
1546
1547 #[test]
1548 fn dock_node_views_preserves_every_input_node() {
1549 let now_ms = 100_000;
1550 let views = dock_node_views(
1551 vec![dock_node("n1", "running", 99_000), dock_node("n2", "success", 80_000)],
1552 now_ms,
1553 false,
1554 );
1555 assert_eq!(views.len(), 2);
1556 assert_eq!(views[0].node_id, "n1");
1557 assert_eq!(views[1].node_id, "n2");
1558 }
1559
1560 #[test]
1561 fn dock_node_views_surfaces_run_in_background_even_though_status_is_terminal() {
1562 let now_ms = 100_000;
1569 let mut bg_node = dock_node("n1", "success", 0);
1570 bg_node.run_in_background = Some(true);
1571 let views = dock_node_views(vec![bg_node], now_ms, false);
1572 assert_eq!(views[0].run_in_background, Some(true));
1573 assert!(!views[0].stuck, "status is terminal — the raw heuristic correctly stays quiet");
1574 }
1575
1576 fn bg_task(
1577 id: &str,
1578 status: crate::backend::storage::background_tasks::BackgroundTaskStatus,
1579 started_at_ms: i64,
1580 ) -> crate::backend::storage::background_tasks::BackgroundTask {
1581 crate::backend::storage::background_tasks::BackgroundTask {
1582 id: id.to_string(),
1583 block_id: "block-1".to_string(),
1584 label: "task dev".to_string(),
1585 pid: Some(4242),
1586 started_at_ms,
1587 status,
1588 last_seen_ms: started_at_ms,
1589 ended_at_ms: None,
1590 owner_tool_use_id: None,
1591 }
1592 }
1593
1594 #[test]
1595 fn merge_background_tasks_adds_a_running_task_the_cache_evicted() {
1596 use crate::backend::storage::background_tasks::BackgroundTaskStatus;
1599 let now_ms = 100_000_000;
1600 let merged = merge_background_tasks(vec![], vec![bg_task("bg-1", BackgroundTaskStatus::Running, 1_000)], now_ms);
1601 assert_eq!(merged.len(), 1);
1602 assert_eq!(merged[0].node_id, "bg-1");
1603 assert_eq!(merged[0].status, "running");
1604 assert_eq!(merged[0].age_ms, now_ms - 1_000);
1605 assert_eq!(merged[0].run_in_background, Some(true));
1606 assert!(!merged[0].stuck);
1607 }
1608
1609 #[test]
1610 fn merge_background_tasks_skips_a_task_the_cache_still_has() {
1611 use crate::backend::storage::background_tasks::BackgroundTaskStatus;
1612 let now_ms = 100_000;
1613 let existing = dock_node_views(vec![dock_node("bg-1", "running", 90_000)], now_ms, false);
1614 let merged = merge_background_tasks(existing, vec![bg_task("bg-1", BackgroundTaskStatus::Running, 1_000)], now_ms);
1615 assert_eq!(merged.len(), 1, "must not duplicate a node the live cache already has");
1616 assert_eq!(merged[0].tool_name, "Bash", "the live cache's own entry wins, not a synthesized one");
1617 }
1618
1619 #[test]
1620 fn merge_background_tasks_ignores_non_running_tasks() {
1621 use crate::backend::storage::background_tasks::BackgroundTaskStatus;
1622 let now_ms = 100_000;
1623 for status in [BackgroundTaskStatus::Done, BackgroundTaskStatus::Error, BackgroundTaskStatus::Stopped] {
1624 let merged = merge_background_tasks(vec![], vec![bg_task("bg-1", status, 1_000)], now_ms);
1625 assert!(merged.is_empty(), "status={status:?} is terminal — must not be synthesized as a live dock row");
1626 }
1627 }
1628
1629 #[test]
1630 fn merge_background_tasks_on_no_tasks_is_a_true_no_op() {
1631 let now_ms = 100_000;
1632 let existing = dock_node_views(vec![dock_node("n1", "running", 90_000)], now_ms, false);
1633 let expected_len = existing.len();
1634 let merged = merge_background_tasks(existing, vec![], now_ms);
1635 assert_eq!(merged.len(), expected_len);
1636 assert_eq!(merged[0].node_id, "n1");
1637 }
1638
1639 #[tokio::test]
1640 async fn handle_muxspect_dock_rejects_empty_block_id() {
1641 let state = crate::server::tests::test_state();
1642 let resp = handle_muxspect_dock(
1643 State(state),
1644 Query(MuxspectDockQuery { block_id: String::new() }),
1645 )
1646 .await
1647 .into_response();
1648 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1649 }
1650
1651 #[tokio::test]
1652 async fn handle_muxspect_dock_clear_rejects_empty_ids() {
1653 let state = crate::server::tests::test_state();
1654 let resp = handle_muxspect_dock_clear(
1655 State(state),
1656 Json(MuxspectDockClearRequest { block_id: String::new(), node_id: "n1".to_string() }),
1657 )
1658 .await
1659 .into_response();
1660 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1661 }
1662
1663 #[tokio::test]
1664 async fn handle_muxspect_dock_clear_404s_on_unknown_node() {
1665 let state = crate::server::tests::test_state();
1666 let resp = handle_muxspect_dock_clear(
1667 State(state),
1668 Json(MuxspectDockClearRequest { block_id: "block-1".to_string(), node_id: "no-such-node".to_string() }),
1669 )
1670 .await
1671 .into_response();
1672 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1673 }
1674
1675 #[tokio::test]
1676 async fn handle_muxspect_dock_clear_succeeds_and_removes_from_cache() {
1677 let state = crate::server::tests::test_state();
1678 state.dock_snapshots.push_delta("block-1", dock_node("n1", "running", 0));
1679 assert!(state.dock_snapshots.has_node("block-1", "n1"));
1680
1681 let resp = handle_muxspect_dock_clear(
1682 State(state.clone()),
1683 Json(MuxspectDockClearRequest { block_id: "block-1".to_string(), node_id: "n1".to_string() }),
1684 )
1685 .await
1686 .into_response();
1687 assert_eq!(resp.status(), StatusCode::OK);
1688 assert!(
1689 !state.dock_snapshots.has_node("block-1", "n1"),
1690 "a cleared node must not still be reported by a subsequent `dock` read"
1691 );
1692 }
1693
1694 async fn json_body(resp: axum::response::Response) -> serde_json::Value {
1695 let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1696 serde_json::from_slice(&body).unwrap()
1697 }
1698
1699 #[tokio::test]
1700 async fn handle_muxspect_background_tasks_scoped_to_block_includes_terminal_tasks() {
1701 let state = crate::server::tests::test_state();
1702 state.mstore.background_task_observe("t1", "block-1", "task dev", 0, 0).unwrap();
1703 state.mstore.background_task_observe("t2", "block-1", "finished build", 0, 0).unwrap();
1704 state
1705 .mstore
1706 .background_task_complete(
1707 "t2",
1708 crate::backend::storage::background_tasks::BackgroundTaskStatus::Done,
1709 500,
1710 )
1711 .unwrap();
1712 state.mstore.background_task_observe("t3", "block-2", "other pane", 0, 0).unwrap();
1714
1715 let resp = handle_muxspect_background_tasks(
1716 State(state),
1717 Query(MuxspectBackgroundTasksQuery { block_id: Some("block-1".to_string()) }),
1718 )
1719 .await
1720 .into_response();
1721 assert_eq!(resp.status(), StatusCode::OK);
1722 let body = json_body(resp).await;
1723 let ids: Vec<&str> = body["tasks"].as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap()).collect();
1724 assert_eq!(ids, vec!["t1", "t2"], "block-scoped query includes terminal tasks for that block");
1725 }
1726
1727 #[tokio::test]
1728 async fn handle_muxspect_background_tasks_without_block_id_lists_running_globally() {
1729 let state = crate::server::tests::test_state();
1730 state.mstore.background_task_observe("t1", "block-1", "still running", 0, 0).unwrap();
1731 state.mstore.background_task_observe("t2", "block-2", "also running", 0, 0).unwrap();
1732 state.mstore.background_task_observe("t3", "block-1", "finished", 0, 0).unwrap();
1733 state
1734 .mstore
1735 .background_task_complete(
1736 "t3",
1737 crate::backend::storage::background_tasks::BackgroundTaskStatus::Done,
1738 500,
1739 )
1740 .unwrap();
1741
1742 let resp = handle_muxspect_background_tasks(
1743 State(state),
1744 Query(MuxspectBackgroundTasksQuery { block_id: None }),
1745 )
1746 .await
1747 .into_response();
1748 assert_eq!(resp.status(), StatusCode::OK);
1749 let body = json_body(resp).await;
1750 let ids: Vec<&str> = body["tasks"].as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap()).collect();
1751 assert_eq!(ids, vec!["t1", "t2"], "global query is running-only, across every block");
1752 }
1753
1754 fn candidate(name: &str, tier: &'static str, last_seen_ms: Option<i64>, stale_eligible: bool) -> SenderCandidate {
1755 SenderCandidate { name: name.to_string(), tier, last_seen_ms, stale_eligible, channel: None, local_url: None }
1756 }
1757
1758 #[test]
1759 fn classify_sender_not_found_on_empty_candidates() {
1760 let verdict = classify_sender("AgentA", &[], 1_000_000);
1761 assert_eq!(verdict.status, "not_found");
1762 assert_eq!(verdict.tier, None);
1763 }
1764
1765 #[test]
1766 fn classify_sender_found_on_host_tier() {
1767 let candidates = [candidate("AgentA", "host", Some(999_000), false)];
1768 let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1769 assert_eq!(verdict.status, "found");
1770 assert_eq!(verdict.tier, Some("host"));
1771 }
1772
1773 #[test]
1774 fn classify_sender_found_on_cross_channel_tier() {
1775 let candidates = [candidate("AgentA", "cross-channel", Some(999_000), true)];
1776 let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1777 assert_eq!(verdict.status, "found");
1778 assert_eq!(verdict.tier, Some("cross-channel"));
1779 }
1780
1781 #[test]
1782 fn classify_sender_found_on_lan_or_wan_tier() {
1783 let lan = classify_sender("Peer", &[candidate("Peer", "lan", Some(999_000), true)], 1_000_000);
1784 assert_eq!(lan.status, "found");
1785 let wan = classify_sender("Peer", &[candidate("Peer", "wan", None, false)], 1_000_000);
1786 assert_eq!(wan.status, "found");
1787 }
1788
1789 #[test]
1790 fn classify_sender_name_match_is_case_insensitive() {
1791 let candidates = [candidate("agenta", "host", Some(999_000), false)];
1792 let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1793 assert_eq!(verdict.status, "found");
1794 }
1795
1796 #[test]
1804 fn classify_sender_host_tier_never_stale_regardless_of_age() {
1805 let now_ms = 1_000_000;
1806 let ancient = now_ms - 10 * crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64;
1807 let candidates = [candidate("AgentA", "host", Some(ancient), false)];
1808 let verdict = classify_sender("AgentA", &candidates, now_ms);
1809 assert_eq!(verdict.status, "found", "host tier is stale_eligible: false — age must never matter");
1810 }
1811
1812 #[test]
1819 fn classify_sender_stale_when_last_seen_past_the_real_grace_threshold() {
1820 let now_ms = 1_000_000;
1821 let stale_ts = now_ms - crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64;
1822 let candidates = [candidate("AgentA", "cross-channel", Some(stale_ts), true)];
1823 let verdict = classify_sender("AgentA", &candidates, now_ms);
1824 assert_eq!(verdict.status, "stale");
1825 assert_eq!(verdict.tier, Some("cross-channel"), "still reports which tier it was found on");
1826 }
1827
1828 #[test]
1829 fn classify_sender_wan_tier_has_no_last_seen_and_is_never_stale() {
1830 let candidates = [candidate("AgentA", "wan", None, false)];
1834 let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1835 assert_eq!(verdict.status, "found");
1836 }
1837
1838 #[test]
1843 fn classify_sender_reports_first_matching_tier_when_present_on_multiple() {
1844 let candidates = [
1845 candidate("AgentA", "host", Some(999_000), false),
1846 candidate("AgentA", "cross-channel", Some(999_000), true),
1847 ];
1848 let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1849 assert_eq!(verdict.tier, Some("host"));
1850 }
1851
1852 #[test]
1860 fn classify_sender_prefers_a_live_candidate_over_an_earlier_stale_one() {
1861 let now_ms = 1_000_000;
1862 let stale_ts = now_ms - crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64;
1863 let candidates = [
1864 candidate("AgentA", "cross-channel", Some(stale_ts), true), candidate("AgentA", "lan", Some(999_000), true), ];
1867 let verdict = classify_sender("AgentA", &candidates, now_ms);
1868 assert_eq!(verdict.status, "found");
1869 assert_eq!(verdict.tier, Some("lan"));
1870 }
1871
1872 #[test]
1873 fn classify_sender_falls_back_to_first_stale_match_when_all_are_stale() {
1874 let now_ms = 1_000_000;
1875 let stale_ts = now_ms - crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64;
1876 let candidates = [
1877 candidate("AgentA", "cross-channel", Some(stale_ts), true),
1878 candidate("AgentA", "lan", Some(stale_ts), true),
1879 ];
1880 let verdict = classify_sender("AgentA", &candidates, now_ms);
1881 assert_eq!(verdict.status, "stale");
1882 assert_eq!(verdict.tier, Some("cross-channel"), "falls back to the most-trusted tier among stale matches");
1883 }
1884
1885 #[tokio::test]
1886 async fn handle_muxspect_verify_sender_rejects_empty_name() {
1887 let state = crate::server::tests::test_state();
1888 let resp = handle_muxspect_verify_sender(State(state), Query(MuxspectVerifySenderQuery { name: String::new() }))
1889 .await
1890 .into_response();
1891 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1892 }
1893
1894 #[tokio::test]
1895 async fn handle_muxspect_verify_sender_not_found_is_still_200() {
1896 let state = crate::server::tests::test_state();
1900 let resp = handle_muxspect_verify_sender(
1901 State(state),
1902 Query(MuxspectVerifySenderQuery { name: "NoSuchAgent".to_string() }),
1903 )
1904 .await
1905 .into_response();
1906 assert_eq!(resp.status(), StatusCode::OK);
1907 let body = json_body(resp).await;
1908 assert_eq!(body["status"], "not_found");
1909 }
1910
1911 #[tokio::test]
1912 async fn handle_muxspect_conversations_empty_state_is_still_200() {
1913 let state = crate::server::tests::test_state();
1917 let resp = handle_muxspect_conversations(State(state)).await.into_response();
1918 assert_eq!(resp.status(), StatusCode::OK);
1919 let body = json_body(resp).await;
1920 assert!(body["agents"].is_array());
1921 }
1922
1923 #[tokio::test]
1924 async fn handle_muxspect_conversations_includes_a_registered_host_agent() {
1925 let state = crate::server::tests::test_state();
1926 let unique = uuid::Uuid::new_v4();
1927 let agent_id = format!("conversations-test-agent-{unique}");
1928 let block_id = format!("conversations-test-block-{unique}");
1929 state
1930 .reactive_handler
1931 .register_agent(&agent_id, &block_id, None)
1932 .unwrap();
1933
1934 let resp = handle_muxspect_conversations(State(state)).await.into_response();
1935 assert_eq!(resp.status(), StatusCode::OK);
1936 let body = json_body(resp).await;
1937 let agents = body["agents"].as_array().expect("agents array");
1938 let entry = agents
1939 .iter()
1940 .find(|a| a["name"] == agent_id)
1941 .expect("registered agent should appear in the listing");
1942 assert_eq!(entry["tier"], "host");
1943 assert_eq!(entry["last_message_preview"], serde_json::Value::Null);
1948 }
1949
1950 fn insert_bare_block(
1955 mstore: &std::sync::Arc<crate::backend::storage::store::Store>,
1956 block_id: &str,
1957 ) {
1958 use crate::backend::obj::Block;
1959 let mut block = Block {
1960 oid: block_id.to_string(),
1961 parentoref: String::new(),
1962 version: 1,
1963 runtimeopts: None,
1964 stickers: None,
1965 meta: Default::default(),
1966 subblockids: None,
1967 };
1968 mstore.insert(&mut block).expect("mstore insert");
1969 }
1970
1971 #[test]
1972 fn last_line_preview_and_activity_returns_last_non_blank_line() {
1973 let filestore = std::sync::Arc::new(FileStore::open_in_memory().unwrap());
1974 let mstore = std::sync::Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1975 let block_id = uuid::Uuid::new_v4().to_string();
1976 filestore
1977 .make_file(&block_id, "output", crate::backend::storage::filestore::FileMeta::default(), crate::backend::storage::filestore::FileOpts::default())
1978 .expect("make_file");
1979 filestore
1980 .append_data(&block_id, "output", b"first line\n\nlast line\n")
1981 .expect("append_data");
1982 insert_bare_block(&mstore, &block_id);
1983
1984 let (preview, activity_ms) = last_line_preview_and_activity(&mstore, &filestore, &block_id);
1985 assert_eq!(preview, Some("last line".to_string()));
1986 assert!(activity_ms.is_some());
1989 }
1990
1991 #[test]
1992 fn last_line_preview_and_activity_truncates_long_lines() {
1993 let filestore = std::sync::Arc::new(FileStore::open_in_memory().unwrap());
1994 let mstore = std::sync::Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1995 let block_id = uuid::Uuid::new_v4().to_string();
1996 let long_line = "x".repeat(PREVIEW_MAX_CHARS + 50);
1997 filestore
1998 .make_file(&block_id, "output", crate::backend::storage::filestore::FileMeta::default(), crate::backend::storage::filestore::FileOpts::default())
1999 .expect("make_file");
2000 filestore
2001 .append_data(&block_id, "output", format!("{long_line}\n").as_bytes())
2002 .expect("append_data");
2003 insert_bare_block(&mstore, &block_id);
2004
2005 let (preview, _) = last_line_preview_and_activity(&mstore, &filestore, &block_id);
2006 let preview = preview.unwrap();
2007 assert_eq!(preview.chars().count(), PREVIEW_MAX_CHARS + 1); assert!(preview.ends_with('…'));
2009 }
2010
2011 #[test]
2012 fn last_line_preview_and_activity_returns_none_for_missing_block() {
2013 let filestore = std::sync::Arc::new(FileStore::open_in_memory().unwrap());
2014 let mstore = std::sync::Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2015 let (preview, activity_ms) = last_line_preview_and_activity(&mstore, &filestore, "no-such-block");
2016 assert_eq!(preview, None);
2017 assert_eq!(activity_ms, None);
2018 }
2019
2020 #[test]
2024 fn flatten_layout_nodes_reports_depth_path_and_effective_minimize() {
2025 use agentmux_common::{FlexDirection, LayoutNode, LayoutNodeData};
2026
2027 fn leaf(id: &str, block: &str, minimized: bool) -> LayoutNode {
2028 let mut n = LayoutNode {
2029 id: id.to_string(),
2030 flex_direction: FlexDirection::Row,
2031 size: 10.0,
2032 children: Vec::new(),
2033 data: Some(LayoutNodeData {
2034 block_id: block.to_string(),
2035 ..Default::default()
2036 }),
2037 ..Default::default()
2038 };
2039 if minimized {
2040 n.extra.insert("minimized".to_string(), json!(true));
2043 }
2044 n
2045 }
2046
2047 let inner = LayoutNode {
2049 id: "inner".into(),
2050 flex_direction: FlexDirection::Row,
2051 size: 10.0,
2052 children: vec![leaf("a", "blk-a", true), leaf("b", "blk-b", true)],
2053 data: None,
2054 ..Default::default()
2055 };
2056 let root = LayoutNode {
2057 id: "root".into(),
2058 flex_direction: FlexDirection::Column,
2059 size: 10.0,
2060 children: vec![leaf("top", "blk-top", false), inner, leaf("bot", "blk-bot", false)],
2061 data: None,
2062 ..Default::default()
2063 };
2064
2065 let mut out = Vec::new();
2066 flatten_layout_nodes(&root, 0, "", &mut out);
2067
2068 assert_eq!(out.len(), 6);
2070 assert_eq!(out[0]["id"], "root");
2071 assert_eq!(out[0]["depth"], 0);
2072 assert_eq!(out[0]["kind"], "branch");
2073 assert_eq!(out[0]["flex_direction"], "column");
2074
2075 assert_eq!(out[1]["path"], "0");
2077 assert_eq!(out[2]["path"], "1");
2078 assert_eq!(out[3]["path"], "1.0");
2079 assert_eq!(out[3]["depth"], 2);
2080
2081 assert_eq!(out[2]["id"], "inner");
2086 assert_eq!(out[2]["locked"], false);
2087 assert_eq!(out[2]["effectively_minimized"], true);
2088 assert_eq!(out[2]["child_count"], 2);
2089
2090 assert_eq!(out[3]["block_id"], "blk-a");
2092 assert_eq!(out[3]["locked"], true);
2093 assert_eq!(out[1]["locked"], false);
2094 assert_eq!(out[1]["effectively_minimized"], false);
2095 }
2096
2097 #[test]
2098 fn truncate_preview_is_shared_by_host_and_cross_channel_paths() {
2099 let long = "y".repeat(PREVIEW_MAX_CHARS + 10);
2105 let truncated = truncate_preview(&long);
2106 assert_eq!(truncated.chars().count(), PREVIEW_MAX_CHARS + 1);
2107 assert!(truncated.ends_with('…'));
2108
2109 let short = "short line";
2110 assert_eq!(truncate_preview(short), short);
2111 }
2112}