hyperhive/hive-matrix-mcp/src/main.rs

226 lines
9.6 KiB
Rust

//! `hive-matrix-daemon` binary — long-running matrix-sdk Client + sync
//! loop per matrix account. Bridges incoming room events to hyperhive
//! wake signals and serves the unix socket the stdio MCP bridge talks to.
//!
//! Lifecycle:
//! 1. Read the configured account list (`accounts::configured()` —
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
//! 2. For each account: whoami probe → recover `user_id` + `device_id`
//! → restore matrix-sdk session (no login flow), install the
//! message-event handler, and spawn its own sync loop.
//! 3. Serve the unix socket against an account→Client registry; each
//! MCP request routes to the account named in its `account` field
//! (the primary account when omitted).
//!
//! Standalone-degraded boot: the PRIMARY account having no token file →
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re
//! provisions it. A SECONDARY account missing its token is skipped (the
//! daemon still serves the others).
//!
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
//! `client::build_and_restore`, and it mirrors the missing-token policy
//! above — a rejected PRIMARY token drops the stale token + sdk state and
//! exits 0 for systemd re-provisioning, while a rejected SECONDARY token
//! is removed and that one account is skipped so the daemon keeps serving
//! the primary and any other healthy account.
use std::sync::Arc;
use anyhow::{Context, Result};
use matrix_sdk::{Client, config::SyncSettings};
mod accounts;
mod client;
mod handlers;
mod paths;
mod protocol;
mod socket;
mod timeline;
mod wake;
use accounts::{AccountCfg, Registry};
/// A per-account sync loop, boxed so loops for N accounts can be driven
/// concurrently on the main task. Deliberately NOT `Send`: matrix-sdk's
/// sync future isn't `Send`, so these run on the current task (via
/// `select_all`) rather than `tokio::spawn`/`JoinSet`.
type SyncLoop = std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>>>>;
/// How often the daemon rewrites the accounts snapshot to advance its
/// mtime, so the dashboard's `as_of` tracks daemon liveness. 30s keeps
/// the staleness window small while the rewrite cost (one tmp + rename of
/// a tiny file) is negligible.
const ACCOUNTS_HEARTBEAT_SECS: u64 = 30;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.init();
let cfgs = accounts::configured().context("read matrix account config")?;
let mcp_socket = paths::daemon_socket();
let hyperhive_socket = paths::hyperhive_socket();
let multi = cfgs.len() > 1;
let primary = cfgs[0].name.clone();
let mut registry = Registry::new(primary);
let mut sync_loops: Vec<SyncLoop> = Vec::new();
for (idx, cfg) in cfgs.into_iter().enumerate() {
let is_primary = idx == 0;
// Account-tag the wakes only in multi-account mode so single-
// account wake bodies stay byte-identical to the legacy format.
let tag = multi.then(|| cfg.name.clone());
match bring_up_account(&cfg, &hyperhive_socket, tag, is_primary).await {
Ok(Some((client, sync_loop))) => {
registry.insert(cfg.name, client);
sync_loops.push(sync_loop);
}
// No token yet: for the primary that means the daemon isn't
// useful — exit 0 like the legacy single-account path so the
// systemd path-watcher restarts us when the token appears.
Ok(None) if is_primary => {
tracing::warn!(
account = %cfg.name,
"primary matrix account has no token yet; exiting cleanly \
(systemd restarts us when hive-c0re provisions it)"
);
return Ok(());
}
Ok(None) => {
tracing::warn!(account = %cfg.name, "secondary matrix account has no token; skipping");
}
// The primary failing to restore is fatal (propagate so
// systemd retries on a transient blip — matches legacy
// behaviour); a secondary failing is logged and skipped.
Err(e) if is_primary => return Err(e.context("bring up primary matrix account")),
Err(e) => {
tracing::error!(account = %cfg.name, error = %format!("{e:#}"), "secondary matrix account failed to restore; skipping");
}
}
}
if registry.is_empty() {
tracing::warn!("no matrix accounts restored; exiting cleanly");
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.
let registry = Arc::new(registry);
// Heartbeat: periodically rewrite the accounts snapshot so its mtime
// advances while the daemon lives. The dashboard derives `as_of` from
// the file mtime, so a stalled mtime now means the daemon is down —
// which lets the dashboard dim accounts whose snapshot is stale rather
// than reporting the boot-time set forever (BE-4 follow-up).
let hb_registry = Arc::clone(&registry);
tokio::spawn(async move {
let mut tick =
tokio::time::interval(std::time::Duration::from_secs(ACCOUNTS_HEARTBEAT_SECS));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let path = paths::accounts_file();
loop {
tick.tick().await;
if let Err(e) = hb_registry.heartbeat_snapshot(&path) {
tracing::warn!(error = %format!("{e:#}"), "matrix-accounts heartbeat write failed");
}
}
});
let socket_listener = mcp_socket.clone();
tokio::spawn(async move {
if let Err(e) = socket::serve(&socket_listener, registry).await {
tracing::error!(error = %e, "mcp socket server exited");
}
});
// Drive all per-account sync loops concurrently on this task (they
// aren't `Send`, so no `tokio::spawn`). matrix-sdk reconnects
// internally, so any loop returning is exceptional — log it and exit
// so systemd restarts the whole daemon cleanly.
let (result, idx, _rest) = futures_util::future::select_all(sync_loops).await;
match result {
Ok(()) => tracing::warn!(
account_index = idx,
"a matrix sync loop exited cleanly; restarting daemon"
),
Err(e) => {
tracing::error!(account_index = idx, error = %format!("{e:#}"), "a matrix sync loop errored; restarting daemon");
}
}
Ok(())
}
/// Restore one account's client (when its token exists), install its
/// message handler, and build its sync loop. Returns
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
/// when its token file is absent (caller decides primary-vs-secondary
/// handling). The returned sync loop is driven by the caller.
async fn bring_up_account(
cfg: &AccountCfg,
hyperhive_socket: &std::path::Path,
tag: Option<String>,
is_primary: bool,
) -> Result<Option<(Client, SyncLoop)>> {
let homeserver = cfg.homeserver();
if !tokio::fs::try_exists(&cfg.token_file)
.await
.unwrap_or(false)
{
return Ok(None);
}
tracing::info!(
account = %cfg.name,
homeserver,
token_file = %cfg.token_file.display(),
state_dir = %cfg.state_dir.display(),
"bringing up matrix account"
);
let client =
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary)
.await
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
// Best-effort: sync the agent icon to this account's matrix avatar over
// the live (authenticated, correct-homeserver) Client. Replaces the old
// curl oneshot; failures are swallowed inside sync_avatar.
client::sync_avatar(&client, &cfg.state_dir, &cfg.name).await;
timeline::install_message_handler(&client, hyperhive_socket.to_path_buf(), tag.clone());
let sync_client = client.clone();
let cb_client = client.clone();
let invite_socket = Arc::new(hyperhive_socket.to_path_buf());
let invite_notified = Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new()));
let sync_loop: SyncLoop = Box::pin(async move {
sync_client
.sync_with_callback(SyncSettings::default(), move |_response| {
let client = cb_client.clone();
let socket = invite_socket.clone();
let notified = invite_notified.clone();
let tag = tag.clone();
async move {
timeline::sweep_invites(&client, &socket, &notified, tag.as_deref()).await;
matrix_sdk::LoopCtrl::Continue
}
})
.await
.context("matrix-sdk sync loop exited")?;
Ok(())
});
Ok(Some((client, sync_loop)))
}