From f38bc1329380c438c5715ce1f4c0e9b1d0679e79 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 22 Jun 2026 12:42:56 +0200 Subject: [PATCH] matrix-accounts dashboard: live status + homeserver from daemon snapshot (be-4) --- docs/web-ui/dashboard.md | 17 ++- hive-c0re/src/dashboard/matrix_accounts.rs | 133 +++++++++++++++++++-- hive-matrix-mcp/src/accounts.rs | 126 ++++++++++++++++++- hive-matrix-mcp/src/main.rs | 9 ++ hive-matrix-mcp/src/paths.rs | 15 +++ 5 files changed, 286 insertions(+), 14 deletions(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 08f8e686..0918dab6 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -226,12 +226,19 @@ title). Its own esbuild bundle (`matrix-accounts.js`); no SSE — it reads purpose-built endpoints. An agent picker (populated from `state.containers`, the live roster) drives a list of that -agent's configured accounts — name, homeserver, and a token-status dot — +agent's accounts — name, homeserver, user id, and a status dot — read from `GET /api/matrix-accounts?agent=` → -`{ accounts: [ { name, homeserver, token_present } ] }`. The status -reflects only whether a token is **stored** (labelled "token stored", -not "online"); a true live up/down indicator needs the matrix daemon's -account registry and is a follow-up. +`{ accounts: [ { name, homeserver, token_present, live, user_id } ], as_of_unix }`. +`token_present` is whether a token is **stored**; `live`, `homeserver`, +and `user_id` are backfilled from the matrix daemon's +`matrix-accounts.json` snapshot — a host-visible file the daemon writes at +startup after its sessions restore (an account with a token but absent +from the snapshot reports `live: false`). `as_of_unix` is the snapshot's +mtime (null when absent), so the dot can show "live as of N ago". The +snapshot is rewritten each daemon (re)start, so an old `as_of_unix` is +ambiguous (stable uptime vs dead daemon) — the live-status dot rendering +(3-state + snapshot-age tooltip, cross-referencing container-running +state) is the dashboard-side follow-up. The provision form (account name, homeserver, login method) posts `POST /matrix-account-login` (`x-www-form-urlencoded`, operator-auth): diff --git a/hive-c0re/src/dashboard/matrix_accounts.rs b/hive-c0re/src/dashboard/matrix_accounts.rs index af3ba0e7..c239d0b8 100644 --- a/hive-c0re/src/dashboard/matrix_accounts.rs +++ b/hive-c0re/src/dashboard/matrix_accounts.rs @@ -9,8 +9,15 @@ //! path-watcher glob). It does not read the nix config, so it lists what is //! *provisioned*, not the full configured set: a config-declared account //! without a token yet appears once it is provisioned via the login form. -//! Homeserver + live up/down status arrive in v2 from the daemon's account -//! registry; here `homeserver` is always `null`. +//! Homeserver + live up/down status (v2) come from the daemon's +//! `matrix-accounts.json` snapshot (written at startup after restores): the +//! response backfills `homeserver`, `user_id`, and `live` per account from +//! it, and carries the snapshot's `as_of_unix` (file mtime) so a reader can +//! judge freshness. An account with a token but absent from the snapshot +//! reports `live: false` (provisioned but not restored / daemon down). + +use std::collections::HashMap; +use std::path::Path; use axum::extract::{Form, Query}; use axum::response::{IntoResponse, Response}; @@ -27,15 +34,58 @@ pub(super) struct MatrixAccountsQuery { #[derive(Serialize)] struct MatrixAccount { name: String, - /// Always `None` in v1 (derived from the token filename, which carries no - /// homeserver); populated in v2 from the daemon's account registry. + /// Effective homeserver, backfilled from the daemon snapshot; `None` when + /// the account isn't in the snapshot (token present but not restored). homeserver: Option, + /// A token file for this account exists in the agent state dir. token_present: bool, + /// The account restored a live client per the daemon snapshot. `false` + /// when provisioned but absent from the snapshot (not up / daemon down). + live: bool, + /// The account's matrix user id, from the snapshot when known. + user_id: Option, } #[derive(Serialize)] struct MatrixAccountsResponse { accounts: Vec, + /// Unix mtime of the daemon's `matrix-accounts.json` snapshot (when the + /// live data was last published), or `None` when no snapshot exists yet. + /// Lets the dashboard show "live as of N ago" without treating a stale + /// snapshot as definitely down. + as_of_unix: Option, +} + +/// One entry of the daemon's `matrix-accounts.json` snapshot +/// (`hive-matrix-mcp::accounts::AccountStatus`). Only the fields the +/// dashboard backfills are read; extra fields (e.g. `is_primary`) are +/// ignored by serde. +#[derive(Deserialize)] +struct SnapshotAccount { + name: String, + homeserver: String, + #[serde(default)] + user_id: Option, + live: bool, +} + +/// Read the daemon's live-account snapshot from the agent state dir. Returns +/// the per-name entries plus the file's unix mtime (`as_of`). A missing or +/// unparseable file yields an empty map (every provisioned account then +/// reports `live: false`); `as_of` is `None` only when the file is absent. +fn read_accounts_snapshot(dir: &Path) -> (HashMap, Option) { + let path = dir.join("matrix-accounts.json"); + let as_of = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); + let map = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .map(|v| v.into_iter().map(|a| (a.name.clone(), a)).collect()) + .unwrap_or_default(); + (map, as_of) } /// Map a state-dir filename to the matrix account name it provisions, or @@ -65,6 +115,7 @@ pub(super) async fn get_matrix_accounts(Query(q): Query) -> } let dir = Coordinator::agent_notes_dir(agent); + let (snapshot, as_of_unix) = read_accounts_snapshot(&dir); let mut accounts = Vec::new(); match std::fs::read_dir(&dir) { Ok(entries) => { @@ -77,10 +128,15 @@ pub(super) async fn get_matrix_accounts(Query(q): Query) -> continue; }; if let Some(name) = account_name_from_filename(fname) { + // Backfill live status + homeserver + user id from the + // daemon snapshot; absent => provisioned but not restored. + let snap = snapshot.get(&name); accounts.push(MatrixAccount { - name, - homeserver: None, + homeserver: snap.map(|s| s.homeserver.clone()), token_present: true, + live: snap.is_some_and(|s| s.live), + user_id: snap.and_then(|s| s.user_id.clone()), + name, }); } } @@ -92,7 +148,11 @@ pub(super) async fn get_matrix_accounts(Query(q): Query) -> } } accounts.sort_by(|a, b| a.name.cmp(&b.name)); - axum::Json(MatrixAccountsResponse { accounts }).into_response() + axum::Json(MatrixAccountsResponse { + accounts, + as_of_unix, + }) + .into_response() } /// Form body for `POST /matrix-account-login` (urlencoded, the dashboard's @@ -273,7 +333,64 @@ async fn matrix_whoami(homeserver: &str, token: &str) -> Result #[cfg(test)] mod tests { - use super::{account_name_from_filename, is_plain_ident}; + use super::{account_name_from_filename, is_plain_ident, read_accounts_snapshot}; + + fn unique_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!( + "hh-c0re-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn snapshot_parses_entries_and_reports_as_of() { + let dir = unique_dir("snap"); + std::fs::write( + dir.join("matrix-accounts.json"), + r#"[ + {"name":"main","homeserver":"http://hs","user_id":"@a:hs","live":true,"is_primary":true}, + {"name":"pub","homeserver":"https://matrix.org","user_id":null,"live":true,"is_primary":false} + ]"#, + ) + .unwrap(); + let (map, as_of) = read_accounts_snapshot(&dir); + assert_eq!(map.len(), 2); + assert_eq!(map["main"].homeserver, "http://hs"); + assert!(map["main"].live); + assert_eq!(map["main"].user_id.as_deref(), Some("@a:hs")); + assert_eq!(map["pub"].user_id, None); + // is_primary is present in the file but ignored — no panic on the + // extra field. + assert!(as_of.is_some()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn snapshot_absent_yields_empty_map_and_no_as_of() { + let dir = unique_dir("nosnap"); + let (map, as_of) = read_accounts_snapshot(&dir); + assert!(map.is_empty()); + assert!(as_of.is_none()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn snapshot_unparseable_is_empty_but_as_of_set() { + let dir = unique_dir("badsnap"); + std::fs::write(dir.join("matrix-accounts.json"), "not json").unwrap(); + let (map, as_of) = read_accounts_snapshot(&dir); + assert!(map.is_empty()); + // File exists, so freshness is still reported even though it can't + // be parsed (every account then reports live: false). + assert!(as_of.is_some()); + std::fs::remove_dir_all(&dir).ok(); + } #[test] fn main_account_from_bare_token() { diff --git a/hive-matrix-mcp/src/accounts.rs b/hive-matrix-mcp/src/accounts.rs index 97bd8a04..5d9e424a 100644 --- a/hive-matrix-mcp/src/accounts.rs +++ b/hive-matrix-mcp/src/accounts.rs @@ -16,9 +16,10 @@ //! never specify one for the hive account. use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use anyhow::Context as _; use matrix_sdk::Client; use serde::{Deserialize, Serialize}; @@ -195,4 +196,127 @@ impl Registry { ) }) } + + /// Publish the live-account snapshot ([`Registry::list`]) to `path`. + /// Thin wrapper over [`write_accounts_snapshot`] (split out so the + /// atomic-write logic is unit-testable without a live `Client`). + /// Called once at daemon startup after all restores. + /// + /// # Errors + /// Propagates filesystem errors from the atomic write. + pub fn write_snapshot(&self, path: &Path) -> anyhow::Result<()> { + write_accounts_snapshot(path, &self.list()) + } +} + +/// Atomically write `accounts` as pretty-printed JSON to `path` +/// (`.tmp` + rename) so a dashboard reader never sees a partial +/// file. Idempotent — skips the rewrite when the on-disk content already +/// matches, keeping the mtime stable. +/// +/// The daemon rebuilds this file fresh on every boot (and is restarted +/// by the `matrix-token*` path-watcher when a new account is +/// provisioned), so the file lists exactly the accounts that restored at +/// the last start. A reader wanting true real-time liveness should also +/// confirm the daemon is running — a stale file otherwise reports the +/// last-known-up set. +/// +/// # Errors +/// Returns an error if the parent dir can't be created or the +/// write/rename fails. +pub fn write_accounts_snapshot(path: &Path, accounts: &[AccountStatus]) -> anyhow::Result<()> { + let body = + serde_json::to_string_pretty(accounts).expect("Vec is always serialisable"); + if std::fs::read_to_string(path).ok().as_deref() == Some(body.as_str()) { + return Ok(()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, path).with_context(|| { + format!( + "rename {} -> {} (atomic publish)", + tmp.display(), + path.display() + ) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{AccountStatus, write_accounts_snapshot}; + + fn sample() -> Vec { + vec![ + AccountStatus { + name: "main".to_owned(), + homeserver: "http://localhost:8008".to_owned(), + user_id: Some("@agent:hive".to_owned()), + live: true, + is_primary: true, + }, + AccountStatus { + name: "public".to_owned(), + homeserver: "https://matrix.org".to_owned(), + user_id: None, + live: true, + is_primary: false, + }, + ] + } + + #[test] + fn snapshot_writes_json_and_cleans_up_tmp() { + let dir = std::env::temp_dir().join(format!( + "hh-acct-snap-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("matrix-accounts.json"); + + write_accounts_snapshot(&path, &sample()).unwrap(); + let written = std::fs::read_to_string(&path).unwrap(); + // Round-trips to the same shape the dashboard consumes. + let parsed: serde_json::Value = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed[0]["name"], "main"); + assert_eq!(parsed[0]["live"], true); + assert_eq!(parsed[0]["is_primary"], true); + assert_eq!(parsed[1]["homeserver"], "https://matrix.org"); + assert!(parsed[1]["user_id"].is_null()); + // No leftover temp file after the atomic publish. + assert!(!path.with_extension("json.tmp").exists()); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn snapshot_is_idempotent_when_unchanged() { + let dir = std::env::temp_dir().join(format!( + "hh-acct-idem-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("matrix-accounts.json"); + + write_accounts_snapshot(&path, &sample()).unwrap(); + let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap(); + // Second write with identical content must skip the rename (mtime + // stays put), so inotify watchers don't see a spurious change. + write_accounts_snapshot(&path, &sample()).unwrap(); + let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap(); + assert_eq!(mtime1, mtime2); + + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/hive-matrix-mcp/src/main.rs b/hive-matrix-mcp/src/main.rs index 6ac631a6..ee2a1254 100644 --- a/hive-matrix-mcp/src/main.rs +++ b/hive-matrix-mcp/src/main.rs @@ -103,6 +103,15 @@ async fn main() -> Result<()> { return Ok(()); } + // Publish the live-account snapshot (BE-4) for the dashboard: the + // accounts that restored, with effective homeserver + user id. Lands + // in the host-visible state dir so hive-c0re reads it to show live + // up/down + backfill homeserver. Best-effort — a failed write must + // not stop the daemon from serving. + if let Err(e) = registry.write_snapshot(&paths::accounts_file()) { + tracing::warn!(error = %format!("{e:#}"), "failed to write matrix-accounts snapshot"); + } + // Serve the socket against the registry. Spawned before driving the // sync loops so the MCP bridge can connect as soon as the first // claude turn fires. diff --git a/hive-matrix-mcp/src/paths.rs b/hive-matrix-mcp/src/paths.rs index 9993a776..ccc8a441 100644 --- a/hive-matrix-mcp/src/paths.rs +++ b/hive-matrix-mcp/src/paths.rs @@ -58,6 +58,21 @@ pub fn matrix_state_dir() -> PathBuf { PathBuf::from(format!("{state_dir}/matrix-sdk-state")) } +/// Resolve the live-accounts snapshot file the daemon writes after +/// startup restores. Override via `HIVE_MATRIX_ACCOUNTS_FILE`; default +/// is `/matrix-accounts.json` — the host-visible +/// per-agent state dir (sibling to `matrix-token`) that hive-c0re's +/// dashboard reads to backfill live up/down status + the effective +/// homeserver for each provisioned account. +#[must_use] +pub fn accounts_file() -> PathBuf { + if let Some(p) = std::env::var_os("HIVE_MATRIX_ACCOUNTS_FILE") { + return PathBuf::from(p); + } + let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default(); + PathBuf::from(format!("{state_dir}/matrix-accounts.json")) +} + /// Hyperhive control socket — the daemon writes wake signals here so /// the harness drives a new claude turn on incoming matrix events. /// Mirrors the path `forge_notify` writes to.