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//! `/api/update` is borg itself: this borg's version, the last release check (borg update.rs), and the
10//! members by version with what an older protocol misses (borg protocol.rs). The page shows a banner
11//! from it when a newer release exists or a member's protocol differs from this node's.
12//!
13//! The binary (main.rs) parses the command line and serves [`router`]; tests drive it in process.
14
15use std::collections::BTreeMap;
16use std::convert::Infallible;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::time::Duration;
20
21use axum::Router;
22use axum::extract::{Query, State};
23use axum::http::{StatusCode, header};
24use axum::response::sse::{Event, KeepAlive, Sse};
25use axum::response::{IntoResponse, Json, Response};
26use axum::routing::{get, post};
27use borg::collective::{self, Route, Share};
28use borg::{absorb, conscience, install, learn, memory, memory_export, msg, outcome, presence, protocol, stats, top, update};
29use futures_util::{Stream, StreamExt};
30
31pub mod learning;
32use serde::Deserialize;
33use serde_json::{Value, json};
34
35pub const USAGE: &str = "usage: borg web [--port PORT] [--dir DIR]   (default port 7979; DIR picks the collective shown first)";
36
37/// One collective the page can show.
38#[derive(Clone)]
39pub struct Mind {
40    pub name: String,
41    pub home: PathBuf,
42    pub share: Share,
43}
44
45pub struct App {
46    pub minds: Vec<Mind>,
47    /// The collective the page opens with: the one the start directory routes to.
48    pub first: String,
49    /// The start directory: its git identity names the engineer looking at the page, whom the messages
50    /// view acknowledges what it shows (`messages_seen`, borg msg.rs `ack_engineer`).
51    pub cwd: String,
52    /// Turns true on Ctrl-C. Event streams end on it, else graceful shutdown waits for every open page.
53    pub stop: tokio::sync::watch::Receiver<bool>,
54}
55
56impl App {
57    fn mind(&self, name: Option<&str>) -> Option<&Mind> {
58        let name = name.unwrap_or(&self.first);
59        self.minds.iter().find(|m| m.name == name)
60    }
61}
62
63#[derive(Deserialize)]
64struct Pick {
65    c: Option<String>,
66}
67
68/// Every collective on this machine, and the one `dir` routes to (else the first).
69pub fn minds(dir: &str) -> Result<(Vec<Mind>, String), String> {
70    let cfg = collective::load()?;
71    let base = learn::home();
72    let minds: Vec<Mind> = install::collectives(cfg.as_ref(), &base)
73        .into_iter()
74        .filter_map(|r| match r {
75            Route::Collective { name, home, share } => Some(Mind { name, home, share }),
76            Route::Off => None,
77        })
78        .collect();
79    let first = match collective::route(cfg.as_ref(), dir, &base) {
80        Route::Collective { name, .. } => name,
81        Route::Off => minds.first().map(|m| m.name.clone()).unwrap_or_default(),
82    };
83    Ok((minds, first))
84}
85
86fn not_found(what: &str) -> Response {
87    (StatusCode::NOT_FOUND, format!("no collective {what}")).into_response()
88}
89
90async fn collectives(State(app): State<Arc<App>>) -> Json<Value> {
91    let list: Vec<Value> = app.minds.iter().map(|m| json!({"name": m.name, "share": if m.share == Share::Team { "team" } else { "local" }})).collect();
92    Json(json!({"first": app.first, "collectives": list}))
93}
94
95/// Buckets summed per key (day or engineer), in key order.
96fn sum_by(buckets: &[stats::Bucket], key: impl Fn(&stats::Bucket) -> String) -> Vec<(String, stats::Bucket)> {
97    let mut out: BTreeMap<String, stats::Bucket> = BTreeMap::new();
98    for b in buckets {
99        out.entry(key(b)).or_default().add(b);
100    }
101    out.into_iter().collect()
102}
103
104fn totals(b: &stats::Bucket) -> Value {
105    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})
106}
107
108/// One entry per day from `first` to `last`, zero where nothing was counted.
109fn series(buckets: &[stats::Bucket], first: &str, last: &str) -> Vec<Value> {
110    let by: BTreeMap<String, stats::Bucket> = sum_by(buckets, |b| b.day.clone()).into_iter().collect();
111    stats::days_between(first, last)
112        .into_iter()
113        .map(|d| {
114            let t = by.get(&d).map(totals).unwrap_or_else(|| totals(&stats::Bucket::default()));
115            json!({"day": d, "totals": t})
116        })
117        .collect()
118}
119
120async fn stats_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
121    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
122    let buckets = stats::current(&m.home);
123    let mut all = stats::Bucket::default();
124    for b in &buckets {
125        all.add(b);
126    }
127    let aliases = collective::aliases();
128    let first = buckets.iter().map(|b| b.day.as_str()).min().unwrap_or_default().to_string();
129    let last = buckets.iter().map(|b| b.day.as_str()).max().unwrap_or_default().to_string();
130    let days = series(&buckets, &first, &last);
131    let engineers: Vec<Value> = sum_by(&buckets, |b| aliases.display(&b.engineer))
132        .into_iter()
133        .map(|(e, t)| {
134            let mine: Vec<stats::Bucket> = buckets.iter().filter(|b| aliases.display(&b.engineer) == e).cloned().collect();
135            json!({"engineer": e, "totals": totals(&t), "days": series(&mine, &first, &last)})
136        })
137        .collect();
138    let nodes = buckets.iter().map(|b| b.node.as_str()).collect::<std::collections::BTreeSet<_>>().len();
139    // `borg debug ope`'s support, one pass over this machine's log (pools are never shared); the estimate itself
140    // derives beliefs per decision, too heavy to poll.
141    let (ds, skipped) = borg::ope::decisions(&outcome::read(&m.home.join("outcomes.jsonl")));
142    let ope = json!({"decisions": ds.len(), "no_p": skipped.no_p, "no_pool": skipped.no_pool});
143    Json(json!({"collective": m.name, "totals": totals(&all), "days": days, "engineers": engineers, "nodes": nodes, "ope": ope})).into_response()
144}
145
146async fn lessons(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
147    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
148    let path = m.home.join("lessons.jsonl");
149    let list = conscience::load_unweighted(&path, None);
150    let outcomes = outcome::read_team(&path);
151    let mem = memory::derive(&outcomes, &list, None);
152    // How often each lesson took part in what stats.rs counted (asked in a block, so far), team-wide.
153    let mut usage: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
154    for b in stats::current(&m.home) {
155        for (id, kinds) in b.lessons {
156            let u = usage.entry(id).or_default();
157            for (k, v) in kinds {
158                *u.entry(k).or_default() += v;
159            }
160        }
161    }
162    let aliases = collective::aliases();
163    let stored: BTreeMap<String, (u32, String)> = learn::lessons(&path).into_iter().map(|l| (l.id, (l.version, l.repo))).collect();
164    let mut out: Vec<Value> = list
165        .iter()
166        .map(|c| {
167            let mm = mem.get(&c.id);
168            json!({
169                "id": c.id, "question": c.question, "situations": c.situations, "consequence": c.consequence,
170                "area": c.area, "scope": c.scope, "source": c.source, "origin_ms": c.origin_ms,
171                "engineers": c.engineers.iter().map(|e| aliases.display(e)).collect::<Vec<_>>(),
172                "activation": mm.map_or(0.0, |x| x.activation), "changed": mm.map_or(0, |x| x.changed),
173                "ignored": mm.map_or(0, |x| x.ignored), "corrected": mm.map_or(0, |x| x.corrected), "vetoed": mm.map_or(0, |x| x.vetoed),
174                "regretted": mm.map_or(0.0, |x| x.regretted),
175                "tested": mm.map_or(0, |x| x.tested),
176                "dormant": mm.is_some_and(|x| x.dormant), "consolidated": mm.is_some_and(|x| x.consolidated),
177                "usage": usage.get(&c.id).cloned().unwrap_or_default(),
178                "version": stored.get(&c.id).map_or(1, |s| s.0), "repo": stored.get(&c.id).map(|s| s.1.clone()).unwrap_or_default(),
179                "reach": c.reach,
180            })
181        })
182        .collect();
183    out.sort_by(|a, b| b["activation"].as_f64().partial_cmp(&a["activation"].as_f64()).unwrap_or(std::cmp::Ordering::Equal));
184    Json(json!({"collective": m.name, "lessons": out})).into_response()
185}
186
187#[derive(Deserialize)]
188struct MemoryQuery {
189    c: Option<String>,
190    /// Only the absorbed lessons (`borg memory export --absorbed`).
191    #[serde(default)]
192    absorbed: bool,
193}
194
195/// The collective's evidence records, exactly what `borg memory export --json` prints (borg
196/// memory_export.rs `document`): each lesson's memory, breadth and, when an outside store holds it, its
197/// absorb and recurrence. The Knowledge view marks absorbed lessons from it.
198async fn memory_of(State(app): State<Arc<App>>, Query(q): Query<MemoryQuery>) -> Response {
199    let Some(m) = app.mind(q.c.as_deref()) else { return not_found(q.c.as_deref().unwrap_or_default()) };
200    let filter = memory_export::Filter { absorbed: q.absorbed, ..Default::default() };
201    Json(memory_export::document(&m.name, &m.home, &filter)).into_response()
202}
203
204/// What the feed shows: what people and drones did, not the routine of every turn end.
205const NOTABLE: [&str; 28] = [
206    stats::CORRECTION,
207    stats::VETO,
208    stats::VETO_REPEAT,
209    stats::REVIEW_FINDING,
210    stats::BLOCK,
211    stats::CHANGED,
212    stats::IGNORED,
213    stats::CORRECTED,
214    stats::REGRETTED,
215    stats::TESTED,
216    stats::LESSON_NEW,
217    stats::LESSON_MERGED,
218    stats::NOT_A_LESSON,
219    stats::STEER_ISSUED,
220    stats::STEER_DELIVERED,
221    stats::STEER_DROPPED,
222    stats::PAUSE_REFUSED,
223    stats::QUEEN_QUEUED,
224    stats::QUEEN_DELIVERED,
225    stats::QUEEN_ANSWERED,
226    stats::QUEEN_SKIPPED,
227    stats::QUEEN_DISMISSED,
228    stats::ASSIMILATED,
229    stats::EARLY,
230    stats::QUEUED_FREE,
231    stats::MODEL_CALL,
232    // Messages between sessions: each one sent, and each refused. A delivery is one row per recipient
233    // (a broadcast many): `/api/messages` says whom each was shown to instead.
234    stats::MSG_SENT,
235    stats::MSG_DROPPED,
236];
237
238#[derive(Deserialize)]
239struct FeedQuery {
240    c: Option<String>,
241    limit: Option<usize>,
242}
243
244/// Display: the rows a listing (`/api/feed`, `/api/messages`) returns when the page sets no `limit`.
245const PAGE: usize = 200;
246/// Guard: the most rows one listing returns whatever its `limit`, so one request cannot make the
247/// server serialise a whole log.
248const PAGE_MAX: usize = 2000;
249
250/// This machine's notable events, newest first (peers' activity arrives as counts only).
251async fn feed(State(app): State<Arc<App>>, Query(q): Query<FeedQuery>) -> Response {
252    let Some(m) = app.mind(q.c.as_deref()) else { return not_found(q.c.as_deref().unwrap_or_default()) };
253    stats::backfill(&m.home);
254    let aliases = collective::aliases();
255    let mut evs: Vec<stats::Event> = stats::events(&m.home).into_iter().filter(|e| NOTABLE.contains(&e.kind.as_str())).collect();
256    evs.sort_by_key(|e| std::cmp::Reverse(e.ms));
257    evs.truncate(q.limit.unwrap_or(PAGE).min(PAGE_MAX));
258    let out: Vec<Value> = evs
259        .into_iter()
260        .map(|e| {
261            json!({"ms": e.ms, "kind": e.kind, "engineer": aliases.display(&e.engineer), "repo": e.repo, "lessons": e.lessons,
262                "n": e.n, "usd": e.usd, "dur_ms": e.dur_ms, "why": e.why, "text": e.text, "source": e.source})
263        })
264        .collect();
265    Json(json!({"collective": m.name, "events": out})).into_response()
266}
267
268async fn queen(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
269    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
270    let mut entries = borg::queen::entries(&m.home);
271    entries.sort_by_key(|e| std::cmp::Reverse(e.ms));
272    // The queen's attention reward (escalation.rs), the team's: folded from every stats bucket.
273    let attention = borg::escalation::team(&m.home);
274    Json(json!({"collective": m.name, "entries": entries, "attention": attention})).into_response()
275}
276
277async fn drones(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
278    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
279    let now = learn::now_ms();
280    let mut ds = top::drones(&m.home, m.share == Share::Team, now);
281    for d in &mut ds {
282        d.p.engineer = collective::aliases().display(&d.p.engineer);
283    }
284    let inbox = top::inbox(&m.home, &ds, now);
285    Json(top::to_json_with(&m.name, m.share, &ds, &inbox)).into_response()
286}
287
288/// Messages between sessions (borg msg.rs) on this node, newest first: who sent each to whom, as `borg
289/// top` names them (a sender from another node with that node's short key: the one part the mesh
290/// checked), what the recipient is (`to_kind`: `all` for a broadcast, `local` or `collective`,
291/// `engineer` or `session`, from msg.rs `Address`; `to_name`, top.rs `recipient`, tells them apart),
292/// and how many sessions here it was shown to. Listing acknowledges nothing: the page fetches every
293/// view whichever is open, so the messages view says what it showed (`messages_seen`).
294async fn messages(State(app): State<Arc<App>>, Query(q): Query<FeedQuery>) -> Response {
295    let Some(m) = app.mind(q.c.as_deref()) else { return not_found(q.c.as_deref().unwrap_or_default()) };
296    let now = learn::now_ms();
297    let ds = top::drones(&m.home, m.share == Share::Team, now);
298    let shown = msg::seen_by(&m.home);
299    let here = msg::node(&m.home);
300    let mut all: Vec<msg::Message> = msg::read(&m.home).into_iter().filter(|x| now - x.ms <= msg::TTL_MS).collect();
301    let unread: usize = top::unread_of(&m.home, &ds, &here, &all, now).values().sum();
302    all.sort_by(|a, b| (b.ms, &b.id).cmp(&(a.ms, &a.id)));
303    all.truncate(q.limit.unwrap_or(PAGE).min(PAGE_MAX));
304    let out: Vec<Value> = all
305        .iter()
306        .map(|x| {
307            let to = shown.get(&presence::safe(&x.id)).map_or(0, Vec::len);
308            let to_kind = match msg::Address::parse(&x.to) {
309                Some(a) if a.broadcast() => "all",
310                Some(msg::Address::Engineer { .. }) => "engineer",
311                _ => "session",
312            };
313            json!({"id": x.id, "kind": x.kind, "by": x.by, "from": top::sender(&ds, &here, x), "harness": x.harness, "node": x.node,
314                "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})
315        })
316        .collect();
317    Json(json!({"collective": m.name, "messages": out, "unread": unread})).into_response()
318}
319
320#[derive(Deserialize)]
321struct Shown {
322    ids: Vec<String>,
323}
324
325/// The page's messages view showed the engineer the messages `ids` names: those to the engineer among
326/// them are acknowledged as the engineer of the start directory (borg msg.rs `ack_engineer`), as `borg
327/// inbox` and `borg top` acknowledge what they show; any other id is ignored. Only the page itself can
328/// call it: a JSON body needs a CORS preflight from any other site's page, which this server never
329/// answers, and the Host check (`loopback_only`) keeps a rebound name out. At most `PAGE_MAX` ids count.
330async fn messages_seen(State(app): State<Arc<App>>, Query(p): Query<Pick>, Json(shown): Json<Shown>) -> Response {
331    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
332    let now = learn::now_ms();
333    let ids: std::collections::HashSet<&str> = shown.ids.iter().take(PAGE_MAX).map(String::as_str).collect();
334    let all: Vec<msg::Message> = msg::read(&m.home).into_iter().filter(|x| now - x.ms <= msg::TTL_MS && ids.contains(x.id.as_str())).collect();
335    let shown: Vec<&msg::Message> = all.iter().collect();
336    match msg::ack_engineer(&m.home, &app.cwd, &shown, now) {
337        Ok(()) => Json(json!({"collective": m.name})).into_response(),
338        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("borg web: acknowledging: {e}")).into_response(),
339    }
340}
341
342/// Lesson selection (borg select.rs) as `borg status` and `borg top` show it, in full (top.rs
343/// `Selection`): what decides and the gate's budget, deviations from the fixed rule, spend per cost
344/// source, effects with intervals, label and evaluation health, pool yield, latency against the stop
345/// budget. Read from the store's selection summary, never recomputed here.
346async fn select_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
347    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
348    let s = top::selection(&m.home.join("lessons.jsonl"), borg::select::policy());
349    Json(json!({"collective": m.name, "selection": s})).into_response()
350}
351
352/// Whether learning works in the collective, week by week (learning.rs), backfilled from its logs.
353async fn learning_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
354    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
355    Json(learning::view(&m.name, &m.home, m.share)).into_response()
356}
357
358/// What `/api/update` serves for the collective `name` at `home`: this borg (`borg`, its mesh protocol
359/// `protocol`), the last release check (`latest`, `checked_ms`, `error`; all null before the first) and
360/// whether its release is newer (`update`, false while `check` is off: `BORG_UPDATE_CHECK=off`), then
361/// the members this node knows of. Each member has its `key`, its `engineer` when known and `name` (the
362/// engineer, else its short key), `borg` (null: older than 0.7, it sent none), the `protocol` its last
363/// session agreed on, `syncs` (false: nothing syncs with it, it speaks a protocol this node does not)
364/// and `note`: what it misses on an older protocol (protocol.rs `NOTES`), or why nothing syncs; null
365/// on this node's footing. Without `mesh/` there are no members.
366pub fn update_view(name: &str, home: &Path) -> Value {
367    let checked = update::checked_at(home);
368    let latest = checked.as_ref().and_then(|c| c.latest.clone());
369    let on = update::on();
370    let newer = on && latest.as_deref().is_some_and(|l| update::newer(l, update::VERSION));
371    let ours = protocol::current();
372    let aliases = collective::aliases();
373    let versions = protocol::versions(home);
374    let mismatches = protocol::mismatches(home);
375    let engineer = |v: Option<&protocol::PeerVersion>| v.and_then(|v| v.engineer.as_deref()).filter(|e| !e.is_empty()).map(|e| aliases.display(e));
376    let named = |key: &str, e: &Option<String>| e.clone().unwrap_or_else(|| protocol::member(key, None));
377    // A member nothing syncs with is told by its mismatch, the newer state: its version record is from
378    // the last session that completed.
379    let mut members: Vec<Value> = versions
380        .iter()
381        .filter(|(key, _)| !mismatches.contains_key(*key))
382        .map(|(key, v)| {
383            let missed = protocol::misses(v.protocol, ours);
384            let e = engineer(Some(v));
385            json!({"key": key, "engineer": e, "name": named(key, &e), "borg": v.borg, "protocol": v.protocol, "ms": v.ms, "syncs": true,
386                "note": (!missed.is_empty()).then_some(missed)})
387        })
388        .collect();
389    members.extend(mismatches.iter().map(|(key, m)| {
390        let v = versions.get(key);
391        let e = engineer(v);
392        json!({"key": key, "engineer": e, "name": named(key, &e), "borg": v.and_then(|v| v.borg.clone()), "protocol": protocol::number(&m.theirs),
393            "ms": m.ms, "syncs": false, "note": m.line(key)})
394    }));
395    json!({
396        "collective": name, "borg": update::VERSION, "protocol": ours, "check": on,
397        "latest": latest, "checked_ms": checked.as_ref().map(|c| c.checked_ms), "error": checked.and_then(|c| c.error),
398        "update": newer, "members": members,
399    })
400}
401
402async fn update_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
403    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
404    Json(update_view(&m.name, &m.home)).into_response()
405}
406
407/// Size and modification time of `path`, or of every file directly in it.
408fn stamp(path: &Path) -> Vec<(u64, u128)> {
409    let one = |p: &Path| std::fs::metadata(p).ok().map(|m| (m.len(), mtime(&m)));
410    match std::fs::read_dir(path) {
411        Ok(rd) => {
412            let mut v: Vec<(u64, u128)> = rd.filter_map(|e| e.ok()).filter_map(|e| one(&e.path())).collect();
413            v.sort();
414            v
415        }
416        Err(_) => one(path).into_iter().collect(),
417    }
418}
419
420fn mtime(m: &std::fs::Metadata) -> u128 {
421    m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos())
422}
423
424/// The views of the page and the store files each depends on.
425fn watched(home: &Path) -> Vec<(&'static str, Vec<PathBuf>)> {
426    vec![
427        (
428            "lessons",
429            vec![home.join("lessons.jsonl"), home.join("outcomes.jsonl"), home.join(outcome::TEAM_FILE), home.join("stats.jsonl"), stats::events_path(home)],
430        ),
431        // The evidence records fold the lessons, the outcomes, this machine's episodes and the absorb log.
432        (
433            "memory",
434            vec![home.join("lessons.jsonl"), home.join("outcomes.jsonl"), home.join(outcome::TEAM_FILE), home.join("episodes.jsonl"), absorb::path(home)],
435        ),
436        ("stats", vec![home.join("stats.jsonl"), stats::events_path(home)]),
437        // A drone's unread count moves with the log and the shown-once claims.
438        ("drones", vec![home.join("presence"), presence::remote_path(home), msg::path(home), msg::seen_path(home)]),
439        ("messages", vec![msg::path(home), msg::seen_path(home)]),
440        ("feed", vec![stats::events_path(home)]),
441        ("queen", vec![home.join("queen"), home.join("stats.jsonl"), stats::events_path(home)]),
442        // Learning health folds the lessons, the rollups and the local events.
443        ("learning", vec![home.join("lessons.jsonl"), home.join("stats.jsonl"), stats::events_path(home)]),
444        // The selection summary's cache, and the outcome log whose tail it folds on every read.
445        ("select", vec![home.join(borg::select::summary::CACHE), home.join("outcomes.jsonl")]),
446        // The release check and what the mesh last heard of the members' versions.
447        ("update", vec![update::path_for(home), protocol::versions_path(home), protocol::protocols_path(home)]),
448    ]
449}
450
451/// Per view, the stamps of the files it depends on: a view changed when its stamps did.
452type Fingerprint = Vec<(&'static str, Vec<Vec<(u64, u128)>>)>;
453
454fn fingerprint(home: &Path) -> Fingerprint {
455    watched(home).into_iter().map(|(view, paths)| (view, paths.iter().map(|p| stamp(p)).collect())).collect()
456}
457
458async fn events(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
459    let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
460    let home = m.home.clone();
461    let start = fingerprint(&home);
462    let stream = futures_util::stream::unfold((home, start), |(home, mut last)| async move {
463        loop {
464            tokio::time::sleep(Duration::from_secs(1)).await;
465            let now = fingerprint(&home);
466            let changed: Vec<&str> = now.iter().zip(&last).filter(|(a, b)| a != b).map(|(a, _)| a.0).collect();
467            if !changed.is_empty() {
468                let ev = Event::default().event("change").data(json!({"changed": changed}).to_string());
469                last = now;
470                return Some((Ok::<Event, Infallible>(ev), (home, last)));
471            }
472            last = now;
473        }
474    });
475    let mut stop = app.stop.clone();
476    let stream = stream.take_until(async move {
477        let _ = stop.wait_for(|s| *s).await;
478    });
479    Sse::new(Box::pin(stream) as std::pin::Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>>).keep_alive(KeepAlive::default()).into_response()
480}
481
482/// The built Leptos client (dist/, built from ui/, see ui/README.md).
483const INDEX: &str = include_str!("../dist/index.html");
484const JS: &[u8] = include_bytes!("../dist/borg-web-ui.js");
485const WASM: &[u8] = include_bytes!("../dist/borg-web-ui_bg.wasm");
486
487fn asset(kind: &'static str, body: &'static [u8]) -> Response {
488    ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "no-cache")], body).into_response()
489}
490
491pub fn router(app: Arc<App>) -> Router {
492    Router::new()
493        .route("/", get(|| async { ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], INDEX).into_response() }))
494        .route("/borg-web-ui.js", get(|| async { asset("text/javascript", JS) }))
495        .route("/borg-web-ui_bg.wasm", get(|| async { asset("application/wasm", WASM) }))
496        .route("/api/collectives", get(collectives))
497        .route("/api/stats", get(stats_of))
498        .route("/api/lessons", get(lessons))
499        .route("/api/memory", get(memory_of))
500        .route("/api/drones", get(drones))
501        .route("/api/feed", get(feed))
502        .route("/api/queen", get(queen))
503        .route("/api/messages", get(messages))
504        .route("/api/messages/seen", post(messages_seen))
505        .route("/api/learning", get(learning_of))
506        .route("/api/select", get(select_of))
507        .route("/api/update", get(update_of))
508        .route("/api/events", get(events))
509        .layer(axum::middleware::from_fn(loopback_only))
510        .with_state(app)
511}
512
513/// Answers only a request whose Host names this machine's loopback. The 127.0.0.1 bind keeps other
514/// machines out but not a page in the engineer's browser whose name re-resolves to 127.0.0.1 (DNS
515/// rebinding): to the browser the port is then that page's own origin, CORS does not apply, and its
516/// script could read the lessons, messages and spend. Such a request carries that name.
517async fn loopback_only(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
518    match req.headers().get(header::HOST).and_then(|h| h.to_str().ok()).is_some_and(loopback) {
519        true => next.run(req).await,
520        false => (StatusCode::FORBIDDEN, "borg web: served to a loopback Host only (127.0.0.1, localhost, [::1])\n").into_response(),
521    }
522}
523
524/// `127.0.0.1`, `localhost` or `[::1]`, with a numeric port or none.
525fn loopback(host: &str) -> bool {
526    let (name, port) = match host.strip_prefix('[') {
527        Some(v6) => match v6.split_once(']') {
528            Some(("::1", port)) => ("::1", port),
529            _ => return false,
530        },
531        None => host.find(':').map_or((host, ""), |i| host.split_at(i)),
532    };
533    let port = port.is_empty() || port.strip_prefix(':').is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
534    port && (name == "::1" || name == "127.0.0.1" || name.eq_ignore_ascii_case("localhost"))
535}
536
537/// Publishes this node's rollups and redacted outcomes of every collective once a minute (stats.rs
538/// `rollup`, outcome.rs `publish`).
539pub async fn publish(minds: Vec<Mind>) {
540    loop {
541        for m in &minds {
542            stats::rollup(&m.home);
543            outcome::publish(&m.home);
544        }
545        tokio::time::sleep(Duration::from_secs(60)).await;
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    /// The page refetches the selection when its summary cache, or the outcome log the summary folds,
554    /// changes; the cache alone changes no other view.
555    #[test]
556    fn the_selection_view_follows_its_summary_and_the_outcomes() {
557        let home = std::env::temp_dir().join(format!("borg-web-watch-select-{}", std::process::id()));
558        let _ = std::fs::remove_dir_all(&home);
559        std::fs::create_dir_all(&home).unwrap();
560        let changed = |a: &Fingerprint, b: &Fingerprint| a.iter().zip(b).filter(|(x, y)| x != y).map(|(x, _)| x.0).collect::<Vec<_>>();
561        let before = fingerprint(&home);
562        std::fs::write(home.join(borg::select::summary::CACHE), "{\"sv\":1,\"at\":0}").unwrap();
563        let cached = fingerprint(&home);
564        assert_eq!(changed(&before, &cached), ["select"]);
565        std::fs::write(home.join("outcomes.jsonl"), "{}\n").unwrap();
566        assert!(changed(&cached, &fingerprint(&home)).contains(&"select"));
567        let _ = std::fs::remove_dir_all(&home);
568    }
569
570    /// A release check or a member's version changes the update view only.
571    #[test]
572    fn the_update_view_follows_the_release_check_and_the_members_versions() {
573        let home = std::env::temp_dir().join(format!("borg-web-watch-update-{}", std::process::id()));
574        let _ = std::fs::remove_dir_all(&home);
575        std::fs::create_dir_all(&home).unwrap();
576        let changed = |a: &Fingerprint, b: &Fingerprint| a.iter().zip(b).filter(|(x, y)| x != y).map(|(x, _)| x.0).collect::<Vec<_>>();
577        let before = fingerprint(&home);
578        std::fs::write(update::path_for(&home), "{\"checked_ms\":1,\"latest\":\"0.7.0\",\"error\":null}").unwrap();
579        let checked = fingerprint(&home);
580        assert_eq!(changed(&before, &checked), ["update"]);
581        let v = protocol::PeerVersion { borg: Some("0.7.0".into()), protocol: protocol::current(), ms: 1, engineer: None };
582        protocol::note_version(&home, "k", &v).unwrap();
583        assert_eq!(changed(&checked, &fingerprint(&home)), ["update"]);
584        let _ = std::fs::remove_dir_all(&home);
585    }
586
587    /// An absorb or release changes the memory view only: the lessons themselves did not change.
588    #[test]
589    fn the_memory_view_follows_the_absorb_log() {
590        let home = std::env::temp_dir().join(format!("borg-web-watch-memory-{}", std::process::id()));
591        let _ = std::fs::remove_dir_all(&home);
592        std::fs::create_dir_all(&home).unwrap();
593        let changed = |a: &Fingerprint, b: &Fingerprint| a.iter().zip(b).filter(|(x, y)| x != y).map(|(x, _)| x.0).collect::<Vec<_>>();
594        let before = fingerprint(&home);
595        std::fs::write(absorb::path(&home), "{\"id\":\"L1\",\"kind\":\"absorb\",\"ms\":1,\"by\":\"a@x\"}\n").unwrap();
596        assert_eq!(changed(&before, &fingerprint(&home)), ["memory"]);
597        let _ = std::fs::remove_dir_all(&home);
598    }
599}