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.
This commit is contained in:
damocles 2026-06-04 09:52:32 +02:00 committed by mara
commit 78db47b00b
2 changed files with 112 additions and 6 deletions

View file

@ -7,7 +7,9 @@
//! 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.
//! 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};
@ -133,10 +135,77 @@ async fn seed_readme(core_token: &str) -> Result<()> {
}
}
/// 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. Uses
/// `--ff-only` so a force-push to the knowledge repo never wedges the
/// local copy silently.
/// 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.

View file

@ -13,8 +13,8 @@ use hive_sh4re::{HostRequest, HostResponse};
use hive_c0re::coordinator::Coordinator;
use hive_c0re::{
agent_sockets, auto_update, bash_tasks_vacuum, broker, client, crash_watch, dashboard,
dashboard_events, events_vacuum, forge, manager_server, matrix, migrate, rebuild_queue,
reminder_scheduler, scheduled_prompts_worker, server, stats_vacuum,
dashboard_events, events_vacuum, forge, knowledge, manager_server, matrix, migrate,
rebuild_queue, reminder_scheduler, scheduled_prompts_worker, server, stats_vacuum,
};
#[derive(Parser)]
@ -262,6 +262,43 @@ async fn cmd_serve(
tokio::spawn(async move {
forge::ensure_all().await;
});
// Knowledge webhook setup: ensure the Forgejo push webhook for
// `internal/knowledge` exists so `pull()` fires on merge. Runs
// after forge::ensure_all so the core token + repo are present.
// No-op when the core token or forge are absent.
let webhook_port = dashboard_port;
tokio::spawn(async move {
if let Some(token) = forge::core_token() {
if let Err(e) = knowledge::ensure_webhook(&token, webhook_port).await {
tracing::warn!(error = ?e, "knowledge: ensure_webhook failed");
}
}
});
// Knowledge periodic pull: hourly fallback in case the webhook is
// missed (e.g. hive-c0re was down during a push). First fires at
// startup (immediate pull after the clone is already present).
let mut knowledge_shutdown = coord.shutdown_rx();
tokio::spawn(async move {
// Initial pull — reconcile any commits that landed while c0re
// was offline, including the push that triggered #1244.
if let Err(e) = knowledge::pull().await {
tracing::debug!(error = ?e, "knowledge: startup pull skipped (no clone yet?)");
}
let interval = std::time::Duration::from_hours(1);
loop {
tokio::select! {
() = tokio::time::sleep(interval) => {
if let Err(e) = knowledge::pull().await {
tracing::warn!(error = ?e, "knowledge: periodic pull failed");
}
}
_ = knowledge_shutdown.changed() => {
tracing::info!("knowledge pull: shutdown signal received");
break;
}
}
}
});
// Matrix user sweep: same shape — ensure every container has
// an account on the local matrix-tuwunel homeserver with an
// access_token persisted to `<state>/matrix-token`. No-op when