Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-matrix-mcp/src/accounts.rs
atlas 97fb76ce99 matrix: the agent's daemon pulls its linked accounts from bao itself
hive-matrix-daemon now learns which external matrix accounts it has from
the swarm secret store, under the agent's own certificate, and the hive
push chain for matrix is gone.

The daemon lists swarm/agents/<agent>/matrix/ (the `list` its policy
grants on its own metadata subtree), reads each account's homeserver
from its credential, and brings the accounts up with their tokens from
the store. Every two minutes it lists again and exits with 75 when the
set of linked accounts changed; the unit restarts on 75 without counting
a failure. A listed name whose credential reads as absent is skipped and
logged once. At start it removes the matrix-token-<a> /
matrix-account-<a>.json pairs a hive delivered (a sidecar marks a pair
as delivered; a declared tokenFile keeps its token).

Removed: CredentialNotice and the $SWARM.credential.* subject and NATS
grant, the controller's publish and its queue precondition on the PUT
route, hive-c0re's credential subscription arm and workers/credential.rs,
priv_client::write_agent_matrix_token, hive-priv's WriteAgentMatrixToken
and its helpers, and the daemon's state-dir account discovery.

Kept: WriteAgentGithubToken and the external-forge path
(WriteAgentExtraForgeAccount, extra_forges.rs) are untouched, and a
declared matrixAccounts tokenFile is still read when the store has no
token for that account.

Refs #4348
2026-10-01 17:43:28 +02:00

744 lines
30 KiB
Rust

//! Multi-account configuration + the dispatch registry.
//!
//! A single `hive-matrix-daemon` can serve N matrix accounts (one
//! matrix-sdk `Client` each, with its own session/store dir + sync
//! loop). Accounts come from two places: the `HIVE_MATRIX_ACCOUNTS` env var
//! (JSON, written by the nix harness module from
//! `services.hyperhive.agent.matrixAccounts`), which declares the
//! **hive-internal account** (named `main`) as an ordinary entry
//! alongside any others; and the accounts the swarm secret store links to
//! this agent ([`discover_linked`]), which an operator linked through the
//! swarm UI.
//!
//! `main` is always present and is always the primary: it is moved to
//! index 0 whichever position it was declared at, and if the env var
//! declares no `main` at all (it is unset, or an agent's harness
//! predates the nix module emitting it) one is synthesized from the
//! per-agent single-account paths (`<state>/matrix-token` +
//! `<state>/matrix-sdk-state`) and the daemon-wide `HIVE_MATRIX_URL`.
//!
//! So a single-account agent (no `HIVE_MATRIX_ACCOUNTS`) gets exactly
//! `main` — zero config, same behaviour as before: omitting `account` on
//! a tool call resolves to `main`. When **multiple** accounts are
//! configured, omitting `account` is rejected with the list of names (see
//! `Registry::resolve` / `pick_name`) — the agent can't tell more than one
//! account exists, so it must choose explicitly.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context as _;
use matrix_sdk::Client;
use serde::{Deserialize, Serialize};
use crate::{credential, paths};
/// One matrix account. `homeserver` is optional per account
/// (defaults to the daemon-wide `HIVE_MATRIX_URL`) so accounts on the
/// same homeserver need not repeat it.
#[derive(Debug, Clone, Deserialize)]
pub struct AccountCfg {
/// Logical name the agent uses to address this account
/// (`account` arg on the MCP tools). Unique within the daemon.
pub name: String,
/// The declared bearer-token file, read when the store has no token for
/// this account. `None` for an account the store links, whose token only
/// the store holds.
pub token_file: Option<PathBuf>,
/// Per-account matrix-sdk sqlite store dir (crypto keys + cache).
pub state_dir: PathBuf,
/// Homeserver URL; falls back to [`paths::homeserver_url`] when absent.
#[serde(default)]
pub homeserver: Option<String>,
}
impl AccountCfg {
/// The effective homeserver URL — this account's own, else the
/// daemon-wide `HIVE_MATRIX_URL` — or `None` when neither is set.
///
/// `None` is a real answer, not a failure: the account is skipped.
#[must_use]
pub fn homeserver(&self) -> Option<String> {
self.homeserver.clone().or_else(paths::homeserver_url)
}
}
/// Name of the hive-internal account: the primary, and what a tool call
/// resolves to when it omits `account`.
const HIVE_ACCOUNT: &str = "main";
/// Build the declared account list: everything in `HIVE_MATRIX_ACCOUNTS`,
/// with the hive-internal `main` account hoisted to index 0 (= primary)
/// or synthesized there when the declaration doesn't carry one.
///
/// With no `HIVE_MATRIX_ACCOUNTS` set this returns just `main`, so a
/// single-account agent is unchanged.
///
/// # Errors
///
/// Returns an error if `HIVE_MATRIX_ACCOUNTS` is set but is not valid
/// JSON, or if two declared accounts share a name.
pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
let declared: Vec<AccountCfg> = match std::env::var_os("HIVE_MATRIX_ACCOUNTS") {
Some(raw) => serde_json::from_str(&raw.to_string_lossy())
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?,
None => Vec::new(),
};
ensure_hive_account(declared)
}
/// Put the hive-internal `main` account at index 0 of `declared`, then
/// reject duplicate names.
///
/// `main` is synthesized from the per-agent single-account paths **only
/// if `declared` doesn't already carry one** — the nix harness module
/// emits it as an ordinary `matrixAccounts` entry, so on a current
/// harness the declaration is authoritative and is used verbatim (just
/// moved to the front, since the module serializes an attrset and `main`
/// sorts wherever its key falls). A harness that predates that emits
/// nothing for it, and the synthesized account keeps that agent working
/// unchanged. Never both: `main` is declared xor synthesized, so there
/// is no arrangement where it is duplicated or missing.
///
/// Index 0 is load-bearing: the daemon's startup loop (`main.rs`) takes
/// index 0 as the primary — the account a tool call acts as when it
/// omits `account`, and the only one whose failure to restore is fatal.
///
/// # Errors
///
/// Returns an error if two declared accounts share a name.
fn ensure_hive_account(mut declared: Vec<AccountCfg>) -> anyhow::Result<Vec<AccountCfg>> {
match declared.iter().position(|a| a.name == HIVE_ACCOUNT) {
// Declared: keep it verbatim, just make it the primary. `remove` +
// `insert` rather than `swap`, so the other accounts keep their
// declared order.
Some(idx) => {
let hive = declared.remove(idx);
declared.insert(0, hive);
}
// Not declared: synthesize it from the legacy single-account paths
// + the daemon-wide homeserver.
None => declared.insert(
0,
AccountCfg {
name: HIVE_ACCOUNT.to_owned(),
token_file: Some(paths::token_file()),
state_dir: paths::matrix_state_dir(),
homeserver: None,
},
),
}
let mut seen = HashSet::new();
for a in &declared {
if !seen.insert(a.name.as_str()) {
anyhow::bail!("duplicate matrix account name {:?}", a.name);
}
}
Ok(declared)
}
/// The accounts the store links to this agent beyond the declared ones, and
/// the linked names that could not be brought up.
#[derive(Debug, Default)]
pub struct Discovery {
/// Accounts to bring up, in the order the store lists them.
pub accounts: Vec<AccountCfg>,
/// Listed names the store holds no credential for.
pub missing: Vec<String>,
/// Linked accounts stored without a homeserver. Defaulting to the hive's
/// would be wrong for an external account, so they are skipped.
pub no_homeserver: Vec<String>,
}
/// Ask the store which accounts it links to this agent, as [`AccountCfg`]s
/// for every one `declared` does not already name.
///
/// Empty when the harness names no agent or the container was given no store.
///
/// # Errors
/// The store refusing or failing a read; see [`credential::linked_accounts`].
pub async fn discover_linked(declared: &[AccountCfg]) -> anyhow::Result<Discovery> {
let Some(agent) = credential::agent_name() else {
return Ok(Discovery::default());
};
let Some(linked) = credential::linked_accounts(&agent).await? else {
return Ok(Discovery::default());
};
let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect();
let mut out = linked_cfgs(&state_root(), &declared, linked.found);
out.missing = linked.missing;
Ok(out)
}
/// The pure half of [`discover_linked`], with the state dir injected so it is
/// testable without a store.
fn linked_cfgs(
state_root: &Path,
declared: &HashSet<&str>,
linked: Vec<credential::Linked>,
) -> Discovery {
let mut out = Discovery::default();
for l in linked {
// A declared account is brought up from its declaration.
if declared.contains(l.name.as_str()) {
continue;
}
let Some(homeserver) = l.homeserver.filter(|h| !h.is_empty()) else {
out.no_homeserver.push(l.name);
continue;
};
out.accounts.push(AccountCfg {
state_dir: state_root.join(format!("matrix-sdk-state-{}", l.name)),
name: l.name,
token_file: None,
homeserver: Some(homeserver),
});
}
out
}
/// This agent's state dir, the parent of the `main` token file.
fn state_root() -> PathBuf {
paths::token_file()
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
}
/// Remove token files a hive delivered into this agent's state dir: each
/// `matrix-account-<name>.json` homeserver sidecar, and the
/// `matrix-token-<name>` beside it unless `declared` names `<name>`.
///
/// Nothing in this tree writes those files: an account linked through the
/// swarm comes from the store. A sidecar is what marks a pair as delivered
/// rather than an operator's: a declared `tokenFile` has none. Best-effort —
/// a failure is logged and skipped.
pub fn remove_delivered_files(declared: &[AccountCfg]) {
let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect();
remove_delivered_files_in(&state_root(), &declared);
}
/// Body of [`remove_delivered_files`] with the state dir injected.
fn remove_delivered_files_in(state_root: &Path, declared: &HashSet<&str>) {
let Ok(rd) = std::fs::read_dir(state_root) else {
return;
};
for entry in rd.flatten() {
let fname = entry.file_name();
let Some(name) = fname
.to_str()
.and_then(|f| f.strip_prefix("matrix-account-"))
.and_then(|f| f.strip_suffix(".json"))
.filter(|n| !n.is_empty())
else {
continue;
};
let mut doomed = vec![entry.path()];
if !declared.contains(name) {
doomed.push(state_root.join(format!("matrix-token-{name}")));
}
for path in doomed {
match std::fs::remove_file(&path) {
Ok(()) => {
tracing::info!(path = %path.display(), "removed a hive-delivered matrix file");
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(
path = %path.display(), error = %e,
"could not remove a hive-delivered matrix file"
),
}
}
}
}
/// Live status of one matrix account, as reported by [`Registry::list`]
/// (the `list_accounts` daemon op). Only accounts that successfully
/// restored a session appear, so `live` is always `true` today; the
/// field is kept so a future "configured but down" entry can report
/// `false` without a wire-shape change.
#[derive(Debug, Serialize)]
pub struct AccountStatus {
/// Logical account name (the `account` arg on the MCP tools).
pub name: String,
/// Effective homeserver URL the restored client is talking to.
pub homeserver: String,
/// The account's own matrix user id (`@user:server`), when known.
pub user_id: Option<String>,
/// Whether the account has a live, restored client. Always `true`
/// for registry entries today (the registry only holds restored
/// accounts); reserved for future configured-but-down reporting.
pub live: bool,
/// Whether this is the primary account (selected when a tool call
/// omits `account`).
pub is_primary: bool,
}
/// Account name → live `Client` map plus the primary-account name used
/// when a request omits `account`. Built once at daemon startup from
/// the accounts that successfully restored a session.
pub struct Registry {
primary: String,
by_name: HashMap<String, Arc<Client>>,
}
/// Pick which account name a request resolves to, or an error. Pure (no
/// `Client`) so the ambiguity rule is unit-testable. `names` is the sorted
/// set of configured account names.
///
/// - `Some(name)` → that name (membership is checked by the caller).
/// - `None` with a single account → the primary (zero-config default).
/// - `None` with multiple accounts → an error: the agent can't tell more
/// than one account exists, so force an explicit `account` and list the
/// choices.
fn pick_name<'a>(
account: Option<&'a str>,
primary: &'a str,
names: &[&str],
) -> Result<&'a str, String> {
match account {
Some(name) => Ok(name),
None if names.len() <= 1 => Ok(primary),
None => Err(format!(
"multiple matrix accounts configured; pass `account` explicitly — available: [{}]",
names.join(", ")
)),
}
}
impl Registry {
/// Build an empty registry whose primary is `primary`. Clients are
/// added with [`Registry::insert`] as each account restores.
#[must_use]
pub fn new(primary: String) -> Self {
Self {
primary,
by_name: HashMap::new(),
}
}
/// Register a restored client under `name`.
pub fn insert(&mut self, name: String, client: Client) {
self.by_name.insert(name, Arc::new(client));
}
/// Whether any account restored successfully.
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
/// Snapshot every restored account: name, homeserver, user id, and
/// primary flag. Registry membership == a session restored, so every
/// entry is reported `live`. Sorted primary-first then by name for a
/// stable order in the dashboard. Account-agnostic — the caller does
/// not resolve a single client (see `MatrixMcp::resolve` in
/// `crate::mcp`).
#[must_use]
pub fn list(&self) -> Vec<AccountStatus> {
let mut out: Vec<AccountStatus> = self
.by_name
.iter()
.map(|(name, client)| AccountStatus {
name: name.clone(),
homeserver: client.homeserver().to_string(),
user_id: client.user_id().map(ToString::to_string),
live: true,
is_primary: *name == self.primary,
})
.collect();
out.sort_by(|a, b| {
b.is_primary
.cmp(&a.is_primary)
.then_with(|| a.name.cmp(&b.name))
});
out
}
/// Resolve a request's `account` to a client. `None` selects the
/// primary account *only when it's unambiguous* — a single configured
/// account. With multiple accounts, omitting `account` is an error
/// (see `pick_name`): the agent can't tell more than one exists, so we
/// force an explicit choice and list the names. Returns a
/// human-readable error (listing known accounts) surfaced to the agent
/// as a tool error.
///
/// # Errors
///
/// Errors when `account` is omitted but multiple accounts are
/// configured, or when the named account (or the primary, if `None`)
/// has no live client — e.g. an unknown name, or the primary failed to
/// restore at startup.
pub fn resolve(&self, account: Option<&str>) -> Result<&Arc<Client>, String> {
let mut names: Vec<&str> = self.by_name.keys().map(String::as_str).collect();
names.sort_unstable();
let name = pick_name(account, &self.primary, &names)?;
self.by_name.get(name).ok_or_else(|| {
format!(
"unknown matrix account {name:?}; available accounts: [{}]",
names.join(", ")
)
})
}
/// 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())
}
/// Heartbeat the snapshot: rewrite it unconditionally so the file
/// mtime advances even when the account set is unchanged. The daemon
/// calls this on a timer (see `main`) so the dashboard's `as_of`
/// (derived from the file mtime) tracks real daemon liveness instead
/// of freezing at the boot-time write — a stalled mtime then means
/// the daemon is down, which the dashboard can render as stale/dimmed.
///
/// # Errors
/// Propagates filesystem errors from the atomic write.
pub fn heartbeat_snapshot(&self, path: &Path) -> anyhow::Result<()> {
heartbeat_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. Used for the boot-time publish.
///
/// The daemon rebuilds this file fresh on every boot, and restarts itself
/// when the store's linked accounts change, so the file lists exactly the
/// accounts that restored at the last start. Real-time liveness is conveyed by the file mtime,
/// which the daemon advances on a timer via
/// [`heartbeat_accounts_snapshot`] — a stalled mtime means the daemon is
/// down, so a reader can treat an old snapshot as stale.
///
/// # 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<()> {
write_accounts_snapshot_inner(path, accounts, false)
}
/// Like [`write_accounts_snapshot`] but always rewrites (tmp + rename)
/// even when the on-disk content is byte-identical, so the file mtime
/// advances. The daemon calls this on a periodic heartbeat so the
/// dashboard's `as_of` (the file mtime) reflects daemon liveness rather
/// than freezing at the boot-time write.
///
/// # Errors
/// Returns an error if the parent dir can't be created or the
/// write/rename fails.
pub fn heartbeat_accounts_snapshot(path: &Path, accounts: &[AccountStatus]) -> anyhow::Result<()> {
write_accounts_snapshot_inner(path, accounts, true)
}
/// Shared body for [`write_accounts_snapshot`] (idempotent) and
/// [`heartbeat_accounts_snapshot`] (`force`). When `force` is false the
/// rewrite is skipped if the on-disk content already matches, keeping the
/// mtime stable; when true the tmp + rename always runs so the mtime
/// advances.
fn write_accounts_snapshot_inner(
path: &Path,
accounts: &[AccountStatus],
force: bool,
) -> anyhow::Result<()> {
let body =
serde_json::to_string_pretty(accounts).expect("Vec<AccountStatus> is always serialisable");
if !force && 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 std::collections::HashSet;
use std::path::{Path, PathBuf};
use super::{
AccountCfg, AccountStatus, ensure_hive_account, heartbeat_accounts_snapshot, linked_cfgs,
pick_name, remove_delivered_files_in, write_accounts_snapshot,
};
use crate::credential::Linked;
/// A declared account, as the nix module would serialize it.
fn cfg(name: &str) -> AccountCfg {
AccountCfg {
name: name.to_owned(),
token_file: Some(PathBuf::from(format!(
"/agents/a/state/matrix-token-{name}"
))),
state_dir: PathBuf::from(format!("/agents/a/state/matrix-sdk-state-{name}")),
homeserver: Some(format!("https://{name}.example")),
}
}
#[test]
fn declared_main_is_used_verbatim_and_not_duplicated() {
// A current harness declares `main` itself; it must be taken as-is
// (its own paths, not the synthesized ones) and must not gain a
// second, synthesized copy.
let out = ensure_hive_account(vec![cfg("main")]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "main");
assert_eq!(
out[0].token_file,
Some(PathBuf::from("/agents/a/state/matrix-token-main"))
);
assert_eq!(out[0].homeserver.as_deref(), Some("https://main.example"));
}
#[test]
fn missing_main_is_synthesized_as_primary() {
// A harness predating the nix module's `main` entry declares extras
// only (or nothing at all): `main` is synthesized at index 0 from
// the per-agent paths, so such an agent keeps working.
let out = ensure_hive_account(Vec::new()).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "main");
let out = ensure_hive_account(vec![cfg("public")]).unwrap();
let names: Vec<&str> = out.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["main", "public"]);
// Synthesized, so it carries no per-account homeserver and falls
// back to the daemon-wide `HIVE_MATRIX_URL`.
assert!(out[0].homeserver.is_none());
}
#[test]
fn declared_main_alongside_extras_is_not_a_collision() {
// The nix module serializes an attrset, so `main` arrives wherever
// its key sorts. It is hoisted to index 0 (= primary), the extras
// keep their declared order, and nothing trips the duplicate check.
let out = ensure_hive_account(vec![cfg("ccc"), cfg("main"), cfg("public")]).unwrap();
let names: Vec<&str> = out.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["main", "ccc", "public"]);
}
#[test]
fn two_accounts_of_the_same_name_are_rejected() {
let err = ensure_hive_account(vec![cfg("public"), cfg("public")]).unwrap_err();
assert!(err.to_string().contains("duplicate matrix account name"));
}
#[test]
fn linked_accounts_skip_declared_names_and_ones_with_no_homeserver() {
let linked = vec![
Linked {
name: "catgirl".to_owned(),
homeserver: Some("https://declared.example".to_owned()),
},
Linked {
name: "bare".to_owned(),
homeserver: None,
},
Linked {
name: "good".to_owned(),
homeserver: Some("https://good.example".to_owned()),
},
];
let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect();
let out = linked_cfgs(Path::new("/agents/a/state"), &declared, linked);
let names: Vec<&str> = out.accounts.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["good"]);
assert_eq!(out.no_homeserver, ["bare"]);
let good = &out.accounts[0];
// The store holds the token, so there is no file to fall back to; the
// session dir keeps the name a hive-delivered account had, so its
// crypto store carries over.
assert_eq!(good.token_file, None);
assert_eq!(
good.state_dir,
PathBuf::from("/agents/a/state/matrix-sdk-state-good")
);
assert_eq!(good.homeserver.as_deref(), Some("https://good.example"));
}
#[test]
fn delivered_files_go_and_operator_files_stay() {
let dir = std::env::temp_dir().join(format!(
"hh-acct-clean-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let sidecar = r#"{"homeserver":"https://x.example"}"#;
// Delivered and not declared: both files go.
std::fs::write(dir.join("matrix-token-linked"), "tok").unwrap();
std::fs::write(dir.join("matrix-account-linked.json"), sidecar).unwrap();
// Delivered and also declared: the sidecar goes, the declared token stays.
std::fs::write(dir.join("matrix-token-catgirl"), "tok").unwrap();
std::fs::write(dir.join("matrix-account-catgirl.json"), sidecar).unwrap();
// An operator's by-hand token has no sidecar, and the hive account's own
// token is not an extra: both stay.
std::fs::write(dir.join("matrix-token-byhand"), "tok").unwrap();
std::fs::write(dir.join("matrix-token"), "tok").unwrap();
let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect();
remove_delivered_files_in(&dir, &declared);
let mut left: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
left.sort();
assert_eq!(
left,
[
"matrix-token",
"matrix-token-byhand",
"matrix-token-catgirl"
]
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn pick_name_single_account_defaults_to_primary() {
assert_eq!(pick_name(None, "main", &["main"]).unwrap(), "main");
}
#[test]
fn pick_name_multi_account_omitted_errors_with_menu() {
let err = pick_name(None, "main", &["main", "public"]).unwrap_err();
assert!(err.contains("pass `account` explicitly"));
assert!(err.contains("main"));
assert!(err.contains("public"));
}
#[test]
fn pick_name_explicit_passes_through_even_with_multiple() {
assert_eq!(
pick_name(Some("public"), "main", &["main", "public"]).unwrap(),
"public"
);
}
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();
}
#[test]
fn heartbeat_advances_mtime_even_when_unchanged() {
let dir = std::env::temp_dir().join(format!(
"hh-acct-hb-{}-{}",
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();
// The heartbeat must rewrite (tmp + rename) even when the content
// is byte-identical, so the mtime advances and the dashboard reads
// a fresh `as_of`. Small sleep so the new mtime is strictly later
// than the first (tmpfs/ext4 have sub-second mtime resolution).
std::thread::sleep(std::time::Duration::from_millis(20));
heartbeat_accounts_snapshot(&path, &sample()).unwrap();
let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(mtime2 > mtime1, "heartbeat should advance mtime");
// Content is still the same shape, and no leftover temp file.
let written = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed[0]["name"], "main");
assert!(!path.with_extension("json.tmp").exists());
std::fs::remove_dir_all(&dir).ok();
}
}