353 lines
15 KiB
Rust
353 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. 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
|
|
|
|
<!-- 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}")
|
|
}
|
|
}
|
|
|
|
/// 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<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;
|
|
// 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.<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");
|
|
}
|
|
}
|