`matrixAccounts` is meant to be the agent's full account list, but the hive-internal `main` account was outside it: the nix module emitted only the extras and `hive-matrix-daemon` prepended a `main` it synthesized from the per-agent paths, with the option schema forbidding the name outright. nix/agent-modules/matrix.nix now declares `main` itself, as an ordinary entry under `matrix.enable`, from the state-dir paths the module already used for its token path-watcher (now a shared `stateDir` binding) plus `matrix.url`. The whole set, `main` included, is serialized to HIVE_MATRIX_ACCOUNTS. accounts::configured therefore synthesizes `main` only when the parsed list carries none, and otherwise takes the declared one verbatim — hoisting it to index 0, since the daemon reads index 0 as the primary and nix serializes an attrset, so `main` sorts wherever its key falls. Declared xor synthesized: an agent whose harness predates this entry keeps working, a current one gets its own, and there is no arrangement where `main` is duplicated or missing. The reserved-name assertion is replaced rather than dropped: the name must now be legal (the module uses it), but `main`'s tokenFile stays pinned to `<state>/matrix-token`, since hive-c0re provisions the hive-internal token there and nowhere else — a retarget would evaluate fine and then never restore. The other two fields are mkDefault and free to override. Refs #4475
688 lines
28 KiB
Rust
688 lines
28 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 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.
|
|
//!
|
|
//! `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;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Context as _;
|
|
use matrix_sdk::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::paths;
|
|
|
|
/// One declared 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,
|
|
/// Path to the bearer-token file for this account (hive-c0re
|
|
/// writes it; the daemon reads it).
|
|
pub token_file: 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, the
|
|
/// same way `discover_token_accounts_in` already skips a discovered
|
|
/// token whose homeserver sidecar is missing.
|
|
#[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 account list: everything declared 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, plus any
|
|
/// dashboard-provisioned accounts discovered on disk.
|
|
///
|
|
/// 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(),
|
|
};
|
|
let mut accounts = ensure_hive_account(declared)?;
|
|
// Append dashboard-provisioned accounts (a `matrix-token-<name>` file +
|
|
// its `matrix-account-<name>.json` homeserver sidecar) that aren't
|
|
// already declared in config, so an account logged in via the dashboard
|
|
// form works without a `matrixAccounts` edit + rebuild. Explicit config
|
|
// wins on name collision — pass the configured set so discovery skips
|
|
// those names silently (a statically-configured account keeps its token
|
|
// on disk but is brought up from config, not discovery, so it must not
|
|
// log a spurious "no homeserver sidecar" warning).
|
|
let configured: std::collections::HashSet<String> =
|
|
accounts.iter().map(|a| a.name.clone()).collect();
|
|
accounts.extend(discover_token_accounts(&configured));
|
|
Ok(accounts)
|
|
}
|
|
|
|
/// 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: paths::token_file(),
|
|
state_dir: paths::matrix_state_dir(),
|
|
homeserver: None,
|
|
},
|
|
),
|
|
}
|
|
let mut seen = std::collections::HashSet::new();
|
|
for a in &declared {
|
|
if !seen.insert(a.name.as_str()) {
|
|
anyhow::bail!("duplicate matrix account name {:?}", a.name);
|
|
}
|
|
}
|
|
Ok(declared)
|
|
}
|
|
|
|
/// Scan the agent state dir for extra matrix accounts provisioned via the
|
|
/// dashboard login form: each is a `matrix-token-<name>` file plus a
|
|
/// `matrix-account-<name>.json` sidecar carrying the homeserver. Returns one
|
|
/// [`AccountCfg`] per discovered account that has BOTH files — a token
|
|
/// without a sidecar is skipped, because the homeserver is then unknown and
|
|
/// defaulting to the hive homeserver would be wrong for an external account.
|
|
/// Best-effort: an unreadable dir or malformed sidecar yields fewer
|
|
/// accounts, never an error — explicit `HIVE_MATRIX_ACCOUNTS` config stays
|
|
/// authoritative.
|
|
///
|
|
/// `configured` is the set of names already declared in
|
|
/// `HIVE_MATRIX_ACCOUNTS`; those are brought up from config regardless of
|
|
/// their on-disk sidecar, so discovery skips them silently rather than
|
|
/// warning about a missing sidecar for an account that isn't actually down.
|
|
fn discover_token_accounts(configured: &std::collections::HashSet<String>) -> Vec<AccountCfg> {
|
|
let token_path = paths::token_file();
|
|
let Some(state_dir) = token_path.parent() else {
|
|
return Vec::new();
|
|
};
|
|
discover_token_accounts_in(state_dir, configured)
|
|
}
|
|
|
|
/// Body of [`discover_token_accounts`] with the state dir injected, so the
|
|
/// scan (and the configured-name skip) is unit-testable against a tempdir.
|
|
fn discover_token_accounts_in(
|
|
state_dir: &Path,
|
|
configured: &std::collections::HashSet<String>,
|
|
) -> Vec<AccountCfg> {
|
|
let Ok(rd) = std::fs::read_dir(state_dir) else {
|
|
return Vec::new();
|
|
};
|
|
let mut out = Vec::new();
|
|
for entry in rd.flatten() {
|
|
let fname = entry.file_name();
|
|
let Some(fname) = fname.to_str() else {
|
|
continue;
|
|
};
|
|
// `matrix-token` (no suffix) is the hive account, handled separately;
|
|
// only `matrix-token-<name>` files are extra accounts.
|
|
let Some(name) = fname.strip_prefix("matrix-token-") else {
|
|
continue;
|
|
};
|
|
if name.is_empty() {
|
|
continue;
|
|
}
|
|
// Already declared in config: it's brought up from `HIVE_MATRIX_ACCOUNTS`,
|
|
// not discovery, and its on-disk token needs no sidecar. Skip silently so
|
|
// a statically-configured account doesn't log a spurious missing-sidecar
|
|
// warning.
|
|
if configured.contains(name) {
|
|
continue;
|
|
}
|
|
let sidecar = state_dir.join(format!("matrix-account-{name}.json"));
|
|
let Some(homeserver) = read_account_homeserver(&sidecar) else {
|
|
tracing::warn!(
|
|
account = name,
|
|
sidecar = %sidecar.display(),
|
|
"matrix: discovered token but no homeserver sidecar; skipping account \
|
|
(re-login via the dashboard to write it)"
|
|
);
|
|
continue;
|
|
};
|
|
out.push(AccountCfg {
|
|
name: name.to_owned(),
|
|
token_file: entry.path(),
|
|
state_dir: state_dir.join(format!("matrix-sdk-state-{name}")),
|
|
homeserver: Some(homeserver),
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Read `{"homeserver": "<url>"}` from a sidecar file. `None` when the file
|
|
/// is missing, unreadable, not valid JSON, or the `homeserver` field is
|
|
/// absent / empty.
|
|
fn read_account_homeserver(path: &Path) -> Option<String> {
|
|
let raw = std::fs::read_to_string(path).ok()?;
|
|
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
|
json.get("homeserver")?
|
|
.as_str()
|
|
.filter(|s| !s.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
/// 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 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. 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::PathBuf;
|
|
|
|
use super::{
|
|
AccountCfg, AccountStatus, discover_token_accounts_in, ensure_hive_account,
|
|
heartbeat_accounts_snapshot, pick_name, write_accounts_snapshot,
|
|
};
|
|
|
|
/// A declared account, as the nix module would serialize it.
|
|
fn cfg(name: &str) -> AccountCfg {
|
|
AccountCfg {
|
|
name: name.to_owned(),
|
|
token_file: 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,
|
|
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 discovery_skips_configured_names_and_orphan_tokens() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"hh-acct-disc-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
// A statically-configured account: token on disk, NO sidecar.
|
|
std::fs::write(dir.join("matrix-token-catgirl"), "tok").unwrap();
|
|
// An orphan dashboard token: no sidecar, not configured.
|
|
std::fs::write(dir.join("matrix-token-orphan"), "tok").unwrap();
|
|
// A fully dashboard-provisioned account: token + sidecar.
|
|
std::fs::write(dir.join("matrix-token-good"), "tok").unwrap();
|
|
std::fs::write(
|
|
dir.join("matrix-account-good.json"),
|
|
r#"{"homeserver":"https://good.example"}"#,
|
|
)
|
|
.unwrap();
|
|
// The hive account's own token must never be treated as an extra.
|
|
std::fs::write(dir.join("matrix-token"), "tok").unwrap();
|
|
|
|
let configured: HashSet<String> = ["main", "catgirl"]
|
|
.iter()
|
|
.map(|s| (*s).to_owned())
|
|
.collect();
|
|
let mut found: Vec<String> = discover_token_accounts_in(&dir, &configured)
|
|
.into_iter()
|
|
.map(|a| a.name)
|
|
.collect();
|
|
found.sort();
|
|
|
|
// catgirl (configured) + orphan (no sidecar) skipped; only `good` discovered.
|
|
assert_eq!(found, vec!["good".to_owned()]);
|
|
|
|
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();
|
|
}
|
|
}
|