matrixAccounts: hive 'main' is implicit primary, option declares extras only

This commit is contained in:
damocles 2026-06-15 21:09:15 +02:00 committed by mara
commit 36fb041c55
2 changed files with 79 additions and 115 deletions

View file

@ -2,16 +2,18 @@
//! //!
//! A single `hive-matrix-daemon` can serve N matrix accounts (one //! A single `hive-matrix-daemon` can serve N matrix accounts (one
//! matrix-sdk `Client` each, with its own session/store dir + sync //! matrix-sdk `Client` each, with its own session/store dir + sync
//! loop). The account list comes from the `HIVE_MATRIX_ACCOUNTS` env //! loop). The **hive-internal account** (named `main`) is always
//! var (JSON, written by the nix harness module from //! present and is the primary: it is synthesized from the per-agent
//! `hyperhive.matrixAccounts`); when that var is absent the daemon //! single-account paths (`<state>/matrix-token` +
//! synthesizes the single legacy account from the existing //! `<state>/matrix-sdk-state`) and the daemon-wide `HIVE_MATRIX_URL`.
//! `HIVE_MATRIX_*` env so every current single-account agent keeps //! Any **extra** accounts come from the `HIVE_MATRIX_ACCOUNTS` env var
//! working with zero config change. //! (JSON, written by the nix harness module from
//! `hyperhive.matrixAccounts`) and are appended after `main`.
//! //!
//! The FIRST configured account is the **primary**: it is selected when //! So a single-account agent (no `HIVE_MATRIX_ACCOUNTS`) gets exactly
//! a tool call omits `account`, so single-account callers never specify //! `main` — zero config, same behaviour as before. The **primary** is
//! one. //! `main`: it is selected when a tool call omits `account`, so callers
//! never specify one for the hive account.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -51,36 +53,43 @@ impl AccountCfg {
} }
} }
/// Read the configured account list. Parses `HIVE_MATRIX_ACCOUNTS` /// Build the account list: the always-present hive-internal `main`
/// (JSON array) when set; otherwise returns the single legacy account /// account (primary, synthesized from the per-agent single-account
/// built from `HIVE_MATRIX_*` / `HYPERHIVE_STATE_DIR`. /// paths) followed by any extras declared in `HIVE_MATRIX_ACCOUNTS`.
///
/// With no `HIVE_MATRIX_ACCOUNTS` set this returns just `main`, so a
/// single-account agent is unchanged. When set, the env var carries
/// only the *extra* accounts (the nix `matrixAccounts` option never
/// redeclares the hive account); they are appended after `main`, which
/// stays index 0 = primary.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if `HIVE_MATRIX_ACCOUNTS` is set but is not valid /// Returns an error if `HIVE_MATRIX_ACCOUNTS` is set but is not valid
/// JSON, is empty, or contains duplicate account names. /// JSON, or if an extra account's name collides with `main` or another
/// extra.
pub fn configured() -> anyhow::Result<Vec<AccountCfg>> { pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") else { // The hive-internal account: always primary, named `main`, built
// Legacy single-account fallback — the only deployed shape until // from the legacy single-account paths + the daemon-wide homeserver.
// the nix `matrixAccounts` option lands. let hive = AccountCfg {
return Ok(vec![AccountCfg { name: "main".to_owned(),
name: "default".to_owned(), token_file: paths::token_file(),
token_file: paths::token_file(), state_dir: paths::matrix_state_dir(),
state_dir: paths::matrix_state_dir(), homeserver: None,
homeserver: None,
}]);
}; };
let raw = raw.to_string_lossy(); let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") else {
let accounts: Vec<AccountCfg> = serde_json::from_str(&raw) return Ok(vec![hive]);
};
let extras: Vec<AccountCfg> = serde_json::from_str(&raw.to_string_lossy())
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?; .map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
if accounts.is_empty() { let mut accounts = Vec::with_capacity(extras.len() + 1);
anyhow::bail!("HIVE_MATRIX_ACCOUNTS is an empty array — declare at least one account"); accounts.push(hive);
} accounts.extend(extras);
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
for a in &accounts { for a in &accounts {
if !seen.insert(a.name.as_str()) { if !seen.insert(a.name.as_str()) {
anyhow::bail!( anyhow::bail!(
"duplicate matrix account name {:?} in HIVE_MATRIX_ACCOUNTS", "duplicate matrix account name {:?} (the hive-internal account is named \"main\")",
a.name a.name
); );
} }

View file

@ -309,11 +309,9 @@ in
description = '' description = ''
Path to this account's bearer-token file. The daemon reads Path to this account's bearer-token file. The daemon reads
the token from here to restore the matrix session; how the the token from here to restore the matrix session; how the
file gets populated is the provisioner's concern (hive-c0re file gets populated is the provisioner's concern (an
for in-hive accounts, an operator-supplied secret for an operator-supplied secret for an external account). The
external account). The daemon skips an account whose token daemon skips an extra account whose token file is absent.
file is absent (the primary account being absent makes the
daemon exit cleanly until it appears).
''; '';
}; };
sessionDir = lib.mkOption { sessionDir = lib.mkOption {
@ -342,10 +340,6 @@ in
default = { }; default = { };
example = lib.literalExpression '' example = lib.literalExpression ''
{ {
main = {
tokenFile = "/agents/dmatrix/state/matrix-token";
sessionDir = "/agents/dmatrix/state/matrix-sdk-state";
};
ccc = { ccc = {
tokenFile = "/agents/dmatrix/state/matrix-token-ccc"; tokenFile = "/agents/dmatrix/state/matrix-token-ccc";
sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc"; sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc";
@ -354,35 +348,27 @@ in
} }
''; '';
description = '' description = ''
Declare multiple matrix accounts served by a single Declare *additional* matrix accounts served by the single
`hive-matrix-daemon` (one matrix-sdk Client + sync loop each), `hive-matrix-daemon` (one matrix-sdk Client + sync loop each),
replacing the wasteful "one MCP server + daemon per account" beyond the agent's built-in hive-internal account. This replaces
pattern. The attribute name keys each account (unique by the wasteful "one MCP server + daemon per account" pattern. The
construction) and is the handle the matrix MCP tools target via attribute name keys each account (unique by construction) and is
their `account` argument; omitting `account` on a tool call selects the handle the matrix MCP tools target via their `account`
`hyperhive.matrixPrimaryAccount`. argument.
The **hive-internal account is always present and is the primary**:
it is named `main`, synthesized by the daemon from
`hyperhive.matrix.url` + `<state>/matrix-token` +
`<state>/matrix-sdk-state`, and is the account a tool call acts as
when it omits `account`. You never declare it here --- this option
is only for the extras (e.g. an external public-matrix account).
Leave empty (the default) for the common single-account case: the Leave empty (the default) for the common single-account case: the
daemon then synthesizes one account from `hyperhive.matrix.url` + agent then has only `main`, exactly as before. When non-empty, the
`<state>/matrix-token` + `<state>/matrix-sdk-state`, so existing extras are serialized to the daemon's `HIVE_MATRIX_ACCOUNTS`
agents need no change. When non-empty, the set is serialized to the environment variable and the daemon appends them after `main`.
daemon's `HIVE_MATRIX_ACCOUNTS` environment variable Requires `hyperhive.matrix.enable` (there is no `main` to extend
(primary-account-first) and the legacy single-account fallback is otherwise).
bypassed.
'';
};
options.hyperhive.matrixPrimaryAccount = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "main";
description = ''
Which `hyperhive.matrixAccounts` key is the agent's primary
account --- the one the matrix MCP tools act as when a tool call
omits its `account` argument. May be left null when exactly one
account is declared (that sole account is then primary); it is
required (asserted) when more than one account is declared. Ignored
when `matrixAccounts` is empty.
''; '';
}; };
@ -774,27 +760,15 @@ in
assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon); assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon);
message = "hyperhive.icon must point to an .svg file"; message = "hyperhive.icon must point to an .svg file";
} }
# With more than one matrix account, the primary must be named # Extra matrix accounts only make sense alongside the hive-internal
# explicitly --- there is no inherent order in the attrset to pick # `main` account they extend, which exists only when matrix is
# one from. # enabled.
{ {
assertion = assertion = config.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable;
builtins.length (builtins.attrNames config.hyperhive.matrixAccounts) <= 1
|| config.hyperhive.matrixPrimaryAccount != null;
message = message =
"hyperhive.matrixPrimaryAccount must be set when more than one " "hyperhive.matrixAccounts requires hyperhive.matrix.enable = true "
+ "hyperhive.matrixAccounts entry is declared (it selects the account " + "(the extras extend the hive-internal `main` account, which only "
+ "used when a matrix tool call omits `account`)."; + "exists when matrix is enabled).";
}
# When set, the primary must name an actual declared account.
{
assertion =
config.hyperhive.matrixPrimaryAccount == null
|| builtins.hasAttr config.hyperhive.matrixPrimaryAccount config.hyperhive.matrixAccounts;
message =
"hyperhive.matrixPrimaryAccount (\"${toString config.hyperhive.matrixPrimaryAccount}\") "
+ "must be one of the hyperhive.matrixAccounts keys "
+ "([ ${lib.concatStringsSep " " (builtins.attrNames config.hyperhive.matrixAccounts)} ]).";
} }
# hyperhive.frontend.extraFiles[*].target is concatenated into # hyperhive.frontend.extraFiles[*].target is concatenated into
# $out during the mergedDist build. The option's strMatching # $out during the mergedDist build. The option's strMatching
@ -1349,45 +1323,26 @@ in
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
RUST_LOG = "info"; RUST_LOG = "info";
} }
# Multi-account: serialize the declared accounts to the JSON the # Multi-account: serialize the *extra* accounts to the JSON the
# daemon parses (`accounts::configured`). Only set when accounts # daemon parses (`accounts::configured`). Only set when extras are
# are declared, so the single-account agents (the common case) # declared; the daemon always synthesizes the primary `main`
# keep hitting the daemon's legacy fallback (which is bypassed the # (hive-internal) account itself from the per-agent paths and
# moment HIVE_MATRIX_ACCOUNTS is present). The `matrixAccounts` # prepends it, so we emit extras only. Each entry is in the
# attrset is keyed by account name (unique by construction); we # daemon's `AccountCfg` serde shape: name (the attr key) /
# emit a primary-first JSON array (the daemon treats index 0 as the # token_file / state_dir / optional homeserver.
# primary) with each entry in the daemon's `AccountCfg` serde shape: // lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) {
# name / token_file / state_dir / optional homeserver. HIVE_MATRIX_ACCOUNTS = builtins.toJSON (
// lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) ( lib.mapAttrsToList (
let name: a:
accts = config.hyperhive.matrixAccounts;
names = builtins.attrNames accts;
# Primary: the explicit option, or the sole account's name when
# only one is declared. The assertions below guarantee this
# resolves to a real key.
primary =
if config.hyperhive.matrixPrimaryAccount != null then
config.hyperhive.matrixPrimaryAccount
else
builtins.head names;
# Primary first, then the remaining names (attrNames is sorted).
ordered = [ primary ] ++ builtins.filter (n: n != primary) names;
toEntry =
name:
let
a = accts.${name};
in
{ {
inherit name; inherit name;
token_file = a.tokenFile; token_file = a.tokenFile;
state_dir = a.sessionDir; state_dir = a.sessionDir;
} }
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }; // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }
in ) config.hyperhive.matrixAccounts
{ );
HIVE_MATRIX_ACCOUNTS = builtins.toJSON (map toEntry ordered); };
}
);
serviceConfig = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
Restart = "on-failure"; Restart = "on-failure";