matrix-accounts dashboard: live status + homeserver from daemon snapshot (be-4)

This commit is contained in:
damocles 2026-06-22 12:42:56 +02:00 committed by mara
commit f38bc13293
5 changed files with 286 additions and 14 deletions

View file

@ -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`
/// (`<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<AccountStatus> 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<AccountStatus> {
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();
}
}

View file

@ -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.

View file

@ -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 `<HYPERHIVE_STATE_DIR>/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.