use crate::schema::Message;
use crate::{config, crosshub, gitcmd, presence, roster, store, watchlock};
use chrono::{DateTime, Utc};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub fn id_ref_matches(full: &str, reference: &str) -> bool {
!reference.is_empty()
&& (full == reference || (reference.len() >= 8 && full.ends_with(reference)))
}
pub fn request_status(msgs: &[Message], req_id: &str) -> &'static str {
if msgs
.iter()
.any(|m| m.front.supersedes.as_deref().is_some_and(|s| id_ref_matches(req_id, s)))
{
return "SUPERSEDED";
}
let mut st = "OPEN";
for m in msgs {
if m.front.of.as_deref().is_some_and(|of| id_ref_matches(req_id, of)) {
match m.front.msg_type.as_str() {
"done" => return "DONE",
"error" => return "ERROR",
"blocked" => st = "BLOCKED",
"claim" => st = "CLAIMED",
_ => {}
}
}
}
st
}
pub fn request_deferred(msgs: &[Message], req: &Message) -> bool {
let mut deferred = req.front.defer;
for m in msgs {
if m.front.of.as_deref().is_some_and(|of| id_ref_matches(&req.front.id, of)) {
match m.front.msg_type.as_str() {
"defer" => deferred = true,
"claim" => deferred = false,
_ => {}
}
}
}
deferred
}
pub fn done_resolution(msgs: &[Message], req_id: &str) -> Option<String> {
msgs.iter()
.filter(|m| m.front.msg_type == "done" && m.front.of.as_deref().is_some_and(|of| id_ref_matches(req_id, of)))
.find_map(|m| m.front.resolution.clone())
}
pub fn claimants(msgs: &[Message], req_id: &str) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for m in msgs {
if m.front.msg_type == "claim"
&& m.front.of.as_deref().is_some_and(|of| id_ref_matches(req_id, of))
&& !seen.contains(&m.front.from)
{
seen.push(m.front.from.clone());
}
}
seen
}
pub const STALE_SECS: i64 = 3 * 86400;
#[derive(Debug, Clone)]
pub struct RequestRow {
pub id: String,
pub from: String,
pub to: Vec<String>,
pub summary: String,
pub status: &'static str,
pub resolution: Option<String>,
pub deferred: bool,
pub claimants: Vec<String>,
pub age_secs: i64,
pub stale: bool,
}
impl RequestRow {
pub fn is_open(&self) -> bool {
matches!(self.status, "OPEN" | "CLAIMED")
}
pub fn is_active(&self) -> bool {
self.is_open() && !self.deferred
}
pub fn is_backlog(&self) -> bool {
self.deferred && matches!(self.status, "OPEN" | "CLAIMED" | "BLOCKED")
}
}
#[derive(Debug, Clone, Default)]
pub struct Board {
pub rows: Vec<RequestRow>,
pub open: usize,
pub claimed: usize,
pub blocked: usize,
pub backlog: usize,
pub closed: usize,
pub wip: BTreeMap<String, usize>,
}
impl Board {
pub fn fold(msgs: &[Message], now: DateTime<Utc>) -> Board {
let mut reqs: Vec<&Message> = msgs.iter().filter(|m| m.front.msg_type == "request").collect();
reqs.sort_by(|a, b| a.front.id.cmp(&b.front.id));
let mut board = Board::default();
for r in reqs {
let status = request_status(msgs, &r.front.id);
let deferred = request_deferred(msgs, r);
let cs = if status == "CLAIMED" { claimants(msgs, &r.front.id) } else { Vec::new() };
let resolution = done_resolution(msgs, &r.front.id);
let age_secs = DateTime::parse_from_rfc3339(&r.front.ts)
.ok()
.map(|t| (now - t.with_timezone(&Utc)).num_seconds().max(0))
.unwrap_or(0);
let row = RequestRow {
id: r.front.id.clone(),
from: r.front.from.clone(),
to: r.front.to.clone(),
summary: r.summary_line(),
status,
resolution,
deferred,
claimants: cs,
age_secs,
stale: matches!(status, "OPEN" | "CLAIMED") && age_secs >= STALE_SECS,
};
if row.is_backlog() {
board.backlog += 1;
} else {
match status {
"OPEN" => board.open += 1,
"CLAIMED" => {
board.claimed += 1;
for c in &row.claimants {
*board.wip.entry(c.clone()).or_default() += 1;
}
}
"BLOCKED" => board.blocked += 1,
_ => board.closed += 1,
}
}
board.rows.push(row);
}
board
}
}
#[derive(Debug, Clone)]
pub struct AgentRow {
pub id: String,
pub display: String,
pub desc: Option<String>,
pub expected_host: Option<String>,
pub last_ts: Option<String>,
pub last_host: Option<String>,
pub presence: Option<presence::Presence>,
pub xhub: Vec<(String, String)>,
}
pub fn agents(
msgs: &[Message],
roster: &roster::Roster,
pres: &std::collections::HashMap<String, presence::Presence>,
xhub: &std::collections::HashMap<String, Vec<(String, String)>>,
) -> Vec<AgentRow> {
use std::collections::HashMap;
let mut last: HashMap<String, (String, Option<String>)> = HashMap::new();
for m in msgs {
let e = last.entry(m.front.from.clone()).or_insert_with(|| (String::new(), None));
if m.front.ts > e.0 {
*e = (m.front.ts.clone(), m.front.host.clone());
}
}
let mut ids: Vec<String> = roster.keys().cloned().collect();
for k in last.keys().chain(pres.keys()) {
if !roster.contains_key(k) {
ids.push(k.clone());
}
}
ids.sort();
ids.dedup();
ids.into_iter()
.map(|id| {
let xh = roster::pubkey(roster, &id)
.and_then(|pk| xhub.get(pk))
.cloned()
.unwrap_or_default();
let (last_ts, last_host) = match last.get(&id) {
Some((t, h)) if !t.is_empty() => (Some(t.clone()), h.clone()),
_ => (None, None),
};
AgentRow {
display: roster::display(roster, &id).to_string(),
desc: roster.get(&id).and_then(|r| r.desc.clone()),
expected_host: roster::host(roster, &id).map(str::to_string),
last_ts,
last_host,
presence: pres.get(&id).cloned(),
xhub: xh,
id,
}
})
.collect()
}
pub fn disk_free_gb(root: &std::path::Path) -> Option<f64> {
let o = std::process::Command::new("df").args(["-Pk", &root.to_string_lossy()]).output().ok()?;
if !o.status.success() {
return None;
}
let s = String::from_utf8_lossy(&o.stdout);
let avail_kb: f64 = s.lines().nth(1)?.split_whitespace().nth(3)?.parse().ok()?;
Some(avail_kb / 1024.0 / 1024.0)
}
pub const TAIL: usize = 40;
#[derive(Clone)]
pub struct Health {
pub role: String,
pub reachable: Option<bool>,
pub pending: Option<u64>,
pub behind: Option<u64>,
pub watch: Option<watchlock::WatchState>,
pub disk_gb: Option<f64>,
}
#[derive(Clone)]
pub struct TailItem {
pub time: String,
pub from: String,
pub to: Vec<String>,
pub kind: String,
pub summary: String,
}
pub struct Snapshot {
pub dir: PathBuf,
pub label: String,
pub roster: roster::Roster,
pub board: Board,
pub agents: Vec<AgentRow>,
pub tail: Vec<TailItem>,
pub health: Health,
pub error: Option<String>,
}
impl Snapshot {
pub fn load(dir: PathBuf, fetch: bool) -> Snapshot {
let label = crosshub::hub_label(&dir);
let now = Utc::now();
if !dir.join("threads").is_dir() && !dir.join("roles").is_dir() {
return Snapshot::errored(dir, label, "not a confer hub (no threads/ or roles/)");
}
let roster = roster::load(&dir);
let msgs = match store::all_messages(&dir) {
Ok(m) => m,
Err(e) => return Snapshot::errored(dir, label, &format!("cannot read messages: {e}")),
};
let pres: std::collections::HashMap<String, presence::Presence> = presence::load_all(&dir, fetch)
.into_iter()
.map(|p| (p.role.clone(), p))
.collect();
let xhub = crosshub::appearances(&dir);
let board = Board::fold(&msgs, now);
let agents = agents(&msgs, &roster, &pres, &xhub);
let mut sorted: Vec<&Message> = msgs.iter().collect();
sorted.sort_by(|a, b| a.front.id.cmp(&b.front.id));
let tail: Vec<TailItem> = sorted
.iter()
.rev()
.take(TAIL)
.rev()
.map(|m| TailItem {
time: m.front.ts.get(11..16).unwrap_or("").to_string(),
from: m.front.from.clone(),
to: m.front.to.clone(),
kind: m.front.msg_type.clone(),
summary: m.summary_line(),
})
.collect();
let health = probe_health(&dir);
Snapshot { dir, label, roster, board, agents, tail, health, error: None }
}
pub fn errored(dir: PathBuf, label: String, msg: &str) -> Snapshot {
Snapshot {
dir,
label,
roster: roster::Roster::new(),
board: Board::default(),
agents: Vec::new(),
tail: Vec::new(),
health: Health { role: String::new(), reachable: Some(false), pending: None, behind: None, watch: None, disk_gb: None },
error: Some(msg.to_string()),
}
}
}
pub fn probe_health(dir: &Path) -> Health {
let role = config::resolve_role(None, dir).unwrap_or_default();
let count = |range: &str| {
gitcmd::output(dir, &["rev-list", "--count", range])
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<u64>().ok())
};
let watch = if role.is_empty() {
None
} else {
Some(watchlock::classify(&watchlock::inspect(&config::hub_key(dir), &role, 90), env!("CARGO_PKG_VERSION")))
};
Health {
reachable: None,
pending: count("@{u}..HEAD"),
behind: count("HEAD..@{u}"),
watch,
disk_gb: disk_free_gb(dir),
role,
}
}
pub fn render_targets(roster: &roster::Roster, targets: &[String]) -> String {
if targets.is_empty() {
return "—".to_string();
}
targets
.iter()
.map(|t| if t == "all" { "all".to_string() } else { roster::display(roster, t).to_string() })
.collect::<Vec<_>>()
.join(", ")
}