From 134a40a5e2214845f960579173246e5747fdae81 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 5 Jun 2026 09:50:44 +0200 Subject: [PATCH] fix: anchor hive Space to canonical #hive alias for dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:: - 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: 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. --- hive-c0re/src/matrix.rs | 127 ++++++++++++++++++++++++++++++++-------- 1 file changed, 102 insertions(+), 25 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 40e42138..78739b6a 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -45,6 +45,12 @@ const PASSWORD_BYTES: usize = 32; /// first registered user automatically. Not an agent; has no state dir. pub const HIVE_ADMIN_LOCALPART: &str = "hive"; +/// Canonical room-alias localpart for the hive Space (`#hive:`). +/// 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 /// purgeable path — not deleted by `destroy --purge` on any agent. #[must_use] @@ -945,33 +951,108 @@ pub fn read_admin_token() -> Result { }) } -/// Create the hive Matrix Space room using the admin account and persist -/// its room ID to [`hive_space_room_id_path()`]. Idempotent — returns -/// the stored room ID immediately if the file already exists. +/// Persist the hive Space room id to [`hive_space_room_id_path()`] (0600). +fn persist_space_room_id(room_id: &str) -> Result<()> { + 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 { + 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::().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:`. /// -/// The Space is a private `m.space` room owned by `@hive`. All agents -/// are invited after their accounts are provisioned in [`ensure_all`]. +/// Dedup strategy (single canonical space): +/// 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:` — 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 /// -/// Returns an error if the Matrix homeserver is unreachable, the -/// `createRoom` call fails, or the room-ID file cannot be written. -pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result { - use std::os::unix::fs::PermissionsExt; - let path = hive_space_room_id_path(); - if let Ok(existing) = std::fs::read_to_string(&path) { +/// Returns an error if the homeserver is unreachable, `createRoom` fails, +/// or the room-ID file cannot be written. +pub async fn ensure_hive_space( + client: &reqwest::Client, + admin_token: &str, + server_name: &str, +) -> Result { + // 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(); if !trimmed.is_empty() { + ensure_room_alias(client, admin_token, server_name, &trimmed).await; tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned"); 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 body = serde_json::json!({ "name": "hive", "creation_content": { "type": "m.space" }, "preset": "private_chat", "visibility": "private", + "room_alias_name": HIVE_SPACE_ALIAS_LOCALPART, }); let resp = client .post(&url) @@ -992,12 +1073,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R .as_str() .with_context(|| format!("matrix: createRoom missing room_id: {json}"))? .to_owned(); - 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)); + persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: created hive space"); Ok(room_id) } @@ -1100,17 +1176,18 @@ pub async fn ensure_all() { return; } }; - let room_id = match ensure_hive_space(&client, &admin_token).await { - Ok(id) => id, - Err(e) => { - tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); - return; - } - }; + // server_name first — both the space alias and the agent invites need it. let server_name = match discover_server_name(&client).await { Ok(s) => s, 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; } };