From 14c7b0d406b7f9dd38088d41273a8eb146fd6acb Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 5 Jun 2026 22:48:10 +0200 Subject: [PATCH] feat: group host-side /var/lib/hyperhive state into db/ forge/ matrix/ run/ subdirs with startup migration --- docs/gateway.md | 2 +- docs/matrix.md | 2 +- docs/persistence.md | 4 +- docs/tools/hivectl-cli.md | 8 +- hive-c0re/src/agent_ports.rs | 10 +- hive-c0re/src/agent_sockets.rs | 10 +- hive-c0re/src/bin/hivectl.rs | 6 +- hive-c0re/src/build_logs.rs | 2 +- hive-c0re/src/forge.rs | 30 ++-- hive-c0re/src/lib.rs | 1 + hive-c0re/src/main.rs | 7 +- hive-c0re/src/matrix.rs | 10 +- hive-c0re/src/meta.rs | 4 +- hive-c0re/src/paths.rs | 214 +++++++++++++++++++++++++++++ hive-c0re/src/scheduled_prompts.rs | 2 +- 15 files changed, 261 insertions(+), 51 deletions(-) create mode 100644 hive-c0re/src/paths.rs diff --git a/docs/gateway.md b/docs/gateway.md index d9c4d1ba..9007d14e 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -117,7 +117,7 @@ are retried automatically on subsequent poll ticks via ## Agent port map (`agent-ports.json`) -`/var/lib/hyperhive/agent-ports.json` is a flat JSON object keyed by +`/var/lib/hyperhive/run/agent-ports.json` is a flat JSON object keyed by logical agent name → TCP web port: ```json diff --git a/docs/matrix.md b/docs/matrix.md index 023862ea..f4e8420b 100644 --- a/docs/matrix.md +++ b/docs/matrix.md @@ -132,7 +132,7 @@ Matrix client that groups all agent-to-agent + operator rooms in one place. **State**: the room ID is persisted to -`/var/lib/hyperhive/matrix-space-room-id` (mode `0600`, root-owned). +`/var/lib/hyperhive/matrix/space-room-id` (mode `0600`, root-owned). This path is **outside** every agent state dir and is NOT deleted by `nixos-container destroy --purge` — the Space survives full agent purges and is reused on re-provision. diff --git a/docs/persistence.md b/docs/persistence.md index 19c5568d..ded69289 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -4,7 +4,7 @@ Where state lives, what survives what, and how it's bounded. ## Three sqlite databases -### `/var/lib/hyperhive/broker.sqlite` (host) +### `/var/lib/hyperhive/db/broker.sqlite` (host) Six tables, all in one file — four queues plus the schedule header/targets split: @@ -127,7 +127,7 @@ sentinel files (`hyperhive-rate-limited`, `hyperhive-needs-login`) if the JSON is absent, so existing containers keep working through the transition window before their next rebuild. -### `/var/lib/hyperhive/build_logs.sqlite` (host) +### `/var/lib/hyperhive/db/build_logs.sqlite` (host) Full stdout + stderr capture for every `nixos-container` / `nix build` invocation the lifecycle layer fires. One row per invocation; diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 65249f1a..62e230f4 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -83,8 +83,8 @@ matrix-tuwunel user provisioning. Manual entry point to the same idempotent flow * `create-user` — Create or refresh the matrix account + access token for `` * `sync-admin` — Provision (or re-provision) the hive system admin matrix account (`@hive:`). hive-c0re runs this automatically on startup before the agent sweep so the account is the first registered user — Conduit/tuwunel grants admin rights to the first user. Run manually to recover a missing admin token file -* `promote-user` — Promote a matrix user to homeserver admin via the admin API. Uses the hive system admin token at `/var/lib/hyperhive/matrix-admin-token`. The `server_name` is discovered automatically from the running homeserver -* `reset-password` — Reset a matrix user's password via the admin API and persist the new password to `/var/lib/hyperhive/matrix-creds/-password` so the next `ensure_user_for` (or `create-user`) can re-login +* `promote-user` — Promote a matrix user to homeserver admin via the admin API. Uses the hive system admin token at `/var/lib/hyperhive/matrix/admin-token`. The `server_name` is discovered automatically from the running homeserver +* `reset-password` — Reset a matrix user's password via the admin API and persist the new password to `/var/lib/hyperhive/matrix/creds/-password` so the next `ensure_user_for` (or `create-user`) can re-login * `invite` — Invite a matrix user to the hive Space (default) or a specific room. Uses the hive admin token; the admin account must be a member of the target room with invite power (it owns the hive Space). Idempotent — already-member / already-invited is a no-op @@ -122,7 +122,7 @@ Provision (or re-provision) the hive system admin matrix account (`@hive:` @@ -134,7 +134,7 @@ Promote a matrix user to homeserver admin via the admin API. Uses the hive syste ## `hivectl matrix reset-password` -Reset a matrix user's password via the admin API and persist the new password to `/var/lib/hyperhive/matrix-creds/-password` so the next `ensure_user_for` (or `create-user`) can re-login. +Reset a matrix user's password via the admin API and persist the new password to `/var/lib/hyperhive/matrix/creds/-password` so the next `ensure_user_for` (or `create-user`) can re-login. After this command succeeds, run `hivectl matrix create-user ` to mint a fresh access token for the agent. diff --git a/hive-c0re/src/agent_ports.rs b/hive-c0re/src/agent_ports.rs index de1032c4..9b879c38 100644 --- a/hive-c0re/src/agent_ports.rs +++ b/hive-c0re/src/agent_ports.rs @@ -1,4 +1,4 @@ -//! `/var/lib/hyperhive/agent-ports.json` writer — flat map of +//! `/var/lib/hyperhive/run/agent-ports.json` writer — flat map of //! agent name → TCP web port. Written alongside `agents.conf` on //! every topology change. JSON shape, port derivation (FNV-1a hash), //! atomicity, and manager exclusion: `docs/gateway.md::Agent port map`. @@ -10,11 +10,9 @@ use anyhow::{Context, Result}; use crate::lifecycle::{self, MANAGER_NAME}; -const HOST_PORTS_PATH: &str = "/var/lib/hyperhive/agent-ports.json"; - #[must_use] pub fn host_ports_path() -> PathBuf { - PathBuf::from(HOST_PORTS_PATH) + crate::paths::agent_ports_file() } /// Compute the agent-port map for the given logical agent names. @@ -34,7 +32,7 @@ pub fn build_map(names: &[String]) -> BTreeMap { } /// Render the map as pretty-printed JSON. Pretty so a human peek at -/// `cat /var/lib/hyperhive/agent-ports.json` shows one row per agent +/// `cat /var/lib/hyperhive/run/agent-ports.json` shows one row per agent /// — keeps the file readable without a separate jq step. fn render(map: &BTreeMap) -> String { // BTreeMap → serde_json::to_string_pretty preserves key order, @@ -44,7 +42,7 @@ fn render(map: &BTreeMap) -> String { } /// Atomically write the JSON for `names` to -/// `/var/lib/hyperhive/agent-ports.json`. Writes via a sibling +/// `/var/lib/hyperhive/run/agent-ports.json`. Writes via a sibling /// `.tmp` + rename so a crashing process never leaves a /// partial file behind that the gateway worker would fail to parse. /// diff --git a/hive-c0re/src/agent_sockets.rs b/hive-c0re/src/agent_sockets.rs index 19b7644e..df4192fb 100644 --- a/hive-c0re/src/agent_sockets.rs +++ b/hive-c0re/src/agent_sockets.rs @@ -1,4 +1,4 @@ -//! `/var/lib/hyperhive/agent-sockets.json` writer. Sibling to +//! `/var/lib/hyperhive/run/agent-sockets.json` writer. Sibling to //! `agent_ports.rs`; same atomic `.tmp` + `rename()` shape so //! the gateway's nginx worker never reads a partial file. Includes //! manager and sub-agents so the gateway can route @@ -13,8 +13,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -const HOST_SOCKETS_PATH: &str = "/var/lib/hyperhive/agent-sockets.json"; - /// Host-side parent directory holding per-agent socket subdirs. The /// gateway container bind-mounts this whole tree (read-only) so it /// can `proxy_pass` to any agent. Each agent's container bind-mounts @@ -43,7 +41,7 @@ const READY_MARKER_LEGACY: &str = ".bound"; #[must_use] pub fn host_sockets_path() -> PathBuf { - PathBuf::from(HOST_SOCKETS_PATH) + crate::paths::agent_sockets_file() } /// Per-agent socket subdir on the host. Lifecycle pre-creates this @@ -111,7 +109,7 @@ pub fn ready_marker_for(name: &str) -> PathBuf { } /// Render the map as pretty-printed JSON. Pretty so a human peek at -/// `cat /var/lib/hyperhive/agent-sockets.json` shows one row per agent +/// `cat /var/lib/hyperhive/run/agent-sockets.json` shows one row per agent /// — keeps the file readable without a separate jq step (mirrors /// `agent_ports::render`). fn render(map: &BTreeMap) -> String { @@ -128,7 +126,7 @@ fn render(map: &BTreeMap) -> String { } /// Atomically write the JSON for `names` to -/// `/var/lib/hyperhive/agent-sockets.json`. Writes via a sibling +/// `/var/lib/hyperhive/run/agent-sockets.json`. Writes via a sibling /// `.tmp` + rename so a crashing process never leaves a /// partial file behind that the gateway worker would fail to parse. /// diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 36c56ad7..9374c004 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -198,14 +198,14 @@ enum MatrixCmd { SyncAdmin, /// Promote a matrix user to homeserver admin via the admin API. /// Uses the hive system admin token at - /// `/var/lib/hyperhive/matrix-admin-token`. The `server_name` is + /// `/var/lib/hyperhive/matrix/admin-token`. The `server_name` is /// discovered automatically from the running homeserver. PromoteUser { /// Matrix localpart of the user to promote (e.g. `argus`). name: String, }, /// Reset a matrix user's password via the admin API and persist the - /// new password to `/var/lib/hyperhive/matrix-creds/-password` + /// new password to `/var/lib/hyperhive/matrix/creds/-password` /// so the next `ensure_user_for` (or `create-user`) can re-login. /// /// After this command succeeds, run `hivectl matrix create-user @@ -655,7 +655,7 @@ async fn matrix_reset_password(name: &str) -> Result<()> { .await .with_context(|| format!("matrix reset-password {name}"))?; // Password is persisted by reset_user_password. - let pw_path = PathBuf::from("/var/lib/hyperhive/matrix-creds").join(format!("{name}-password")); + let pw_path = hive_c0re::paths::matrix_creds_dir().join(format!("{name}-password")); println!("matrix: password for @{name}:{server_name} reset"); println!("password persisted at: {}", pw_path.display()); println!("next: hivectl matrix create-user {name} # mints a fresh access token"); diff --git a/hive-c0re/src/build_logs.rs b/hive-c0re/src/build_logs.rs index 5b3cdf31..ba095898 100644 --- a/hive-c0re/src/build_logs.rs +++ b/hive-c0re/src/build_logs.rs @@ -1,7 +1,7 @@ //! Sqlite-backed full build-log capture — stdout + stderr per //! `nixos-container` / `nix build` invocation, accumulated live. //! Schema, indices, retention, and the rationale for replacing -//! the old ring buffer: `docs/persistence.md::/var/lib/hyperhive/build_logs.sqlite`. +//! the old ring buffer: `docs/persistence.md::/var/lib/hyperhive/db/build_logs.sqlite`. use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 83ae2f0c..660b3a76 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -20,20 +20,11 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive"; /// itself to push the meta repo + drive admin API calls (org /// creation, future webhook setup, etc.). Root-only. const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; -/// Marker that records whether `ensure_core_avatar` has successfully -/// uploaded the hyperhive logo as `core`'s avatar. One-shot: the -/// upload runs once, the marker is written, subsequent startups skip -/// the call. Delete to force re-upload. -const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set"; -/// Sibling marker for the `agent-configs` org avatar. Same one-shot -/// semantics — delete to force the upload to re-run. -const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set"; -/// Per-agent marker written once the account email has been aligned to -/// `{name}@hyperhive`. Skips the `PATCH /api/v1/admin/users/{name}` -/// call on every subsequent `sync_agent` tick — that PATCH was resetting -/// Forgejo's `use_custom_avatar` flag and clobbering the avatar uploaded -/// by `forge-avatar-sync`. Delete to force re-alignment. -const EMAIL_ALIGNED_MARKER_PREFIX: &str = "/var/lib/hyperhive/forge-email-aligned-"; +// Forge provisioning markers (`forge/core-avatar-set`, +// `forge/agent-configs-avatar-set`, `forge/email-aligned-`) live +// in `crate::paths` — one-shot guards: the upload/align runs once, the +// marker is written, subsequent startups skip. Delete one to force its +// step to re-run. // Avatar PNGs are loaded at runtime from // `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the // helpers in `hive_sh4re::assets`. The `agent-configs.png` is @@ -221,8 +212,8 @@ async fn change_user_password(name: &str, password: &str) -> Result<()> { /// `login_name` (required by Forgejo's `EditUserOption` validator) and /// `source_id = 0` (local auth, the default for users hive-c0re creates). async fn ensure_user_email(name: &str) { - let marker = format!("{EMAIL_ALIGNED_MARKER_PREFIX}{name}"); - if std::path::Path::new(&marker).exists() { + let marker = crate::paths::forge_email_aligned_marker(name); + if marker.exists() { return; } let Some(token) = core_token() else { @@ -236,6 +227,9 @@ async fn ensure_user_email(name: &str) { let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}"); match forge_http(reqwest::Method::PATCH, &url, &token, &body).await { Ok(status) if status.is_success() => { + if let Some(parent) = marker.parent() { + std::fs::create_dir_all(parent).ok(); + } std::fs::write(&marker, "").ok(); tracing::info!(%name, %email, "forge: user email aligned"); } @@ -361,7 +355,7 @@ pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result< /// — any non-2xx is logged at the caller; the project runs fine /// with the default hash identicon. async fn ensure_core_avatar(token: &str) -> Result<()> { - let marker = std::path::Path::new(CORE_AVATAR_MARKER); + let marker = crate::paths::forge_core_avatar_marker(); if marker.exists() { return Ok(()); } @@ -392,7 +386,7 @@ async fn ensure_core_avatar(token: &str) -> Result<()> { /// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG /// JSON body — same shape as the admin user endpoint above. async fn ensure_config_org_avatar(token: &str) -> Result<()> { - let marker = std::path::Path::new(CONFIG_ORG_AVATAR_MARKER); + let marker = crate::paths::forge_config_org_avatar_marker(); if marker.exists() { return Ok(()); } diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index 74cd9ee2..dce299a7 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -42,6 +42,7 @@ pub mod matrix; pub mod meta; pub mod migrate; pub mod operator_questions; +pub mod paths; pub mod priv_client; pub mod questions; pub mod rebuild_queue; diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 72f1520d..b7e63e5e 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -54,7 +54,7 @@ enum Cmd { #[arg(long, default_value = "")] nixpkgs_unstable_flake: String, /// Path to the sqlite message store. - #[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")] + #[arg(long, default_value = hive_c0re::paths::BROKER_DB)] db: PathBuf, /// Dashboard HTTP port. #[arg(long, default_value_t = 7000)] @@ -223,6 +223,11 @@ async fn cmd_serve( agent_memory_max: String, socket: &std::path::Path, ) -> Result<()> { + // Move any host-side state still at the legacy flat layout into its + // subdir (`db/`, `forge/`, `matrix/`, `run/`) BEFORE opening the + // broker db — the broker + build-logs dbs are among the relocated + // files. Idempotent; a no-op once migrated. + hive_c0re::paths::relocate_legacy_state(); let cwt: std::collections::HashMap = serde_json::from_str(&context_window_tokens) .context("--context-window-tokens: invalid JSON")?; let coord = Arc::new(Coordinator::open( diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index e0bf1ebf..193b9d47 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -55,7 +55,7 @@ pub const HIVE_SPACE_NAME: &str = "hive"; /// purgeable path — not deleted by `destroy --purge` on any agent. #[must_use] pub fn admin_token_path() -> PathBuf { - PathBuf::from("/var/lib/hyperhive/matrix-admin-token") + crate::paths::matrix_admin_token() } /// Token file inside the agent's bind-mounted state dir (visible as @@ -68,7 +68,7 @@ fn token_path(name: &str) -> PathBuf { /// purgeable `agent_state_root` tree so it survives `destroy --purge` /// and allows re-login recovery when the same agent name is re-spawned. /// -/// Path: `/var/lib/hyperhive/matrix-creds/-password` +/// Path: `/var/lib/hyperhive/matrix/creds/-password` /// /// The token file lives inside the agent's bind-mounted state dir (under /// `agent_notes_dir`) so the agent container can read it; the password @@ -76,7 +76,7 @@ fn token_path(name: &str) -> PathBuf { /// the access token exclusively) and belongs with other hive-c0re /// credential state, not inside the purgeable per-agent tree. fn password_path(name: &str) -> PathBuf { - PathBuf::from("/var/lib/hyperhive/matrix-creds").join(format!("{name}-password")) + crate::paths::matrix_creds_dir().join(format!("{name}-password")) } /// Legacy password path (inside the old purgeable `agent_notes_dir`). @@ -92,7 +92,7 @@ fn legacy_password_path(name: &str) -> PathBuf { /// Outside every purgeable path — not deleted by `destroy --purge`. #[must_use] pub fn hive_space_room_id_path() -> PathBuf { - PathBuf::from("/var/lib/hyperhive/matrix-space-room-id") + crate::paths::matrix_space_room_id() } /// Probe whether `hive-matrix` exists as a nixos-container. Cheap — @@ -661,7 +661,7 @@ pub async fn ensure_user_for( // Password file missing — attempt auto-recovery via admin API. // This covers the case where agent state dirs were wiped but the // homeserver still has the accounts. Requires the hive admin - // token at /var/lib/hyperhive/matrix-admin-token. + // token at /var/lib/hyperhive/matrix/admin-token. tracing::info!( %name, "matrix: stored password missing, attempting admin-API auto-recovery" diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 8027b6d8..379723e2 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -101,7 +101,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { crate::topology::reconcile(&agent_names) .with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?; - // Refresh /var/lib/hyperhive/agent-ports.json so the hive-gateway + // Refresh /var/lib/hyperhive/run/agent-ports.json so the hive-gateway // nginx sees the new agent set. The file is the single source of // truth for which agents the gateway proxies to, since the // gateway container lives in system config and can't be rebuilt @@ -115,7 +115,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { tracing::warn!(error = ?e, "agent_ports::write failed (non-fatal)"); } - // Refresh /var/lib/hyperhive/agent-sockets.json — sibling to the + // Refresh /var/lib/hyperhive/run/agent-sockets.json — sibling to the // ports map, drives the gateway's unix-socket upstreams once // agents opt in to `HIVE_WEB_SOCKET`. Coexists with the TCP-port // map during the transition: the gateway picks the socket diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs new file mode 100644 index 00000000..7cc0a2ed --- /dev/null +++ b/hive-c0re/src/paths.rs @@ -0,0 +1,214 @@ +//! Central host-side state paths under `/var/lib/hyperhive`. +//! +//! Historically these were flat string literals scattered across many +//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-ports.json`, +//! …) directly under the state root. This module groups the **strictly +//! host-side** ones (read/written by hive-c0re alone, no nix-module or +//! container coupling) into subdirs: `db/`, `forge/`, `matrix/`, `run/`. +//! +//! Nix-coupled paths (`forge-core-token`, `matrix-register-token`, +//! `gateway/`, `meta/`, `agents/`) are intentionally **not** moved here +//! — they cross into nix modules / bind mounts and are tracked +//! separately so the Rust path and the nix default can move in lockstep. +//! +//! [`relocate_legacy_state`] moves any file still at the old flat +//! location into its new subdir on startup, before the broker db is +//! opened. + +use std::path::{Path, PathBuf}; + +/// Root of all hive-c0re persistent state. +pub const STATE_ROOT: &str = "/var/lib/hyperhive"; + +/// Default broker db path (`db/broker.sqlite`). Exposed as a `&str` for +/// the `--broker-db` clap `default_value`; `build_logs.sqlite` is placed +/// alongside it (the build-logs store keys off the broker db's parent). +pub const BROKER_DB: &str = "/var/lib/hyperhive/db/broker.sqlite"; + +#[must_use] +pub fn state_root() -> PathBuf { + PathBuf::from(STATE_ROOT) +} + +/// `db/` — sqlite databases (broker, build logs). +#[must_use] +pub fn db_dir() -> PathBuf { + state_root().join("db") +} + +/// `forge/` — hive-c0re's own forge provisioning markers. +#[must_use] +pub fn forge_dir() -> PathBuf { + state_root().join("forge") +} + +/// `forge/core-avatar-set` — marker: core account avatar uploaded. +#[must_use] +pub fn forge_core_avatar_marker() -> PathBuf { + forge_dir().join("core-avatar-set") +} + +/// `forge/agent-configs-avatar-set` — marker: agent-configs org avatar set. +#[must_use] +pub fn forge_config_org_avatar_marker() -> PathBuf { + forge_dir().join("agent-configs-avatar-set") +} + +/// `forge/email-aligned-` — marker: ``'s forge email aligned. +#[must_use] +pub fn forge_email_aligned_marker(name: &str) -> PathBuf { + forge_dir().join(format!("email-aligned-{name}")) +} + +/// `matrix/` — host-side matrix provisioning state (admin token, hive +/// Space room id, per-agent password creds). The shared registration +/// token is bind-mounted into the tuwunel container via nix and stays +/// at its own path (tracked separately). +#[must_use] +pub fn matrix_dir() -> PathBuf { + state_root().join("matrix") +} + +/// `matrix/admin-token` — hive system admin access token. +#[must_use] +pub fn matrix_admin_token() -> PathBuf { + matrix_dir().join("admin-token") +} + +/// `matrix/space-room-id` — persisted hive Space room id. +#[must_use] +pub fn matrix_space_room_id() -> PathBuf { + matrix_dir().join("space-room-id") +} + +/// `matrix/creds/` — per-agent throwaway matrix passwords (survive +/// `destroy --purge`; agents auth by token, this is recovery only). +#[must_use] +pub fn matrix_creds_dir() -> PathBuf { + matrix_dir().join("creds") +} + +/// `run/` — runtime maps hive-c0re regenerates on every meta sync. +#[must_use] +pub fn run_dir() -> PathBuf { + state_root().join("run") +} + +/// `run/agent-ports.json` — name→port map the gateway routing reads. +#[must_use] +pub fn agent_ports_file() -> PathBuf { + run_dir().join("agent-ports.json") +} + +/// `run/agent-sockets.json` — name→socket-path map for UDS upstreams. +#[must_use] +pub fn agent_sockets_file() -> PathBuf { + run_dir().join("agent-sockets.json") +} + +/// Move any host-side state file still at its legacy flat location +/// (directly under [`STATE_ROOT`]) into its new subdir. Idempotent and +/// rename-based: a move only happens when the old path exists and the +/// new one doesn't, so re-runs are no-ops. +/// +/// Must run **before** the broker db is opened (the broker + build-logs +/// dbs are relocated here). Safe because those dbs use rollback-journal +/// mode (no `-wal`/`-shm` sidecars after a clean shutdown), and a rename +/// within the same filesystem is atomic. +pub fn relocate_legacy_state() { + let root = state_root(); + let moves: [(&str, PathBuf); 8] = [ + ("broker.sqlite", db_dir().join("broker.sqlite")), + ("build_logs.sqlite", db_dir().join("build_logs.sqlite")), + ("forge-core-avatar-set", forge_core_avatar_marker()), + ( + "forge-agent-configs-avatar-set", + forge_config_org_avatar_marker(), + ), + ("matrix-admin-token", matrix_admin_token()), + ("matrix-space-room-id", matrix_space_room_id()), + ("matrix-creds", matrix_creds_dir()), + ("agent-sockets.json", agent_sockets_file()), + ]; + for (old_rel, new) in &moves { + move_if_legacy(&root.join(old_rel), new); + } + // agent-ports.json handled here too (kept out of the array only to + // keep the fixed-size literal tidy). + move_if_legacy(&root.join("agent-ports.json"), &agent_ports_file()); + // `forge-email-aligned-` markers: glob the flat root. + if let Ok(rd) = std::fs::read_dir(&root) { + for ent in rd.flatten() { + if let Some(name) = ent + .file_name() + .to_str() + .and_then(|s| s.strip_prefix("forge-email-aligned-")) + { + move_if_legacy(&ent.path(), &forge_email_aligned_marker(name)); + } + } + } +} + +/// Rename `old` → `new` when `old` exists and `new` doesn't, creating +/// `new`'s parent dir first. Logs on success / failure; never panics +/// (a failed relocate must not take the daemon down — worst case the +/// owning module recreates fresh state at the new path). +fn move_if_legacy(old: &Path, new: &Path) { + if !old.exists() || new.exists() { + return; + } + if let Some(parent) = new.parent() { + let _ = std::fs::create_dir_all(parent); + } + match std::fs::rename(old, new) { + Ok(()) => tracing::info!( + from = %old.display(), + to = %new.display(), + "relocate: moved legacy state into subdir" + ), + Err(e) => tracing::warn!( + from = %old.display(), + to = %new.display(), + error = ?e, + "relocate: rename failed; owning module will recreate at the new path" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn move_if_legacy_moves_then_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("flat-file"); + let new = tmp.path().join("sub/dir/new-file"); + fs::write(&old, b"payload").unwrap(); + + move_if_legacy(&old, &new); + assert!(!old.exists(), "old should be gone after move"); + assert_eq!(fs::read(&new).unwrap(), b"payload"); + + // Re-run with old absent → no-op, new untouched. + move_if_legacy(&old, &new); + assert_eq!(fs::read(&new).unwrap(), b"payload"); + } + + #[test] + fn move_if_legacy_skips_when_new_exists() { + let tmp = tempfile::tempdir().unwrap(); + let old = tmp.path().join("flat"); + let new = tmp.path().join("sub/new"); + fs::write(&old, b"OLD").unwrap(); + fs::create_dir_all(new.parent().unwrap()).unwrap(); + fs::write(&new, b"NEW").unwrap(); + + // New already present → must NOT overwrite, old left in place. + move_if_legacy(&old, &new); + assert_eq!(fs::read(&new).unwrap(), b"NEW"); + assert!(old.exists(), "old left untouched when new exists"); + } +} diff --git a/hive-c0re/src/scheduled_prompts.rs b/hive-c0re/src/scheduled_prompts.rs index d4d83571..fb66fe80 100644 --- a/hive-c0re/src/scheduled_prompts.rs +++ b/hive-c0re/src/scheduled_prompts.rs @@ -4,7 +4,7 @@ //! `interval_seconds` and re-arm `next_fire_at` on delivery; //! one-shots are reaped. //! -//! Schema + retention: `docs/persistence.md::/var/lib/hyperhive/broker.sqlite` +//! Schema + retention: `docs/persistence.md::/var/lib/hyperhive/db/broker.sqlite` //! (the `scheduled_prompts` / `scheduled_prompt_targets` table bullets). //! Submit paths (operator-direct vs `ApprovalKind::SchedulePrompt`, //! plus why even agent-self schedules go through approval):