Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/workers/knowledge.rs
atlas 3b9c004a0c fix(c0re): name the remote and branch on the knowledge pull
A bare `git pull --ff-only` merges whatever `branch.<current>.merge`
lists. A clone carrying more than one such entry aborts with "Cannot
fast-forward to multiple branches", which takes /knowledge out for every
agent on the hive - and the daemon neither writes that config nor can
see it, so the call worked only for as long as it happened to stay
clean.

Reproduced against two throwaway bare repos: duplicate merge entries
give that exact fatal, exit 128. A detached HEAD gives a different
error, and a missing tracking config does not reproduce at all - the
clone of an empty repo does write the tracking entry, so my first
explanation was wrong.

Naming origin and main makes the pull independent of local branch
config: a stray entry degrades to "the pull did not pick it up" instead
of breaking the shared mount. Does not explain how a second entry
appeared; nothing in this tree writes branch config.
2026-08-14 19:50:56 +02:00

357 lines
15 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 std::collections::BTreeMap;
use anyhow::{Context, Result};
use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType};
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
<!-- 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")?;
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}")
}
}
/// 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.
///
/// `hive_domain` is the public domain name of the hive; the webhook URL is
/// `https://<hive_domain>/webhook/knowledge` (routed through the gateway,
/// avoiding the Forgejo SSRF guard that blocks loopback delivery).
///
/// `webhook_secret` is the HMAC secret Forgejo will attach as
/// `X-Hub-Signature-256` on each delivery; hive-c0re verifies this header
/// in the dashboard webhook handler (`post_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,
hive_domain: &str,
webhook_secret: &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 (and the hourly pull fallback masks the missing hook).
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
let target_url = format!("https://{hive_domain}/webhook/knowledge");
let client = crate::forge::api(core_token)?;
// List existing hooks — skip creation if ours is already there.
// Best-effort like the raw-HTTP predecessor: a listing failure
// falls through to the create attempt.
let listed = 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));
match listed {
Ok(hooks) => {
let already_exists = hooks.iter().any(|h| {
h.config
.as_ref()
.and_then(|c| c.get("url"))
.map(String::as_str)
== Some(target_url.as_str())
});
if already_exists {
tracing::debug!(%target_url, "knowledge: push webhook already configured");
return Ok(());
}
// Delete stale hooks that point at our path but a different base
// (e.g. old loopback hooks from before the SSRF-bypass migration).
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.ends_with("/webhook/knowledge")
&& hook_url != target_url
&& let Some(id) = h.id
{
tracing::info!(hook_url, "knowledge: deleting stale webhook (wrong base)");
let _ = tokio::time::timeout(
HTTP_TIMEOUT,
client.repo_delete_hook(ORG, REPO, id).send(),
)
.await;
}
}
}
Err(e) => {
tracing::debug!(error = %e, "knowledge: listing hooks failed; attempting create");
}
}
// Create the webhook.
let mut additional = BTreeMap::new();
additional.insert("secret".to_owned(), webhook_secret.to_owned());
let hook = CreateHookOption {
active: Some(true),
authorization_header: None,
branch_filter: None,
config: CreateHookOptionConfig {
content_type: "json".to_owned(),
url: url::Url::parse(&target_url).context("parse webhook target url")?,
additional,
},
events: Some(vec!["push".to_owned()]),
r#type: CreateHookOptionType::Forgejo,
};
tokio::time::timeout(HTTP_TIMEOUT, client.repo_create_hook(ORG, REPO, hook))
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| format!("create webhook for {ORG}/{REPO}"))?;
tracing::info!(%target_url, "knowledge: push webhook created");
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<String> {
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:<token>@` 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;
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.<current>.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");
}
}