confer-cli 0.6.6

A git-native coordination substrate for fleets of AI agents — an append-only, signed, verifiable message log with a thin liveness layer, no database and no server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! Pure projections over the message log — the folds that answer "what's the state
//! of the board?" and "who's on this hub?", returning **structs** (never printing).
//!
//! Extracted from the `cmd_*` handlers so the same folds drive the CLI's stdout
//! rendering, the `dashboard` TUI, and the `serve` web view. The core
//! folds (`Board::fold`, `agents`) are pure functions of already-loaded data. The
//! `Snapshot` fold on top loads a hub from disk (messages, roster, presence, git
//! health) into one render-ready struct that every front-end shares.

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};

/// Strict id match for the **fold layer**: exact full id, or a trailing suffix of
/// ≥8 chars (the random ULID tail — the documented short-id affordance, safe
/// because it's random). Never leading-prefix (ULIDs share a timestamp prefix) and
/// never empty. `of` is resolved to a full id at append time, so exact is the
/// normal path; the suffix tolerates legacy refs.
pub fn id_ref_matches(full: &str, reference: &str) -> bool {
    !reference.is_empty()
        && (full == reference || (reference.len() >= 8 && full.ends_with(reference)))
}

/// Fold a request's terminal/active status from the log. DONE/ERROR/SUPERSEDED are
/// terminal; BLOCKED is a stall a `claim` clears; else CLAIMED or OPEN.
pub fn request_status(msgs: &[Message], req_id: &str) -> &'static str {
    // A superseded request is withdrawn/replaced — a terminal state.
    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",
                // claiming (re-)commits to active work, clearing a prior block.
                "claim" => st = "CLAIMED",
                _ => {}
            }
        }
    }
    st
}

/// Is a request deferred to the backlog? — its sender-time `defer` facet OR a later
/// `defer` event (settable by anyone, e.g. the addressee), cleared once someone
/// `claim`s it (committing to active work). Event-sourced, DESIGN.md Phase 2.
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
}

/// The resolution recorded on a request's closing `done`, if any.
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())
}

/// Distinct roles that have `claim`ed a request, in first-claim order (the head is
/// the owner; a tail means a contested claim race).
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
}

/// A stale-open request is flagged for debt visibility.
pub const STALE_SECS: i64 = 3 * 86400;

/// One request as the board sees it — raw data (ids/roles unresolved), so the
/// renderer owns display formatting (short-id, roster display, glyphs).
#[derive(Debug, Clone)]
pub struct RequestRow {
    pub id: String,
    pub from: String,
    pub to: Vec<String>,
    pub summary: String,
    /// OPEN | CLAIMED | BLOCKED | DONE | ERROR | SUPERSEDED.
    pub status: &'static str,
    /// Resolution on a closing `done` (wont-do | obsolete | duplicate).
    pub resolution: Option<String>,
    /// Deferred to backlog (off the active board).
    pub deferred: bool,
    /// Distinct claimants, first-claim order (head = owner; tail = contested).
    pub claimants: Vec<String>,
    pub age_secs: i64,
    /// Open (OPEN|CLAIMED) and older than STALE_SECS — surfaced as debt.
    pub stale: bool,
}

impl RequestRow {
    pub fn is_open(&self) -> bool {
        matches!(self.status, "OPEN" | "CLAIMED")
    }
    /// On the ACTIVE board = open and not deferred and not blocked.
    pub fn is_active(&self) -> bool {
        self.is_open() && !self.deferred
    }
    /// On the backlog = deferred while still in a live state.
    pub fn is_backlog(&self) -> bool {
        self.deferred && matches!(self.status, "OPEN" | "CLAIMED" | "BLOCKED")
    }
}

/// The whole board folded from the log: every request as a row (sorted by id =
/// chronological), plus the cumulative-flow tally and per-agent WIP.
#[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 {
    /// Fold the board from all messages as of `now` (for age/stale).
    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,
            };

            // Flow tally: backlog wins over its underlying live state.
            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
    }
}

/// One agent as the roster/presence fold sees it — resolved display fields so a
/// renderer only formats. `presence` is carried raw so the renderer computes
/// liveness against its own `now`.
#[derive(Debug, Clone)]
pub struct AgentRow {
    pub id: String,
    pub display: String,
    pub desc: Option<String>,
    pub expected_host: Option<String>,
    /// Most recent post ts (RFC3339) and the host it came from, if any.
    pub last_ts: Option<String>,
    pub last_host: Option<String>,
    pub presence: Option<presence::Presence>,
    /// Cross-hub appearances of the same signing key: (hub_label, role) pairs (F3).
    pub xhub: Vec<(String, String)>,
}

/// Fold the agent list: union of roster ids, observed authors, and presence roles,
/// each resolved to display + last-post + liveness + cross-hub identity. Pure over
/// the passed data — the caller supplies presence and the cross-hub index (both of
/// which involve I/O it controls: a presence fetch, the `~/.confer/hubs.json` scan).
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;
    // last-posted (ts, host) per role id.
    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());
        }
    }
    // union of roster ids + observed authors + presence roles.
    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()
}

/// Free disk (GB) at `root`'s filesystem — a resilience signal (low disk stalls
/// git/watch). `None` if `df` is unavailable/unparsable.
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)
}

/// How many recent messages a snapshot's activity tail keeps.
pub const TAIL: usize = 40;

/// Folded health of one hub. Local-only probes here; `reachable` is left `None`
/// (unknown) for a live front-end's own sync worker to fill from its integrate
/// outcome — never a per-tick `ls-remote`.
#[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>,
}

/// One line in the activity tail (raw ids; the renderer resolves display).
#[derive(Clone)]
pub struct TailItem {
    pub time: String,
    pub from: String,
    pub to: Vec<String>,
    pub kind: String,
    pub summary: String,
}

/// A hub folded into one render-ready struct — shared by the TUI and the web view
/// so both show identical state. Everything a front-end needs, no front-end types.
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 {
    /// Fold a hub from its local clone. `fetch` controls whether presence refs are
    /// fetched over the network (a live worker passes true; a cheap re-fold passes
    /// false and reads whatever presence is already local). A non-hub or unreadable
    /// dir folds to an error snapshot rather than failing.
    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()),
        }
    }
}

/// Local-only health probe (no network): role, pending/behind vs upstream, watch
/// state, free disk. `reachable` stays `None` until a live worker sets it.
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,
    }
}

/// Compact target rendering: display names joined, `all` kept literal, empty → `—`.
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(", ")
}