fix: anchor hive Space to canonical #hive alias for dedup

ensure_hive_space relied solely on the persisted room-id file. If that file
is ever lost (a full /var/lib/hyperhive wipe), the next sweep blind-creates a
new m.space — the homeserver keeps the old one, so duplicate hive spaces
accumulate (observed: multiple 'hive'/'pr1ma' rooms on the live instance).

Anchor the Space to a stable canonical alias #hive:<server>:
- fast path (room-id file present): reuse it and heal the alias mapping so
  it keeps pointing at the canonical room
- no file: resolve #hive:<server> and adopt the existing room if present,
  re-persisting the file — recovers the space after a wipe instead of
  duplicating it
- only create (with the alias) when neither yields a room

server_name is now discovered before ensure_hive_space in ensure_all and
threaded through (the alias needs it). Existing deployments heal the alias
onto their current space on the next sweep; no new room is created when the
file is present.
This commit is contained in:
atlas 2026-06-05 09:50:44 +02:00 committed by mara
commit 134a40a5e2

View file

@ -45,6 +45,12 @@ const PASSWORD_BYTES: usize = 32;
/// first registered user automatically. Not an agent; has no state dir. /// first registered user automatically. Not an agent; has no state dir.
pub const HIVE_ADMIN_LOCALPART: &str = "hive"; pub const HIVE_ADMIN_LOCALPART: &str = "hive";
/// Canonical room-alias localpart for the hive Space (`#hive:<server>`).
/// The Space is anchored to this alias at creation so it stays discoverable
/// even if the persisted room-id file is lost (a full state wipe), preventing
/// duplicate spaces from being created on the next sweep.
pub const HIVE_SPACE_ALIAS_LOCALPART: &str = "hive";
/// Host path for the hive admin matrix access token. Outside every /// Host path for the hive admin matrix access token. Outside every
/// purgeable path — not deleted by `destroy --purge` on any agent. /// purgeable path — not deleted by `destroy --purge` on any agent.
#[must_use] #[must_use]
@ -945,33 +951,108 @@ pub fn read_admin_token() -> Result<String> {
}) })
} }
/// Create the hive Matrix Space room using the admin account and persist /// Persist the hive Space room id to [`hive_space_room_id_path()`] (0600).
/// its room ID to [`hive_space_room_id_path()`]. Idempotent — returns fn persist_space_room_id(room_id: &str) -> Result<()> {
/// the stored room ID immediately if the file already exists. use std::os::unix::fs::PermissionsExt;
let path = hive_space_room_id_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, format!("{room_id}\n"))
.with_context(|| format!("matrix: write space room_id to {}", path.display()))?;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
Ok(())
}
/// Resolve a room alias (`#name:server`) to its room id via the directory
/// API. Returns `None` on any non-success (e.g. 404 = alias unknown).
async fn resolve_room_alias(
client: &reqwest::Client,
admin_token: &str,
server_name: &str,
) -> Option<String> {
let encoded = format!("%23{HIVE_SPACE_ALIAS_LOCALPART}%3A{server_name}");
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}");
let resp = client.get(&url).bearer_auth(admin_token).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let json = resp.json::<serde_json::Value>().await.ok()?;
json["room_id"].as_str().map(ToOwned::to_owned)
}
/// Best-effort: point the canonical `#hive:server` alias at `room_id`.
/// Idempotent — a 409 (alias already maps here) is treated as success; any
/// other failure is logged at debug and ignored (the room-id file remains
/// the primary anchor).
async fn ensure_room_alias(
client: &reqwest::Client,
admin_token: &str,
server_name: &str,
room_id: &str,
) {
let encoded = format!("%23{HIVE_SPACE_ALIAS_LOCALPART}%3A{server_name}");
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}");
let resp = client
.put(&url)
.bearer_auth(admin_token)
.json(&serde_json::json!({ "room_id": room_id }))
.send()
.await;
match resp {
Ok(r) if r.status().is_success() || r.status() == StatusCode::CONFLICT => {}
Ok(r) => tracing::debug!(status = %r.status(), %room_id, "matrix: set hive space alias non-fatal"),
Err(e) => tracing::debug!(error = ?e, "matrix: set hive space alias failed (non-fatal)"),
}
}
/// Create (or recover) the hive Matrix Space and persist its room ID to
/// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by
/// `@hive`, anchored to the canonical alias `#hive:<server>`.
/// ///
/// The Space is a private `m.space` room owned by `@hive`. All agents /// Dedup strategy (single canonical space):
/// are invited after their accounts are provisioned in [`ensure_all`]. /// 1. If the room-id file exists, reuse it and heal the alias mapping
/// best-effort (so the alias keeps pointing at the canonical room).
/// 2. Otherwise, resolve `#hive:<server>` — if it exists, adopt it and
/// re-persist the file. This recovers the existing space after a state
/// wipe instead of creating a duplicate.
/// 3. Only if neither yields a room do we `createRoom` (with the alias).
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the Matrix homeserver is unreachable, the /// Returns an error if the homeserver is unreachable, `createRoom` fails,
/// `createRoom` call fails, or the room-ID file cannot be written. /// or the room-ID file cannot be written.
pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> { pub async fn ensure_hive_space(
use std::os::unix::fs::PermissionsExt; client: &reqwest::Client,
let path = hive_space_room_id_path(); admin_token: &str,
if let Ok(existing) = std::fs::read_to_string(&path) { server_name: &str,
) -> Result<String> {
// 1. Stored room id wins. Heal the alias so it stays discoverable.
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
let trimmed = existing.trim().to_owned(); let trimmed = existing.trim().to_owned();
if !trimmed.is_empty() { if !trimmed.is_empty() {
ensure_room_alias(client, admin_token, server_name, &trimmed).await;
tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned"); tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned");
return Ok(trimmed); return Ok(trimmed);
} }
} }
// 2. No stored id — recover the existing space via its canonical alias
// before creating a new one (prevents duplicate spaces after a wipe).
if let Some(room_id) = resolve_room_alias(client, admin_token, server_name).await {
persist_space_room_id(&room_id)?;
tracing::info!(%room_id, "matrix: recovered hive space via #hive alias");
return Ok(room_id);
}
// 3. Create the space, anchored to the canonical alias.
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom");
let body = serde_json::json!({ let body = serde_json::json!({
"name": "hive", "name": "hive",
"creation_content": { "type": "m.space" }, "creation_content": { "type": "m.space" },
"preset": "private_chat", "preset": "private_chat",
"visibility": "private", "visibility": "private",
"room_alias_name": HIVE_SPACE_ALIAS_LOCALPART,
}); });
let resp = client let resp = client
.post(&url) .post(&url)
@ -992,12 +1073,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R
.as_str() .as_str()
.with_context(|| format!("matrix: createRoom missing room_id: {json}"))? .with_context(|| format!("matrix: createRoom missing room_id: {json}"))?
.to_owned(); .to_owned();
if let Some(parent) = path.parent() { persist_space_room_id(&room_id)?;
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, format!("{room_id}\n"))
.with_context(|| format!("matrix: write space room_id to {}", path.display()))?;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
tracing::info!(%room_id, "matrix: created hive space"); tracing::info!(%room_id, "matrix: created hive space");
Ok(room_id) Ok(room_id)
} }
@ -1100,17 +1176,18 @@ pub async fn ensure_all() {
return; return;
} }
}; };
let room_id = match ensure_hive_space(&client, &admin_token).await { // server_name first — both the space alias and the agent invites need it.
Ok(id) => id,
Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
return;
}
};
let server_name = match discover_server_name(&client).await { let server_name = match discover_server_name(&client).await {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
tracing::warn!(error = ?e, "matrix: discover_server_name failed; skipping space invites"); tracing::warn!(error = ?e, "matrix: discover_server_name failed; skipping space provisioning");
return;
}
};
let room_id = match ensure_hive_space(&client, &admin_token, &server_name).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
return; return;
} }
}; };