1use 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#[derive(Clone)]
39pub struct Mind {
40 pub name: String,
41 pub home: PathBuf,
42 pub share: Share,
43 pub mode: &'static str,
45}
46
47pub struct App {
48 pub minds: Vec<Mind>,
49 pub first: String,
51 pub cwd: String,
54 pub stop: tokio::sync::watch::Receiver<bool>,
56}
57
58impl App {
59 fn mind(&self, name: Option<&str>) -> Option<&Mind> {
60 let name = name.unwrap_or(&self.first);
61 self.minds.iter().find(|m| m.name == name)
62 }
63}
64
65#[derive(Deserialize)]
66struct Pick {
67 c: Option<String>,
68}
69
70pub fn minds(dir: &str) -> Result<(Vec<Mind>, String), String> {
72 let cfg = collective::load()?;
73 let base = learn::home();
74 let minds: Vec<Mind> = install::collectives(cfg.as_ref(), &base)
75 .into_iter()
76 .filter_map(|r| match r {
77 Route::Collective { name, home, share } => {
78 let mode = borg::live::of(cfg.as_ref(), &name).mode.name();
79 Some(Mind { name, home, share, mode })
80 }
81 Route::Off => None,
82 })
83 .collect();
84 let first = match collective::route(cfg.as_ref(), dir, &base) {
85 Route::Collective { name, .. } => name,
86 Route::Off => minds.first().map(|m| m.name.clone()).unwrap_or_default(),
87 };
88 Ok((minds, first))
89}
90
91fn not_found(what: &str) -> Response {
92 (StatusCode::NOT_FOUND, format!("no collective {what}")).into_response()
93}
94
95async fn collectives(State(app): State<Arc<App>>) -> Json<Value> {
96 let list: Vec<Value> =
97 app.minds.iter().map(|m| json!({"name": m.name, "share": if m.share == Share::Team { "team" } else { "local" }, "mode": m.mode})).collect();
98 Json(json!({"first": app.first, "collectives": list}))
99}
100
101fn sum_by(buckets: &[stats::Bucket], key: impl Fn(&stats::Bucket) -> String) -> Vec<(String, stats::Bucket)> {
103 let mut out: BTreeMap<String, stats::Bucket> = BTreeMap::new();
104 for b in buckets {
105 out.entry(key(b)).or_default().add(b);
106 }
107 out.into_iter().collect()
108}
109
110fn totals(b: &stats::Bucket) -> Value {
111 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})
112}
113
114fn series(buckets: &[stats::Bucket], first: &str, last: &str) -> Vec<Value> {
116 let by: BTreeMap<String, stats::Bucket> = sum_by(buckets, |b| b.day.clone()).into_iter().collect();
117 stats::days_between(first, last)
118 .into_iter()
119 .map(|d| {
120 let t = by.get(&d).map(totals).unwrap_or_else(|| totals(&stats::Bucket::default()));
121 json!({"day": d, "totals": t})
122 })
123 .collect()
124}
125
126async fn stats_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
127 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
128 let buckets = stats::current(&m.home);
129 let mut all = stats::Bucket::default();
130 for b in &buckets {
131 all.add(b);
132 }
133 let aliases = collective::aliases();
134 let first = buckets.iter().map(|b| b.day.as_str()).min().unwrap_or_default().to_string();
135 let last = buckets.iter().map(|b| b.day.as_str()).max().unwrap_or_default().to_string();
136 let days = series(&buckets, &first, &last);
137 let engineers: Vec<Value> = sum_by(&buckets, |b| aliases.display(&b.engineer))
138 .into_iter()
139 .map(|(e, t)| {
140 let mine: Vec<stats::Bucket> = buckets.iter().filter(|b| aliases.display(&b.engineer) == e).cloned().collect();
141 json!({"engineer": e, "totals": totals(&t), "days": series(&mine, &first, &last)})
142 })
143 .collect();
144 let nodes = buckets.iter().map(|b| b.node.as_str()).collect::<std::collections::BTreeSet<_>>().len();
145 let (ds, skipped) = borg::ope::decisions(&outcome::read(&m.home.join("outcomes.jsonl")));
148 let ope = json!({"decisions": ds.len(), "no_p": skipped.no_p, "no_pool": skipped.no_pool});
149 Json(json!({"collective": m.name, "totals": totals(&all), "days": days, "engineers": engineers, "nodes": nodes, "ope": ope})).into_response()
150}
151
152async fn lessons(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
153 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
154 let path = m.home.join("lessons.jsonl");
155 let list = conscience::load_unweighted(&path, None);
156 let outcomes = outcome::read_team(&path);
157 let mem = memory::derive(&outcomes, &list, None);
158 let mut usage: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
160 for b in stats::current(&m.home) {
161 for (id, kinds) in b.lessons {
162 let u = usage.entry(id).or_default();
163 for (k, v) in kinds {
164 *u.entry(k).or_default() += v;
165 }
166 }
167 }
168 let aliases = collective::aliases();
169 let stored: BTreeMap<String, (u32, String)> = learn::lessons(&path).into_iter().map(|l| (l.id, (l.version, l.repo))).collect();
170 let mut out: Vec<Value> = list
171 .iter()
172 .map(|c| {
173 let mm = mem.get(&c.id);
174 json!({
175 "id": c.id, "question": c.question, "situations": c.situations, "consequence": c.consequence,
176 "area": c.area, "scope": c.scope, "source": c.source, "origin_ms": c.origin_ms,
177 "engineers": c.engineers.iter().map(|e| aliases.display(e)).collect::<Vec<_>>(),
178 "activation": mm.map_or(0.0, |x| x.activation), "changed": mm.map_or(0, |x| x.changed),
179 "ignored": mm.map_or(0, |x| x.ignored), "corrected": mm.map_or(0, |x| x.corrected), "vetoed": mm.map_or(0, |x| x.vetoed),
180 "regretted": mm.map_or(0.0, |x| x.regretted),
181 "tested": mm.map_or(0, |x| x.tested),
182 "dormant": mm.is_some_and(|x| x.dormant), "consolidated": mm.is_some_and(|x| x.consolidated),
183 "usage": usage.get(&c.id).cloned().unwrap_or_default(),
184 "version": stored.get(&c.id).map_or(1, |s| s.0), "repo": stored.get(&c.id).map(|s| s.1.clone()).unwrap_or_default(),
185 "reach": c.reach,
186 })
187 })
188 .collect();
189 out.sort_by(|a, b| b["activation"].as_f64().partial_cmp(&a["activation"].as_f64()).unwrap_or(std::cmp::Ordering::Equal));
190 Json(json!({"collective": m.name, "lessons": out})).into_response()
191}
192
193#[derive(Deserialize)]
194struct MemoryQuery {
195 c: Option<String>,
196 #[serde(default)]
198 absorbed: bool,
199}
200
201async fn memory_of(State(app): State<Arc<App>>, Query(q): Query<MemoryQuery>) -> Response {
205 let Some(m) = app.mind(q.c.as_deref()) else { return not_found(q.c.as_deref().unwrap_or_default()) };
206 let filter = memory_export::Filter { absorbed: q.absorbed, ..Default::default() };
207 Json(memory_export::document(&m.name, &m.home, &filter)).into_response()
208}
209
210const NOTABLE: [&str; 28] = [
212 stats::CORRECTION,
213 stats::VETO,
214 stats::VETO_REPEAT,
215 stats::REVIEW_FINDING,
216 stats::BLOCK,
217 stats::CHANGED,
218 stats::IGNORED,
219 stats::CORRECTED,
220 stats::REGRETTED,
221 stats::TESTED,
222 stats::LESSON_NEW,
223 stats::LESSON_MERGED,
224 stats::NOT_A_LESSON,
225 stats::STEER_ISSUED,
226 stats::STEER_DELIVERED,
227 stats::STEER_DROPPED,
228 stats::PAUSE_REFUSED,
229 stats::QUEEN_QUEUED,
230 stats::QUEEN_DELIVERED,
231 stats::QUEEN_ANSWERED,
232 stats::QUEEN_SKIPPED,
233 stats::QUEEN_DISMISSED,
234 stats::ASSIMILATED,
235 stats::EARLY,
236 stats::QUEUED_FREE,
237 stats::MODEL_CALL,
238 stats::MSG_SENT,
241 stats::MSG_DROPPED,
242];
243
244#[derive(Deserialize)]
245struct FeedQuery {
246 c: Option<String>,
247 limit: Option<usize>,
248}
249
250const PAGE: usize = 200;
252const PAGE_MAX: usize = 2000;
255
256async fn feed(State(app): State<Arc<App>>, Query(q): Query<FeedQuery>) -> Response {
258 let Some(m) = app.mind(q.c.as_deref()) else { return not_found(q.c.as_deref().unwrap_or_default()) };
259 stats::backfill(&m.home);
260 let aliases = collective::aliases();
261 let mut evs: Vec<stats::Event> = stats::events(&m.home).into_iter().filter(|e| NOTABLE.contains(&e.kind.as_str())).collect();
262 evs.sort_by_key(|e| std::cmp::Reverse(e.ms));
263 evs.truncate(q.limit.unwrap_or(PAGE).min(PAGE_MAX));
264 let out: Vec<Value> = evs
265 .into_iter()
266 .map(|e| {
267 json!({"ms": e.ms, "kind": e.kind, "engineer": aliases.display(&e.engineer), "repo": e.repo, "lessons": e.lessons,
268 "n": e.n, "usd": e.usd, "dur_ms": e.dur_ms, "why": e.why, "text": e.text})
269 })
270 .collect();
271 Json(json!({"collective": m.name, "events": out})).into_response()
272}
273
274async fn queen(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
275 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
276 let mut entries = borg::queen::entries(&m.home);
277 entries.sort_by_key(|e| std::cmp::Reverse(e.ms));
278 let attention = borg::escalation::team(&m.home);
280 Json(json!({"collective": m.name, "entries": entries, "attention": attention})).into_response()
281}
282
283async fn drones(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
284 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
285 let now = learn::now_ms();
286 let mut ds = top::drones(&m.home, m.share == Share::Team, now);
287 for d in &mut ds {
288 d.p.engineer = collective::aliases().display(&d.p.engineer);
289 }
290 let inbox = top::inbox(&m.home, &ds, now);
291 Json(top::to_json_with(&m.name, m.share, &ds, &inbox)).into_response()
292}
293
294async fn messages(State(app): State<Arc<App>>, Query(q): Query<FeedQuery>) -> Response {
301 let Some(m) = app.mind(q.c.as_deref()) else { return not_found(q.c.as_deref().unwrap_or_default()) };
302 let now = learn::now_ms();
303 let ds = top::drones(&m.home, m.share == Share::Team, now);
304 let shown = msg::seen_by(&m.home);
305 let here = msg::node(&m.home);
306 let mut all: Vec<msg::Message> = msg::read(&m.home).into_iter().filter(|x| now - x.ms <= msg::TTL_MS).collect();
307 let unread: usize = top::unread_of(&m.home, &ds, &here, &all, now).values().sum();
308 all.sort_by(|a, b| (b.ms, &b.id).cmp(&(a.ms, &a.id)));
309 all.truncate(q.limit.unwrap_or(PAGE).min(PAGE_MAX));
310 let out: Vec<Value> = all
311 .iter()
312 .map(|x| {
313 let to = shown.get(&presence::safe(&x.id)).map_or(0, Vec::len);
314 let to_kind = match msg::Address::parse(&x.to) {
315 Some(a) if a.broadcast() => "all",
316 Some(msg::Address::Engineer { .. }) => "engineer",
317 _ => "session",
318 };
319 json!({"id": x.id, "kind": x.kind, "by": x.by, "from": top::sender(&ds, &here, x), "harness": x.harness, "node": x.node,
320 "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})
321 })
322 .collect();
323 Json(json!({"collective": m.name, "messages": out, "unread": unread})).into_response()
324}
325
326#[derive(Deserialize)]
327struct Shown {
328 ids: Vec<String>,
329}
330
331async fn messages_seen(State(app): State<Arc<App>>, Query(p): Query<Pick>, Json(shown): Json<Shown>) -> Response {
337 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
338 let now = learn::now_ms();
339 let ids: std::collections::HashSet<&str> = shown.ids.iter().take(PAGE_MAX).map(String::as_str).collect();
340 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();
341 let shown: Vec<&msg::Message> = all.iter().collect();
342 match msg::ack_engineer(&m.home, &app.cwd, &shown, now) {
343 Ok(()) => Json(json!({"collective": m.name})).into_response(),
344 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("borg web: acknowledging: {e}")).into_response(),
345 }
346}
347
348async fn select_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
353 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
354 let s = top::selection(&m.home.join("lessons.jsonl"), borg::select::policy());
355 Json(json!({"collective": m.name, "selection": s})).into_response()
356}
357
358async fn learning_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
360 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
361 Json(learning::view(&m.name, &m.home, m.share)).into_response()
362}
363
364pub fn update_view(name: &str, home: &Path) -> Value {
373 let checked = update::checked_at(home);
374 let latest = checked.as_ref().and_then(|c| c.latest.clone());
375 let on = update::on();
376 let newer = on && latest.as_deref().is_some_and(|l| update::newer(l, update::VERSION));
377 let ours = protocol::current();
378 let aliases = collective::aliases();
379 let versions = protocol::versions(home);
380 let mismatches = protocol::mismatches(home);
381 let engineer = |v: Option<&protocol::PeerVersion>| v.and_then(|v| v.engineer.as_deref()).filter(|e| !e.is_empty()).map(|e| aliases.display(e));
382 let named = |key: &str, e: &Option<String>| e.clone().unwrap_or_else(|| protocol::member(key, None));
383 let mut members: Vec<Value> = versions
386 .iter()
387 .filter(|(key, _)| !mismatches.contains_key(*key))
388 .map(|(key, v)| {
389 let missed = protocol::misses(v.protocol, ours);
390 let e = engineer(Some(v));
391 json!({"key": key, "engineer": e, "name": named(key, &e), "borg": v.borg, "protocol": v.protocol, "ms": v.ms, "syncs": true,
392 "note": (!missed.is_empty()).then_some(missed)})
393 })
394 .collect();
395 members.extend(mismatches.iter().map(|(key, m)| {
396 let v = versions.get(key);
397 let e = engineer(v);
398 json!({"key": key, "engineer": e, "name": named(key, &e), "borg": v.and_then(|v| v.borg.clone()), "protocol": protocol::number(&m.theirs),
399 "ms": m.ms, "syncs": false, "note": m.line(key)})
400 }));
401 json!({
402 "collective": name, "borg": update::VERSION, "protocol": ours, "check": on,
403 "latest": latest, "checked_ms": checked.as_ref().map(|c| c.checked_ms), "error": checked.and_then(|c| c.error),
404 "update": newer, "members": members,
405 })
406}
407
408async fn update_of(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
409 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
410 Json(update_view(&m.name, &m.home)).into_response()
411}
412
413fn stamp(path: &Path) -> Vec<(u64, u128)> {
415 let one = |p: &Path| std::fs::metadata(p).ok().map(|m| (m.len(), mtime(&m)));
416 match std::fs::read_dir(path) {
417 Ok(rd) => {
418 let mut v: Vec<(u64, u128)> = rd.filter_map(|e| e.ok()).filter_map(|e| one(&e.path())).collect();
419 v.sort();
420 v
421 }
422 Err(_) => one(path).into_iter().collect(),
423 }
424}
425
426fn mtime(m: &std::fs::Metadata) -> u128 {
427 m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos())
428}
429
430fn watched(home: &Path) -> Vec<(&'static str, Vec<PathBuf>)> {
432 vec![
433 (
434 "lessons",
435 vec![home.join("lessons.jsonl"), home.join("outcomes.jsonl"), home.join(outcome::TEAM_FILE), home.join("stats.jsonl"), stats::events_path(home)],
436 ),
437 (
439 "memory",
440 vec![home.join("lessons.jsonl"), home.join("outcomes.jsonl"), home.join(outcome::TEAM_FILE), home.join("episodes.jsonl"), absorb::path(home)],
441 ),
442 ("stats", vec![home.join("stats.jsonl"), stats::events_path(home)]),
443 ("drones", vec![home.join("presence"), presence::remote_path(home), msg::path(home), msg::seen_path(home)]),
445 ("messages", vec![msg::path(home), msg::seen_path(home)]),
446 ("feed", vec![stats::events_path(home)]),
447 ("queen", vec![home.join("queen"), home.join("stats.jsonl"), stats::events_path(home)]),
448 ("learning", vec![home.join("lessons.jsonl"), home.join("stats.jsonl"), stats::events_path(home)]),
450 ("select", vec![home.join(borg::select::summary::CACHE), home.join("outcomes.jsonl")]),
452 ("update", vec![update::path_for(home), protocol::versions_path(home), protocol::protocols_path(home)]),
454 ]
455}
456
457type Fingerprint = Vec<(&'static str, Vec<Vec<(u64, u128)>>)>;
459
460fn fingerprint(home: &Path) -> Fingerprint {
461 watched(home).into_iter().map(|(view, paths)| (view, paths.iter().map(|p| stamp(p)).collect())).collect()
462}
463
464async fn events(State(app): State<Arc<App>>, Query(p): Query<Pick>) -> Response {
465 let Some(m) = app.mind(p.c.as_deref()) else { return not_found(p.c.as_deref().unwrap_or_default()) };
466 let home = m.home.clone();
467 let start = fingerprint(&home);
468 let stream = futures_util::stream::unfold((home, start), |(home, mut last)| async move {
469 loop {
470 tokio::time::sleep(Duration::from_secs(1)).await;
471 let now = fingerprint(&home);
472 let changed: Vec<&str> = now.iter().zip(&last).filter(|(a, b)| a != b).map(|(a, _)| a.0).collect();
473 if !changed.is_empty() {
474 let ev = Event::default().event("change").data(json!({"changed": changed}).to_string());
475 last = now;
476 return Some((Ok::<Event, Infallible>(ev), (home, last)));
477 }
478 last = now;
479 }
480 });
481 let mut stop = app.stop.clone();
482 let stream = stream.take_until(async move {
483 let _ = stop.wait_for(|s| *s).await;
484 });
485 Sse::new(Box::pin(stream) as std::pin::Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>>).keep_alive(KeepAlive::default()).into_response()
486}
487
488const INDEX: &str = include_str!("../dist/index.html");
490const JS: &[u8] = include_bytes!("../dist/borg-web-ui.js");
491const WASM: &[u8] = include_bytes!("../dist/borg-web-ui_bg.wasm");
492
493fn asset(kind: &'static str, body: &'static [u8]) -> Response {
494 ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "no-cache")], body).into_response()
495}
496
497pub fn router(app: Arc<App>) -> Router {
498 Router::new()
499 .route("/", get(|| async { ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], INDEX).into_response() }))
500 .route("/borg-web-ui.js", get(|| async { asset("text/javascript", JS) }))
501 .route("/borg-web-ui_bg.wasm", get(|| async { asset("application/wasm", WASM) }))
502 .route("/api/collectives", get(collectives))
503 .route("/api/stats", get(stats_of))
504 .route("/api/lessons", get(lessons))
505 .route("/api/memory", get(memory_of))
506 .route("/api/drones", get(drones))
507 .route("/api/feed", get(feed))
508 .route("/api/queen", get(queen))
509 .route("/api/messages", get(messages))
510 .route("/api/messages/seen", post(messages_seen))
511 .route("/api/learning", get(learning_of))
512 .route("/api/select", get(select_of))
513 .route("/api/update", get(update_of))
514 .route("/api/events", get(events))
515 .layer(axum::middleware::from_fn(loopback_only))
516 .with_state(app)
517}
518
519async fn loopback_only(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
524 match req.headers().get(header::HOST).and_then(|h| h.to_str().ok()).is_some_and(loopback) {
525 true => next.run(req).await,
526 false => (StatusCode::FORBIDDEN, "borg web: served to a loopback Host only (127.0.0.1, localhost, [::1])\n").into_response(),
527 }
528}
529
530fn loopback(host: &str) -> bool {
532 let (name, port) = match host.strip_prefix('[') {
533 Some(v6) => match v6.split_once(']') {
534 Some(("::1", port)) => ("::1", port),
535 _ => return false,
536 },
537 None => host.find(':').map_or((host, ""), |i| host.split_at(i)),
538 };
539 let port = port.is_empty() || port.strip_prefix(':').is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
540 port && (name == "::1" || name == "127.0.0.1" || name.eq_ignore_ascii_case("localhost"))
541}
542
543pub async fn publish(minds: Vec<Mind>) {
546 loop {
547 for m in &minds {
548 stats::rollup(&m.home);
549 outcome::publish(&m.home);
550 }
551 tokio::time::sleep(Duration::from_secs(60)).await;
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
562 fn the_selection_view_follows_its_summary_and_the_outcomes() {
563 let home = std::env::temp_dir().join(format!("borg-web-watch-select-{}", std::process::id()));
564 let _ = std::fs::remove_dir_all(&home);
565 std::fs::create_dir_all(&home).unwrap();
566 let changed = |a: &Fingerprint, b: &Fingerprint| a.iter().zip(b).filter(|(x, y)| x != y).map(|(x, _)| x.0).collect::<Vec<_>>();
567 let before = fingerprint(&home);
568 std::fs::write(home.join(borg::select::summary::CACHE), "{\"sv\":1,\"at\":0}").unwrap();
569 let cached = fingerprint(&home);
570 assert_eq!(changed(&before, &cached), ["select"]);
571 std::fs::write(home.join("outcomes.jsonl"), "{}\n").unwrap();
572 assert!(changed(&cached, &fingerprint(&home)).contains(&"select"));
573 let _ = std::fs::remove_dir_all(&home);
574 }
575
576 #[test]
578 fn the_update_view_follows_the_release_check_and_the_members_versions() {
579 let home = std::env::temp_dir().join(format!("borg-web-watch-update-{}", std::process::id()));
580 let _ = std::fs::remove_dir_all(&home);
581 std::fs::create_dir_all(&home).unwrap();
582 let changed = |a: &Fingerprint, b: &Fingerprint| a.iter().zip(b).filter(|(x, y)| x != y).map(|(x, _)| x.0).collect::<Vec<_>>();
583 let before = fingerprint(&home);
584 std::fs::write(update::path_for(&home), "{\"checked_ms\":1,\"latest\":\"0.7.0\",\"error\":null}").unwrap();
585 let checked = fingerprint(&home);
586 assert_eq!(changed(&before, &checked), ["update"]);
587 let v = protocol::PeerVersion { borg: Some("0.7.0".into()), protocol: protocol::current(), ms: 1, engineer: None };
588 protocol::note_version(&home, "k", &v).unwrap();
589 assert_eq!(changed(&checked, &fingerprint(&home)), ["update"]);
590 let _ = std::fs::remove_dir_all(&home);
591 }
592
593 #[test]
595 fn the_memory_view_follows_the_absorb_log() {
596 let home = std::env::temp_dir().join(format!("borg-web-watch-memory-{}", std::process::id()));
597 let _ = std::fs::remove_dir_all(&home);
598 std::fs::create_dir_all(&home).unwrap();
599 let changed = |a: &Fingerprint, b: &Fingerprint| a.iter().zip(b).filter(|(x, y)| x != y).map(|(x, _)| x.0).collect::<Vec<_>>();
600 let before = fingerprint(&home);
601 std::fs::write(absorb::path(&home), "{\"id\":\"L1\",\"kind\":\"absorb\",\"ms\":1,\"by\":\"a@x\"}\n").unwrap();
602 assert_eq!(changed(&before, &fingerprint(&home)), ["memory"]);
603 let _ = std::fs::remove_dir_all(&home);
604 }
605}