hyperhive/hive-c0re/src/knowledge.rs
damocles 78db47b00b fix: auto-create knowledge webhook + periodic pull fallback
hive-c0re now auto-creates the Forgejo push webhook for
internal/knowledge at startup (ensure_webhook). this is what was
missing — the webhook endpoint existed but was never registered in
forgejo, so merging iris's PR didn't trigger a pull.

also adds a periodic hourly pull as a fallback (and an immediate pull
at startup to reconcile commits that landed while c0re was offline).

fixes #1244.
2026-06-04 12:12:20 +02:00

229 lines
9.1 KiB
Rust

//! 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. A Forgejo webhook notifies it
//! on push to main so agents always see an up-to-date snapshot. The
//! webhook is auto-created by [`ensure_webhook`] at startup. A
//! periodic pull in `main.rs` provides a fallback cadence.
use anyhow::{Context, Result};
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.
pub const LOCAL_DIR: &str = "/var/lib/hyperhive/knowledge";
/// 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 ToC 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
<!-- Add an entry here each time you create a new document:
- [Title](path/to/file.md) - one-line description
-->
";
/// 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")?;
// Embed credentials in the URL — safe for localhost-only forge.
let url = format!("http://core:{core_token}@localhost:3000/{ORG}/{REPO}.git");
let out = tokio::process::Command::new("git")
.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 {:?} failed: {stderr}", args);
}
}
let url = format!("http://core:{core_token}@localhost:3000/{ORG}/{REPO}.git");
let out = tokio::process::Command::new("git")
.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}")
}
}
/// Ensure a Forgejo push webhook for `internal/knowledge` exists and
/// points at hive-c0re's `/webhook/knowledge` endpoint. Idempotent —
/// lists existing hooks first and skips creation when one is already
/// targeting the correct URL. `dashboard_port` is the TCP port
/// hive-c0re's dashboard listens on (default 7000); the webhook URL
/// is `http://127.0.0.1:<port>/webhook/knowledge`.
///
/// Called at startup alongside [`ensure_local_clone`]. No-op when the
/// core token is absent (forge not yet provisioned).
pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> Result<()> {
const FORGE_HTTP: &str = "http://localhost:3000";
let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/knowledge");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("build reqwest client for webhook setup")?;
// List existing hooks — skip creation if ours is already there.
let list_url = format!("{FORGE_HTTP}/api/v1/repos/{ORG}/{REPO}/hooks");
let resp = client
.get(&list_url)
.header("Authorization", format!("token {core_token}"))
.send()
.await
.with_context(|| format!("GET {list_url}"))?;
if resp.status().is_success() {
let hooks: Vec<serde_json::Value> = resp.json().await.unwrap_or_default();
let already_exists = hooks.iter().any(|h| {
h.get("config")
.and_then(|c| c.get("url"))
.and_then(|u| u.as_str())
== Some(&target_url)
});
if already_exists {
tracing::debug!(%target_url, "knowledge: push webhook already configured");
return Ok(());
}
}
// Create the webhook.
let create_url = format!("{FORGE_HTTP}/api/v1/repos/{ORG}/{REPO}/hooks");
let body = serde_json::json!({
"type": "forgejo",
"config": {
"url": target_url,
"content_type": "json"
},
"events": ["push"],
"active": true
});
let resp = client
.post(&create_url)
.header("Authorization", format!("token {core_token}"))
.json(&body)
.send()
.await
.with_context(|| format!("POST {create_url}"))?;
let status = resp.status();
if status.is_success() {
tracing::info!(%target_url, "knowledge: push webhook created");
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("create webhook for {ORG}/{REPO} failed ({status}): {body}")
}
}
/// 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.
pub async fn pull() -> 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");
}
let out = tokio::process::Command::new("git")
.args(["-C", LOCAL_DIR, "pull", "--ff-only"])
.output()
.await
.context("git pull knowledge")?;
if out.status.success() {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned();
tracing::info!(result = %stdout, "knowledge: pull succeeded");
Ok(())
} else {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
anyhow::bail!("git pull {ORG}/{REPO} failed: {stderr}")
}
}