matrix-accounts dashboard: live status + homeserver from daemon snapshot (be-4)
This commit is contained in:
parent
06b2bd8b72
commit
f38bc13293
5 changed files with 286 additions and 14 deletions
|
|
@ -226,12 +226,19 @@ title). Its own esbuild bundle (`matrix-accounts.js`); no SSE — it reads
|
||||||
purpose-built endpoints.
|
purpose-built endpoints.
|
||||||
|
|
||||||
An agent picker (populated from `state.containers`, the live roster) drives a list of that
|
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=<name>` →
|
read from `GET /api/matrix-accounts?agent=<name>` →
|
||||||
`{ accounts: [ { name, homeserver, token_present } ] }`. The status
|
`{ accounts: [ { name, homeserver, token_present, live, user_id } ], as_of_unix }`.
|
||||||
reflects only whether a token is **stored** (labelled "token stored",
|
`token_present` is whether a token is **stored**; `live`, `homeserver`,
|
||||||
not "online"); a true live up/down indicator needs the matrix daemon's
|
and `user_id` are backfilled from the matrix daemon's
|
||||||
account registry and is a follow-up.
|
`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
|
The provision form (account name, homeserver, login method) posts
|
||||||
`POST /matrix-account-login` (`x-www-form-urlencoded`, operator-auth):
|
`POST /matrix-account-login` (`x-www-form-urlencoded`, operator-auth):
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,15 @@
|
||||||
//! path-watcher glob). It does not read the nix config, so it lists what is
|
//! 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
|
//! *provisioned*, not the full configured set: a config-declared account
|
||||||
//! without a token yet appears once it is provisioned via the login form.
|
//! 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
|
//! Homeserver + live up/down status (v2) come from the daemon's
|
||||||
//! registry; here `homeserver` is always `null`.
|
//! `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::extract::{Form, Query};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
@ -27,15 +34,58 @@ pub(super) struct MatrixAccountsQuery {
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct MatrixAccount {
|
struct MatrixAccount {
|
||||||
name: String,
|
name: String,
|
||||||
/// Always `None` in v1 (derived from the token filename, which carries no
|
/// Effective homeserver, backfilled from the daemon snapshot; `None` when
|
||||||
/// homeserver); populated in v2 from the daemon's account registry.
|
/// the account isn't in the snapshot (token present but not restored).
|
||||||
homeserver: Option<String>,
|
homeserver: Option<String>,
|
||||||
|
/// A token file for this account exists in the agent state dir.
|
||||||
token_present: bool,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct MatrixAccountsResponse {
|
struct MatrixAccountsResponse {
|
||||||
accounts: Vec<MatrixAccount>,
|
accounts: Vec<MatrixAccount>,
|
||||||
|
/// 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<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
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<String, SnapshotAccount>, Option<i64>) {
|
||||||
|
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::<Vec<SnapshotAccount>>(&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
|
/// 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<MatrixAccountsQuery>) ->
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir = Coordinator::agent_notes_dir(agent);
|
let dir = Coordinator::agent_notes_dir(agent);
|
||||||
|
let (snapshot, as_of_unix) = read_accounts_snapshot(&dir);
|
||||||
let mut accounts = Vec::new();
|
let mut accounts = Vec::new();
|
||||||
match std::fs::read_dir(&dir) {
|
match std::fs::read_dir(&dir) {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
|
|
@ -77,10 +128,15 @@ pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) ->
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Some(name) = account_name_from_filename(fname) {
|
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 {
|
accounts.push(MatrixAccount {
|
||||||
name,
|
homeserver: snap.map(|s| s.homeserver.clone()),
|
||||||
homeserver: None,
|
|
||||||
token_present: true,
|
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<MatrixAccountsQuery>) ->
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
accounts.sort_by(|a, b| a.name.cmp(&b.name));
|
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
|
/// Form body for `POST /matrix-account-login` (urlencoded, the dashboard's
|
||||||
|
|
@ -273,7 +333,64 @@ async fn matrix_whoami(homeserver: &str, token: &str) -> Result<String, String>
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn main_account_from_bare_token() {
|
fn main_account_from_bare_token() {
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@
|
||||||
//! never specify one for the hive account.
|
//! never specify one for the hive account.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
use matrix_sdk::Client;
|
use matrix_sdk::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,15 @@ async fn main() -> Result<()> {
|
||||||
return Ok(());
|
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
|
// Serve the socket against the registry. Spawned before driving the
|
||||||
// sync loops so the MCP bridge can connect as soon as the first
|
// sync loops so the MCP bridge can connect as soon as the first
|
||||||
// claude turn fires.
|
// claude turn fires.
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,21 @@ pub fn matrix_state_dir() -> PathBuf {
|
||||||
PathBuf::from(format!("{state_dir}/matrix-sdk-state"))
|
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
|
/// Hyperhive control socket — the daemon writes wake signals here so
|
||||||
/// the harness drives a new claude turn on incoming matrix events.
|
/// the harness drives a new claude turn on incoming matrix events.
|
||||||
/// Mirrors the path `forge_notify` writes to.
|
/// Mirrors the path `forge_notify` writes to.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue