Skip to main content

borg_web/
lib.rs

1//! `borg web [--port 7979] [--dir DIR]`: a live page of the collective's knowledge and usage statistics,
2//! on 127.0.0.1 only.
3//!
4//! It reads the stores the way `borg top` does (files, no IPC with `borg mesh serve`), so local
5//! collectives show too. `/api/events` is a server-sent event stream: a 1 s watch on the store files
6//! tells the page which views changed, and the page refetches only those. Once a minute it publishes
7//! this node's daily rollups (borg stats.rs) so the mesh can spread them.
8//!
9//! The binary (main.rs) parses the command line and serves [`router`]; tests drive it in process.
10
11use std::collections::BTreeMap;
12use std::convert::Infallible;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::Duration;
16
17use axum::Router;
18use axum::extract::{Query, State};
19use axum::http::{StatusCode, header};
20use axum::response::sse::{Event, KeepAlive, Sse};
21use axum::response::{IntoResponse, Json, Response};
22use axum::routing::get;
23use borg::collective::{self, Route, Share};
24use borg::{conscience, install, learn, memory, msg, outcome, presence, stats, top};
25use futures_util::{Stream, StreamExt};
26
27pub mod learning;
28use serde::Deserialize;
29use serde_json::{Value, json};
30
31pub const USAGE: &str = "usage: borg web [--port PORT] [--dir DIR]   (default port 7979; DIR picks the collective shown first)";
32
33/// One collective the page can show.
34#[derive(Clone)]
35pub struct Mind {
36    pub name: String,
37    pub home: PathBuf,
38    pub share: Share,
39    /// The delivery mode its drones run in (borg live.rs: `hook` or `live`).
40    pub mode: &'static str,
41}
42
43pub struct App {
44    pub minds: Vec<Mind>,
45    /// The collective the page opens with: the one the start directory routes to.
46    pub first: String,
47    /// Turns true on Ctrl-C. Event streams end on it, else graceful shutdown waits for every open page.
48    pub stop: tokio::sync::watch::Receiver<bool>,
49}
50
51impl App {
52    fn mind(&self, name: Option<&str>) -> Option<&Mind> {
53        let name = name.unwrap_or(&self.first);
54        self.minds.iter().find(|m| m.name == name)
55    }
56}
57
58#[derive(Deserialize)]
59struct Pick {
60    c: Option<String>,
61}
62
63/// Every collective on this machine, and the one `dir` routes to (else the first).
64pub fn minds(dir: &str) -> Result<(Vec<Mind>, String), String> {
65    let cfg = collective::load()?;
66    let base = learn::home();
67    let minds: Vec<Mind> = install::collectives(cfg.as_ref(), &base)
68        .into_iter()
69        .filter_map(|r| match r {
70            Route::Collective { name, home, share } => {
71                let mode = borg::live::of(cfg.as_ref(), &name).mode.name();
72                Some(Mind { name, home, share, mode })
73            }
74            Route::Off => None,
75        })
76        .collect();
77    let first = match collective::route(cfg.as_ref(), dir, &base) {
78        Route::Collective { name, .. } => name,
79        Route::Off => minds.first().map(|m| m.name.clone()).unwrap_or_default(),
80    };
81    Ok((minds, first))
82}
83
84fn not_found(what: &str) -> Response {
85    (StatusCode::NOT_FOUND, format!("no collective {what}")).into_response()
86}
87
88async fn collectives(State(app): State<Arc<App>>) -> Json<Value> {
89    let list: Vec<Value> =
90        app.minds.iter().map(|m| json!({"name": m.name, "share": if m.share == Share::Team { "team" } else { "local" }, "mode": m.mode})).collect();
91    Json(json!({"first": app.first, "collectives": list}))
92}
93
94/// Buckets summed per key (day or engineer), in key order.
95fn sum_by(buckets: &[stats::Bucket], key: impl Fn(&stats::Bucket) -> String) -> Vec<(String, stats::Bucket)> {
96    let mut out: BTreeMap<String, stats::Bucket> = BTreeMap::new();
97    for b in buckets {
98        out.entry(key(b)).or_default().add(b);
99    }
100    out.into_iter().collect()
101}
102
103fn totals(b: &stats::Bucket) -> Value {
104    json!({"counts": b.counts, "usd": b.usd, "usd_by": b.usd_by, "usd_mode": b.usd_mode, "channels": b.channels, "dur_ms": b.dur_ms})
105}
106
107/// One entry per day from `first` to `last`, zero where nothing was counted.
108fn series(buckets: &[stats::Bucket], first: &str, last: &str) -> Vec<Value> {
109    let by: BTreeMap<String, stats::Bucket> = sum_by(buckets, |b| b.day.clone()).into_iter().collect();
110    stats::days_between(first, last)
111        .into_iter()
112        .map(|d| {
113            let t = by.get(&d).map(totals).unwrap_or_else(|| totals(&stats::Bucket::default()));
114            json!({"day": d, "totals": t})
115        })
116        .collect()
117}
118
119async fn stats_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
120    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
121    let buckets = stats::current(&m.home);
122    let mut all = stats::Bucket::default();
123    for b in &buckets {
124        all.add(b);
125    }
126    let aliases = collective::aliases();
127    let first = buckets.iter().map(|b| b.day.as_str()).min().unwrap_or_default().to_string();
128    let last = buckets.iter().map(|b| b.day.as_str()).max().unwrap_or_default().to_string();
129    let days = series(&buckets, &first, &last);
130    let engineers: Vec<Value> = sum_by(&buckets, |b| aliases.display(&b.engineer))
131        .into_iter()
132        .map(|(e, t)| {
133            let mine: Vec<stats::Bucket> = buckets.iter().filter(|b| aliases.display(&b.engineer) == e).cloned().collect();
134            json!({"engineer": e, "totals": totals(&t), "days": series(&mine, &first, &last)})
135        })
136        .collect();
137    let nodes = buckets.iter().map(|b| b.node.as_str()).collect::<std::collections::BTreeSet<_>>().len();
138    // `borg debug ope`'s support, one pass over this machine's log (pools are never shared); the estimate itself
139    // derives beliefs per decision, too heavy to poll.
140    let (ds, skipped) = borg::ope::decisions(&outcome::read(&m.home.join("outcomes.jsonl")));
141    let ope = json!({"decisions": ds.len(), "no_p": skipped.no_p, "no_pool": skipped.no_pool});
142    Json(json!({"collective": m.name, "totals": totals(&all), "days": days, "engineers": engineers, "nodes": nodes, "ope": ope})).into_response()
143}
144
145async fn lessons(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
146    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
147    let path = m.home.join("lessons.jsonl");
148    let list = conscience::load_unweighted(&path, None);
149    let outcomes = outcome::read_team(&path);
150    let mem = memory::derive(&outcomes, &list, None);
151    // How often each lesson took part in what stats.rs counted (asked in a block, so far), team-wide.
152    let mut usage: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
153    for b in stats::current(&m.home) {
154        for (id, kinds) in b.lessons {
155            let u = usage.entry(id).or_default();
156            for (k, v) in kinds {
157                *u.entry(k).or_default() += v;
158            }
159        }
160    }
161    let aliases = collective::aliases();
162    let stored: BTreeMap<String, (u32, String)> = learn::lessons(&path).into_iter().map(|l| (l.id, (l.version, l.repo))).collect();
163    let mut out: Vec<Value> = list
164        .iter()
165        .map(|c| {
166            let mm = mem.get(&c.id);
167            json!({
168                "id": c.id, "question": c.question, "situations": c.situations, "consequence": c.consequence,
169                "area": c.area, "scope": c.scope, "source": c.source, "origin_ms": c.origin_ms,
170                "engineers": c.engineers.iter().map(|e| aliases.display(e)).collect::<Vec<_>>(),
171                "activation": mm.map_or(0.0, |x| x.activation), "changed": mm.map_or(0, |x| x.changed),
172                "ignored": mm.map_or(0, |x| x.ignored), "corrected": mm.map_or(0, |x| x.corrected), "vetoed": mm.map_or(0, |x| x.vetoed),
173                "regretted": mm.map_or(0.0, |x| x.regretted),
174                "tested": mm.map_or(0, |x| x.tested),
175                "dormant": mm.is_some_and(|x| x.dormant), "consolidated": mm.is_some_and(|x| x.consolidated),
176                "usage": usage.get(&c.id).cloned().unwrap_or_default(),
177                "version": stored.get(&c.id).map_or(1, |s| s.0), "repo": stored.get(&c.id).map(|s| s.1.clone()).unwrap_or_default(),
178                "reach": c.reach,
179            })
180        })
181        .collect();
182    out.sort_by(|a, b| b["activation"].as_f64().partial_cmp(&a["activation"].as_f64()).unwrap_or(std::cmp::Ordering::Equal));
183    Json(json!({"collective": m.name, "lessons": out})).into_response()
184}
185
186/// What the feed shows: what people and drones did, not the routine of every turn end.
187const NOTABLE: [&str; 28] = [
188    stats::CORRECTION,
189    stats::VETO,
190    stats::VETO_REPEAT,
191    stats::REVIEW_FINDING,
192    stats::BLOCK,
193    stats::CHANGED,
194    stats::IGNORED,
195    stats::CORRECTED,
196    stats::REGRETTED,
197    stats::TESTED,
198    stats::LESSON_NEW,
199    stats::LESSON_MERGED,
200    stats::NOT_A_LESSON,
201    stats::STEER_ISSUED,
202    stats::STEER_DELIVERED,
203    stats::STEER_DROPPED,
204    stats::PAUSE_REFUSED,
205    stats::QUEEN_QUEUED,
206    stats::QUEEN_DELIVERED,
207    stats::QUEEN_ANSWERED,
208    stats::QUEEN_SKIPPED,
209    stats::QUEEN_DISMISSED,
210    stats::ASSIMILATED,
211    stats::EARLY,
212    stats::QUEUED_FREE,
213    stats::MODEL_CALL,
214    // Messages between sessions: each one sent, and each refused. A delivery is one row per recipient
215    // (a broadcast many): `/api/messages` says whom each was shown to instead.
216    stats::MSG_SENT,
217    stats::MSG_DROPPED,
218];
219
220#[derive(Deserialize)]
221struct FeedQuery {
222    c: Option<String>,
223    limit: Option<usize>,
224}
225
226/// This machine's notable events, newest first (peers' activity arrives as counts only).
227async fn feed(State(app): State<Arc<App>>, Query(q): Query<FeedQuery>) -> Response {
228    let Some(m) = app.mind(q.c.as_deref()) else { return not_found(q.c.as_deref().unwrap_or_default()) };
229    stats::backfill(&m.home);
230    let aliases = collective::aliases();
231    let mut evs: Vec<stats::Event> = stats::events(&m.home).into_iter().filter(|e| NOTABLE.contains(&e.kind.as_str())).collect();
232    evs.sort_by_key(|e| std::cmp::Reverse(e.ms));
233    evs.truncate(q.limit.unwrap_or(200).min(2000));
234    let out: Vec<Value> = evs
235        .into_iter()
236        .map(|e| {
237            json!({"ms": e.ms, "kind": e.kind, "engineer": aliases.display(&e.engineer), "repo": e.repo, "lessons": e.lessons,
238                "n": e.n, "usd": e.usd, "dur_ms": e.dur_ms, "why": e.why, "text": e.text})
239        })
240        .collect();
241    Json(json!({"collective": m.name, "events": out})).into_response()
242}
243
244async fn queen(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
245    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
246    let mut entries = borg::queen::entries(&m.home);
247    entries.sort_by_key(|e| std::cmp::Reverse(e.ms));
248    // The queen's attention reward (escalation.rs), the team's: folded from every stats bucket.
249    let attention = borg::escalation::team(&m.home);
250    Json(json!({"collective": m.name, "entries": entries, "attention": attention})).into_response()
251}
252
253async fn drones(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
254    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
255    let now = learn::now_ms();
256    let mut ds = top::drones(&m.home, m.share == Share::Team, now);
257    for d in &mut ds {
258        d.p.engineer = collective::aliases().display(&d.p.engineer);
259    }
260    let inbox = top::inbox(&m.home, &ds, now);
261    Json(top::to_json_with(&m.name, m.share, &ds, &inbox)).into_response()
262}
263
264/// Messages between sessions (borg msg.rs) on this node, newest first: who sent each to whom, as `borg
265/// top` names them (a sender from another node with that node's short key: the one part the mesh
266/// checked), what the recipient is (`to_kind`: `all` for a broadcast, `local` or `collective`,
267/// `engineer` or `session`, from msg.rs `Address`; `to_name`, top.rs `recipient`, tells them apart),
268/// and how many sessions here it was shown to.
269async fn messages(State(app): State<Arc<App>>, Query(q): Query<FeedQuery>) -> Response {
270    let Some(m) = app.mind(q.c.as_deref()) else { return not_found(q.c.as_deref().unwrap_or_default()) };
271    let now = learn::now_ms();
272    let ds = top::drones(&m.home, m.share == Share::Team, now);
273    let shown = msg::seen_by(&m.home);
274    let here = msg::node(&m.home);
275    let mut all: Vec<msg::Message> = msg::read(&m.home).into_iter().filter(|x| now - x.ms <= msg::TTL_MS).collect();
276    let unread: usize = top::unread_of(&m.home, &ds, &here, &all, now).values().sum();
277    all.sort_by(|a, b| (b.ms, &b.id).cmp(&(a.ms, &a.id)));
278    all.truncate(q.limit.unwrap_or(200).min(msg::KEEP));
279    let out: Vec<Value> = all
280        .iter()
281        .map(|x| {
282            let to = shown.get(&presence::safe(&x.id)).map_or(0, Vec::len);
283            let to_kind = match msg::Address::parse(&x.to) {
284                Some(a) if a.broadcast() => "all",
285                Some(msg::Address::Engineer { .. }) => "engineer",
286                _ => "session",
287            };
288            json!({"id": x.id, "kind": x.kind, "by": x.by, "from": top::sender(&ds, &here, x), "harness": x.harness, "node": x.node,
289                "to": x.to, "to_name": top::recipient(&ds, &here, &x.to), "to_kind": to_kind, "re": x.re, "ms": x.ms, "text": x.text, "shown_to": to})
290        })
291        .collect();
292    Json(json!({"collective": m.name, "messages": out, "unread": unread})).into_response()
293}
294
295/// Whether learning works in the collective, week by week (learning.rs), backfilled from its logs.
296async fn learning_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
297    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
298    Json(learning::view(&m.name, &m.home, m.share)).into_response()
299}
300
301/// Size and modification time of `path`, or of every file directly in it.
302fn stamp(path: &Path) -> Vec<(u64, u128)> {
303    let one = |p: &Path| std::fs::metadata(p).ok().map(|m| (m.len(), mtime(&m)));
304    match std::fs::read_dir(path) {
305        Ok(rd) => {
306            let mut v: Vec<(u64, u128)> = rd.filter_map(|e| e.ok()).filter_map(|e| one(&e.path())).collect();
307            v.sort();
308            v
309        }
310        Err(_) => one(path).into_iter().collect(),
311    }
312}
313
314fn mtime(m: &std::fs::Metadata) -> u128 {
315    m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos())
316}
317
318/// The views of the page and the store files each depends on.
319fn watched(home: &Path) -> Vec<(&'static str, Vec<PathBuf>)> {
320    vec![
321        (
322            "lessons",
323            vec![home.join("lessons.jsonl"), home.join("outcomes.jsonl"), home.join(outcome::TEAM_FILE), home.join("stats.jsonl"), stats::events_path(home)],
324        ),
325        ("stats", vec![home.join("stats.jsonl"), stats::events_path(home)]),
326        // A drone's unread count moves with the log and the shown-once claims.
327        ("drones", vec![home.join("presence"), presence::remote_path(home), msg::path(home), msg::seen_path(home)]),
328        ("messages", vec![msg::path(home), msg::seen_path(home)]),
329        ("feed", vec![stats::events_path(home)]),
330        ("queen", vec![home.join("queen"), home.join("stats.jsonl"), stats::events_path(home)]),
331        // Learning health folds the lessons, the rollups and the local events.
332        ("learning", vec![home.join("lessons.jsonl"), home.join("stats.jsonl"), stats::events_path(home)]),
333    ]
334}
335
336/// Per view, the stamps of the files it depends on: a view changed when its stamps did.
337type Fingerprint = Vec<(&'static str, Vec<Vec<(u64, u128)>>)>;
338
339fn fingerprint(home: &Path) -> Fingerprint {
340    watched(home).into_iter().map(|(view, paths)| (view, paths.iter().map(|p| stamp(p)).collect())).collect()
341}
342
343async fn events(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
344    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
345    let home = m.home.clone();
346    let start = fingerprint(&home);
347    let stream = futures_util::stream::unfold((home, start), |(home, mut last)| async move {
348        loop {
349            tokio::time::sleep(Duration::from_secs(1)).await;
350            let now = fingerprint(&home);
351            let changed: Vec<&str> = now.iter().zip(&last).filter(|(a, b)| a != b).map(|(a, _)| a.0).collect();
352            if !changed.is_empty() {
353                let ev = Event::default().event("change").data(json!({"changed": changed}).to_string());
354                last = now;
355                return Some((Ok::<Event, Infallible>(ev), (home, last)));
356            }
357            last = now;
358        }
359    });
360    let mut stop = app.stop.clone();
361    let stream = stream.take_until(async move {
362        let _ = stop.wait_for(|s| *s).await;
363    });
364    Sse::new(Box::pin(stream) as std::pin::Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>>).keep_alive(KeepAlive::default()).into_response()
365}
366
367/// The built Leptos client (dist/, built from ui/, see ui/README.md).
368const INDEX: &str = include_str!("../dist/index.html");
369const JS: &[u8] = include_bytes!("../dist/borg-web-ui.js");
370const WASM: &[u8] = include_bytes!("../dist/borg-web-ui_bg.wasm");
371
372fn asset(kind: &'static str, body: &'static [u8]) -> Response {
373    ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "no-cache")], body).into_response()
374}
375
376pub fn router(app: Arc<App>) -> Router {
377    Router::new()
378        .route("/", get(|| async { ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], INDEX).into_response() }))
379        .route("/borg-web-ui.js", get(|| async { asset("text/javascript", JS) }))
380        .route("/borg-web-ui_bg.wasm", get(|| async { asset("application/wasm", WASM) }))
381        .route("/api/collectives", get(collectives))
382        .route("/api/stats", get(stats_of))
383        .route("/api/lessons", get(lessons))
384        .route("/api/drones", get(drones))
385        .route("/api/feed", get(feed))
386        .route("/api/queen", get(queen))
387        .route("/api/messages", get(messages))
388        .route("/api/learning", get(learning_of))
389        .route("/api/events", get(events))
390        .layer(axum::middleware::from_fn(loopback_only))
391        .with_state(app)
392}
393
394/// Answers only a request whose Host names this machine's loopback. The 127.0.0.1 bind keeps other
395/// machines out but not a page in the engineer's browser whose name re-resolves to 127.0.0.1 (DNS
396/// rebinding): to the browser the port is then that page's own origin, CORS does not apply, and its
397/// script could read the lessons, messages and spend. Such a request carries that name.
398async fn loopback_only(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
399    match req.headers().get(header::HOST).and_then(|h| h.to_str().ok()).is_some_and(loopback) {
400        true => next.run(req).await,
401        false => (StatusCode::FORBIDDEN, "borg web: served to a loopback Host only (127.0.0.1, localhost, [::1])\n").into_response(),
402    }
403}
404
405/// `127.0.0.1`, `localhost` or `[::1]`, with a numeric port or none.
406fn loopback(host: &str) -> bool {
407    let (name, port) = match host.strip_prefix('[') {
408        Some(v6) => match v6.split_once(']') {
409            Some(("::1", port)) => ("::1", port),
410            _ => return false,
411        },
412        None => host.find(':').map_or((host, ""), |i| host.split_at(i)),
413    };
414    let port = port.is_empty() || port.strip_prefix(':').is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
415    port && (name == "::1" || name == "127.0.0.1" || name.eq_ignore_ascii_case("localhost"))
416}
417
418/// Publishes this node's rollups and redacted outcomes of every collective once a minute (stats.rs
419/// `rollup`, outcome.rs `publish`).
420pub async fn publish(minds: Vec<Mind>) {
421    loop {
422        for m in &minds {
423            stats::rollup(&m.home);
424            outcome::publish(&m.home);
425        }
426        tokio::time::sleep(Duration::from_secs(60)).await;
427    }
428}