feat(#3255): hives stop owning the knowledge webhook, and clean up their own

A webhook has exactly one target URL, so every hive registering one
against the shared internal/knowledge repository was last-writer-wins
rather than idempotent: all but the most recent silently stopped
receiving deliveries. The swarm controller holds the single registration
and now addresses an event to each hive over the queue instead.

This is a migration, not 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 would survive on exactly the deployments
that have it while fresh installs looked fixed. The hive that created a
hook removes it.

It removes only its OWN, matched on the full URL rather than the
/webhook/knowledge suffix. A hook with that suffix and a different base
belongs to another hive, possibly one not yet upgraded, and deleting it
would break that hive's knowledge sync until it caught up. Reaping a
neighbour's registration is the behaviour being removed here; doing it
while fixing it would only 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. The hive-side registrars also acted as reapers
of hooks under their own path, which is why the swarm hook lives under
/webhook/forge/; removing this registrar removes that reaper too.
Intended, and stated because no reviewer would infer it from the diff.

The receive endpoint goes with it. A live HMAC-verified
/webhook/knowledge that nothing can legitimately reach would tell the
next reader that this is how a hive learns about knowledge changes.

Docs move in the same commit: docs/swarm/README.md said two hooks exist
per swarm-wide repo and neither should be deleted, which is now true for
agent-configs and wrong for internal/knowledge — a half-correct
description being worse than an uncorrected one.
This commit is contained in:
atlas 2026-08-19 19:25:30 +02:00 committed by mara
commit d2a550e685
6 changed files with 111 additions and 218 deletions

View file

@ -184,7 +184,6 @@ pub async fn serve(
.routes(routes!(schedules::post_schedule_resume))
.routes(routes!(schedules::post_schedule_fire_now))
.routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(webhook::post_webhook_knowledge))
.routes(routes!(webhook::post_webhook_config_pr))
.routes(routes!(approvals::post_approve))
.routes(routes!(approvals::post_deny))
@ -430,7 +429,6 @@ mod router_build_probe {
.routes(routes!(schedules::post_schedule_resume))
.routes(routes!(schedules::post_schedule_fire_now))
.routes(routes!(schedules::post_rebuild_queue_cancel))
.routes(routes!(webhook::post_webhook_knowledge))
.routes(routes!(webhook::post_webhook_config_pr))
.routes(routes!(approvals::post_approve))
.routes(routes!(approvals::post_deny))

View file

@ -1,12 +1,18 @@
//! Forgejo webhook endpoints.
//!
//! - **`/webhook/knowledge`** — push events on `internal/knowledge` trigger a
//! `git pull` on the local clone so agents see up-to-date docs.
//! - **`/webhook/config-pr`** — `pull_request` events on any `agent-configs/*`
//! repo queue a [`hive_sh4re::approvals::ApprovalKind::MergeConfigPr`] approval row
//! so the operator can review + approve the merge from the dashboard.
//!
//! Both endpoints are reached via the gateway (HTTPS, public domain URL) so
//! There was a second endpoint here, `/webhook/knowledge`, which pulled the
//! local `internal/knowledge` clone on push. It is gone along with the
//! per-hive registration that fed it: a webhook has exactly one target URL,
//! so every hive registering one against the shared repository was
//! last-writer-wins. The swarm controller now holds the single registration
//! and addresses an event to each hive over the queue, which
//! [`crate::workers::knowledge`] documents.
//!
//! The endpoint is reached via the gateway (HTTPS, public domain URL) so
//! Forgejo's SSRF guard does not block delivery. Each delivery is verified
//! against the `X-Hub-Signature-256` HMAC header Forgejo attaches; the
//! shared secret is auto-generated at startup and persisted to
@ -42,104 +48,6 @@ fn verify_hmac(state: &AppState, headers: &HeaderMap, body: &Bytes) -> Result<()
crate::webhook_secret::verify_signature(secret, body, sig).map_err(|e| e.to_string())
}
// ── knowledge webhook ──────────────────────────────────────────────────────────
/// Minimal Forgejo push-webhook payload — only the fields we care about.
#[derive(Deserialize)]
pub(super) struct PushWebhookPayload {
#[serde(rename = "ref")]
git_ref: Option<String>,
repository: Option<PushWebhookRepo>,
}
#[derive(Deserialize)]
pub(super) struct PushWebhookRepo {
full_name: Option<String>,
}
/// POST `/webhook/knowledge` — Forgejo push webhook for
/// `internal/knowledge`.
///
/// Runs `git pull` on the local clone so agents see up-to-date documents
/// on their next turn.
///
/// Expected Forgejo webhook configuration:
/// - URL: `https://<HYPERHIVE_HIVE_DOMAIN>/webhook/knowledge`
/// - Content type: `application/json`
/// - Event: "Push" (fires on merge commits to main as well)
/// - Secret: auto-generated HMAC key (see [`crate::webhook_secret`])
///
/// The gateway routes `/webhook/` → hive-c0re; the HMAC secret protects
/// the endpoint from unauthenticated callers.
#[utoipa::path(
post,
path = "/webhook/knowledge",
request_body(
content = String,
content_type = "application/json",
description = "Forgejo push-webhook payload, taken as raw bytes \
(not a typed extractor) so HMAC verification runs \
over the exact wire bytes before any JSON parsing"
),
responses(
(status = 200, description = "processed (pull triggered or ignored)", body = String),
(status = 400, description = "invalid JSON payload"),
(status = 401, description = "bad or missing HMAC signature"),
(status = 503, description = "HMAC secret unavailable at startup"),
),
tag = "webhook"
)]
pub(super) async fn post_webhook_knowledge(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if let Err(e) = verify_hmac(&state, &headers, &body) {
tracing::warn!("webhook/knowledge: HMAC verification failed: {e}");
let status = if e.contains("unavailable") {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::UNAUTHORIZED
};
return (status, e).into_response();
}
let payload = match serde_json::from_slice::<PushWebhookPayload>(&body) {
Ok(p) => p,
Err(e) => {
tracing::warn!("webhook/knowledge: JSON parse error: {e}");
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
}
};
let expected_repo = format!("{}/{}", crate::knowledge::ORG, crate::knowledge::REPO);
let full_name = payload
.repository
.as_ref()
.and_then(|r| r.full_name.as_deref())
.unwrap_or("");
if full_name != expected_repo {
tracing::debug!(
full_name,
"webhook/knowledge: ignoring push from unexpected repo"
);
return (StatusCode::OK, "ignored").into_response();
}
let git_ref = payload.git_ref.as_deref().unwrap_or("");
if git_ref != "refs/heads/main" {
tracing::debug!(git_ref, "webhook/knowledge: ignoring non-main push");
return (StatusCode::OK, "ignored").into_response();
}
tracing::info!("webhook/knowledge: pull triggered by push to {expected_repo}");
let coord = state.coord.clone();
tokio::spawn(async move {
if let Err(e) = crate::knowledge::pull(&coord).await {
tracing::warn!(error = ?e, "webhook/knowledge: pull failed");
}
});
(StatusCode::OK, "ok").into_response()
}
// ── config-PR webhook ──────────────────────────────────────────────────────────
/// Minimal Forgejo `pull_request`-webhook payload.