//! Hive-wide knowledge repository management. //! //! `internal/knowledge` on the forge is cloned to //! [`LOCAL_DIR`] and bind-mounted read-only into every agent container //! at `/knowledge`. Agents read documents from it directly and //! contribute by forking the repo and opening PRs — they never write //! to the bind-mounted path inside the container. //! //! hive-c0re maintains the local clone. It learns that the repository //! moved from the **swarm controller**, which owns the one Forgejo //! webhook and addresses an event to each hive over the queue; a //! periodic pull in `main.rs` provides a fallback cadence. //! //! A hive used to register that webhook itself, pointing at its own //! `/webhook/knowledge`. A webhook has exactly one target URL, so with //! more than one hive that was last-writer-wins rather than idempotent — //! every hive but the most recent silently stopped receiving deliveries. //! [`remove_webhook`] is the migration off it. use anyhow::{Context, Result}; use crate::coordinator::Coordinator; use crate::forge::{core_auth_header, forge_git_url}; pub const ORG: &str = "internal"; pub const REPO: &str = "knowledge"; /// Host-side path for the local clone. Also referenced from /// `lifecycle.rs` (bind-mount source) and the dashboard webhook handler. /// The literal lives in [`crate::paths`]; re-exported here under the name /// this module and its consumers have always used. pub use crate::paths::KNOWLEDGE_DIR as LOCAL_DIR; /// In-container mount point for the knowledge repo. Bind-mounted /// read-only from [`LOCAL_DIR`] into every agent container. pub const CONTAINER_MOUNT: &str = "/knowledge"; /// Default README pushed to a freshly created `internal/knowledge` repo. /// Short explanation + empty table-of-contents with an HTML comment instructing contributors /// to add entries when they create new files. const README_CONTENT: &str = "\ # knowledge Hive-wide reference documents: conventions, runbooks, and anything that \ every agent should know. ## How to contribute 1. Fork this repo into your own namespace on the forge. 2. Create a branch, add or update a document. 3. Open a pull request — the operator reviews and merges. 4. Every agent container updates automatically on merge. Do **not** push directly to `main` — agents have read-only access. ## Contents "; /// Clone `internal/knowledge` to [`LOCAL_DIR`] if it is not already a git /// repository. `core_token` authenticates the HTTPS clone so private repos /// work. Idempotent — skips if `LOCAL_DIR/.git` exists. /// /// When the upstream repo is empty (freshly created), seeds it with a /// README.md before returning so the local clone is always non-empty and /// agents see a useful starting document. /// /// Called once at hive-c0re startup after `forge::ensure_all`. pub async fn ensure_local_clone(core_token: &str) -> Result<()> { let git_dir = std::path::Path::new(LOCAL_DIR).join(".git"); if git_dir.exists() { tracing::debug!("knowledge: local clone already present at {LOCAL_DIR}"); return Ok(()); } std::fs::create_dir_all(LOCAL_DIR).context("create knowledge local dir")?; let url = forge_git_url(&format!("{ORG}/{REPO}")); let out = crate::lifecycle::git_command_authed(&core_auth_header(core_token)) .args(["clone", &url, LOCAL_DIR]) .output() .await .context("git clone knowledge repo")?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); anyhow::bail!("git clone {ORG}/{REPO} failed: {stderr}"); } tracing::info!("knowledge: cloned {ORG}/{REPO} to {LOCAL_DIR}"); // If the repo is brand new (no commits), seed it with a README. let head_out = tokio::process::Command::new("git") .args(["-C", LOCAL_DIR, "rev-parse", "HEAD"]) .output() .await .context("git rev-parse HEAD")?; if !head_out.status.success() { seed_readme(core_token).await?; } Ok(()) } /// Write the initial README.md, commit, and push to `internal/knowledge`. /// Called only when the upstream repo is empty. async fn seed_readme(core_token: &str) -> Result<()> { let readme = std::path::Path::new(LOCAL_DIR).join("README.md"); std::fs::write(&readme, README_CONTENT).context("write README.md")?; // Set a minimal git identity for the seed commit. for (k, v) in [("user.email", "core@hive"), ("user.name", "hive-c0re")] { let out = tokio::process::Command::new("git") .args(["-C", LOCAL_DIR, "config", k, v]) .output() .await .with_context(|| format!("git config {k}"))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); anyhow::bail!("git config {k} failed: {stderr}"); } } for args in [ vec!["add", "README.md"], vec!["commit", "-m", "init: seed README"], ] { let out = tokio::process::Command::new("git") .args(["-C", LOCAL_DIR].iter().chain(args.iter())) .output() .await .with_context(|| format!("git {args:?}"))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); anyhow::bail!("git {args:?} failed: {stderr}"); } } let url = forge_git_url(&format!("{ORG}/{REPO}")); let out = crate::lifecycle::git_command_authed(&core_auth_header(core_token)) .args(["-C", LOCAL_DIR, "push", &url, "HEAD:main"]) .output() .await .context("git push knowledge README")?; if out.status.success() { tracing::info!("knowledge: seeded README.md and pushed to {ORG}/{REPO}"); Ok(()) } else { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); anyhow::bail!("git push {ORG}/{REPO} failed: {stderr}") } } /// Delete this hive's own `internal/knowledge` push webhook if it is /// still registered, so the swarm controller is the only party holding /// one. /// /// # Why this is a migration and not just a deletion /// /// Not registering any more fixes nothing on a hive that has already /// run: the hook it created persists on the forge, so the contention /// this removes would survive on exactly the deployments that have it /// while fresh installs looked fixed. The hive that created a hook is /// the one that removes it. /// /// # It removes only its OWN hook, never a neighbour's /// /// The match is the full URL, not the `/webhook/knowledge` suffix. A /// hook with that suffix and a different base belongs to *another hive* — /// one that may not have been upgraded yet — and deleting it would break /// its knowledge sync until it was. Reaping a neighbour's registration is /// the very behaviour this issue is about; doing it in the name of fixing /// it would just invert the direction. /// /// (The predecessor did reap by suffix, to clear loopback hooks left by /// an older single-hive layout. That was safe when a hive was alone on /// its forge and is not safe now.) /// /// A listing failure is an error rather than a silent skip: there is no /// create attempt left to fall through to, so swallowing it would leave /// the hook in place with nothing said. The caller logs and continues — /// boot does not depend on this. pub async fn remove_webhook(core_token: &str, hive_domain: &str) -> Result<()> { // The typed client carries no per-request timeout, so each call is // wrapped in one: this runs as a detached startup task, and a forge // that accepts connections but never answers would otherwise hang it // forever. const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); let own_url = format!("https://{hive_domain}/webhook/knowledge"); let client = crate::forge::api(core_token)?; let hooks = tokio::time::timeout(HTTP_TIMEOUT, client.repo_list_hooks(ORG, REPO).all()) .await .map_err(anyhow::Error::from) .and_then(|r| r.map_err(anyhow::Error::from)) .with_context(|| format!("list webhooks for {ORG}/{REPO}"))?; for h in &hooks { let hook_url = h .config .as_ref() .and_then(|c| c.get("url")) .map_or("", String::as_str); if hook_url == own_url && let Some(id) = h.id { tokio::time::timeout(HTTP_TIMEOUT, client.repo_delete_hook(ORG, REPO, id).send()) .await .map_err(anyhow::Error::from) .and_then(|r| r.map_err(anyhow::Error::from)) .with_context(|| format!("delete webhook {id} for {ORG}/{REPO}"))?; tracing::info!( %own_url, "knowledge: removed this hive's push webhook — the swarm controller owns it now" ); } } Ok(()) } /// Current `HEAD` sha of the local clone, best-effort. `None` on any /// failure (used only to detect whether a pull actually moved `HEAD` — /// worth skipping the broadcast over, not worth failing the pull for). async fn head_sha() -> Option { let out = tokio::process::Command::new("git") .args(["-C", LOCAL_DIR, "rev-parse", "HEAD"]) .output() .await .ok()?; out.status .success() .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) } /// Pull the latest changes in the local clone. Called from the webhook /// handler on every push to `internal/knowledge` main, and periodically /// from `main.rs` as a fallback. Uses `--ff-only` so a force-push to /// the knowledge repo never wedges the local copy silently. /// /// When the pull actually moves `HEAD` (a real change, not a no-op), /// broadcasts a short `git diff --stat` summary to every live agent's /// inbox via `coord` — inbox-only, no forced wake, and shared by both /// call sites since the broadcast lives in here rather than in each /// caller. pub async fn pull(coord: &Coordinator) -> Result<()> { // Sanity: if the clone is missing (e.g. storage was wiped), refuse // to pull and let the caller decide whether to re-clone. let git_dir = std::path::Path::new(LOCAL_DIR).join(".git"); if !git_dir.exists() { anyhow::bail!("knowledge: {LOCAL_DIR}/.git not found — clone first"); } // Rewrite any credentialed `origin` left by an older clone, which spliced // `core:@` into the URL and persisted it in `.git/config` — the one // place this repo stored a token in a named remote, against the rule // `forge::repos::push_config` states. Harmless to repeat once the URL is // already clean. let plain = crate::forge::forge_git_url(&format!("{ORG}/{REPO}")); let _ = crate::lifecycle::git_command() .args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain]) .output() .await; // Discard any local drift before pulling — both tracked (`reset --hard`) // and untracked (`clean -fd`). Nothing in this codebase writes to // `LOCAL_DIR` after the initial clone (`seed_readme` runs once, at // creation, before any pull) — this working tree exists to mirror // `origin/main`, not to be edited in place. A tracked file left dirty // by any other means (a stray manual edit on the host, an interrupted // prior operation, or — the actual root cause here — two unsynchronized // boot-time pull callers racing on this same working tree, since fixed // in `main.rs`) would otherwise abort the `--ff-only` merge below with // "local changes would be overwritten"; an untracked file left behind // the same ways aborts it with "untracked working tree files would be // overwritten" instead — same wedge, just `clean`'s failure message // rather than `reset`'s. Either way it wedges every future pull // identically until someone notices and resets it by hand. Best-effort: // a failure here surfaces through the pull's own error below rather // than needing its own branch. let _ = crate::lifecycle::git_command() .args(["-C", LOCAL_DIR, "reset", "--hard", "HEAD"]) .output() .await; let _ = crate::lifecycle::git_command() .args(["-C", LOCAL_DIR, "clean", "-fd"]) .output() .await; let before = head_sha().await; // The repo is public (`ensure_knowledge_repo` makes it so), so an // unauthenticated pull is enough — but authenticate when a token is around, // which keeps this working if the repo is ever made private again. let auth = crate::forge::core_token().map(|t| core_auth_header(&t)); let mut cmd = match &auth { Some(header) => crate::lifecycle::git_command_authed(header), None => crate::lifecycle::git_command(), }; // Remote and branch are named explicitly. A bare `git pull` merges // whatever `branch..merge` lists, and a clone with more than one // such entry aborts with "Cannot fast-forward to multiple branches" — // taking /knowledge out across the hive over local config this daemon // never writes and cannot see. `origin` exists by construction: the // `remote set-url` above just set it. let out = cmd .args(["-C", LOCAL_DIR, "pull", "--ff-only", "origin", "main"]) .output() .await .context("git pull knowledge")?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); anyhow::bail!("git pull {ORG}/{REPO} failed: {stderr}") } let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned(); tracing::info!(result = %stdout, "knowledge: pull succeeded"); let after = head_sha().await; if let (Some(before), Some(after)) = (before, after) && before != after { broadcast_change(coord, &before, &after).await; } Ok(()) } /// Diff `before..after` in the local clone and broadcast a one-line-per-file /// summary to every live agent. Best-effort — a diff or send failure is /// logged, never propagated (the pull itself already succeeded). async fn broadcast_change(coord: &Coordinator, before: &str, after: &str) { let diff = tokio::process::Command::new("git") .args([ "-C", LOCAL_DIR, "diff", "--stat", &format!("{before}..{after}"), ]) .output() .await; let stat = match diff { Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(), Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); tracing::warn!(%stderr, "knowledge: diff --stat failed; broadcasting without it"); String::new() } Err(e) => { tracing::warn!(error = ?e, "knowledge: diff --stat failed; broadcasting without it"); String::new() } }; let body = if stat.is_empty() { "[system] /knowledge updated — see the repo for what changed.".to_owned() } else { format!("[system] /knowledge updated:\n{stat}") }; let errors = coord.broadcast_send(hive_sh4re::manager::SYSTEM_SENDER, &body); if !errors.is_empty() { tracing::warn!(?errors, "knowledge: broadcast had per-agent failures"); } }